Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviSaver.php
T
janniclas.loosenandClaude Opus 5.5 b1dd35abd8 feat: Divi tools edit Theme Builder layouts; visible layouts need confirm
TB layouts load through ThemeBuilderAccess (P12), live layouts (attached +
enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point
of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are
off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22),
also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags
and clears site CSS + TB cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:45:07 +02:00

246 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Save pipeline of spec §5.6, steps 3–5: restore point and wp_update_post() with wp_slash() (the
* status stays), Divi 5 flags (DiviFlags), Divi's static CSS cache. A failure after saving is
* only a warning (FollowUp); a filter that changed the stored content is reported as well.
*
* kses (spec D7): for users without unfiltered_html WordPress' kses would run
* filter_block_content(), which re-parses and re-serializes EVERY block – untouched content
* would no longer be byte-identical (spec §1.3, §5.2) and global modules could break. kses'
* filters (kses_remove_filters()) are therefore switched off – only when they were active – and
* restored in finally (kses_init_filters()) right after wp_update_post() returns, before flags,
* cache and every other follow-up. Every value the Divi tools write was sanitized and every
* $variable(…)$ re-serialized by the plugin before (ValueCleaner, VariableRef, WpValueSanitizer).
*
* The window (review M-5): kses_remove_filters() removes ALL core kses filters – post content,
* title, excerpt, content_filtered and comments (pre_comment_content) – while exactly two calls
* run: RevisionPoint::capture() (ruling S37: the restore point copies the STORED content; kses
* would strip an admin's embeds from it, so undoing via that revision would lose them) and
* wp_update_post() with every hook it fires: wp_insert_post_data, save_post(_{type}),
* wp_insert_post, post_updated (→ wp_save_post_revision / _wp_put_post_revision),
* transition_post_status, wp_after_insert_post and third-party listeners. No MCP input reaches
* those hooks except the content itself (already cleaned); anything a third-party hook writes
* there is saved without kses, as it would be for an administrator. The rollback write in
* restore() uses the same window with the previous stored content; saveNew() (the first layout
* of a new post – a draft of lit/content-create or a library entry of lit/library-save) uses it
* for its single wp_update_post().
*
* Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth):
* Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data
* AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and
* DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first
* two re-serialize the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such
* pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings
* S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md.
*
* SaveScope: library entries get no Divi flags; a global (or referenced) entry additionally clears
* the static CSS of every page and the Theme Builder cache; a Theme Builder layout gets the Divi flags
* and clears like a global entry.
*
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
* Not final so abilities can be tested with a mock.
*/
class DiviSaver
{
public const REASON = 'Die Divi-Seite konnte nicht gespeichert werden.';
public const CHANGED_ON_SAVE = 'WordPress oder ein Plugin hat den Inhalt beim Speichern verändert; tree_rev '
. 'beschreibt den gespeicherten Stand – vor weiteren Änderungen lit-divi-outline lesen.';
/**
* @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed
*/
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
$revisionId = null;
try {
// Ruling S37: the restore point is captured inside the same kses window.
$result = $this->writeContent($postId, $content, static function () use ($postId, &$revisionId): void {
$revisionId = RevisionPoint::capture($postId);
});
} catch (Throwable $e) {
$this->restore($postId, (string) $post->post_content, $revisionId, $e);
}
$refusal = self::refusal($result);
if ($refusal !== null) {
throw WriteFailed::rolledBack(
self::REASON,
detail: sprintf('wp_update_post(%d) refused: %s', $postId, $refusal),
state: self::keeping($revisionId),
postId: $postId,
revisionId: $revisionId
);
}
$warnings = [];
if ($scope->setsFlags()) {
FollowUp::run(
static fn () => DiviFlags::set($postId),
'divi builder flags',
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
. 'einmal im Divi-Builder öffnen und speichern.',
$warnings
);
}
FollowUp::run(
static function () use ($postId, $scope): void {
if (! class_exists('ET_Core_PageResource')) {
return;
}
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
// A global library entry is part of every page that shows it (spec §5.6 step 5).
if ($scope->clearsSiteCss()) {
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
if (function_exists('et_theme_builder_clear_wp_cache')) {
et_theme_builder_clear_wp_cache('all');
}
}
},
'divi static css',
'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. '
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
$warnings
);
$stored = get_post($postId);
if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) {
$warnings[] = self::CHANGED_ON_SAVE;
}
return ['revision_id' => $revisionId, 'warnings' => $warnings];
}
/**
* The first content of a brand-new post: a draft of lit/content-create with divi or a library
* entry of lit/library-save, both inserted with their normal WordPress fields first (title and
* excerpt went through kses as usual). Same kses window as save(), holding only this
* wp_update_post() and its hooks; no restore point (the post has no earlier state) and no
* static CSS to clear. With SaveScope::Page the Divi flags are part of the write (without them
* a new page is no Divi layout); SaveScope::LibraryItem sets none. Every failure throws – the
* caller deletes the new post (all or nothing).
*
* @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException
*/
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
$refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
}
if ($scope->setsFlags()) {
DiviFlags::set($postId);
}
$stored = get_post($postId);
return $stored instanceof WP_Post && (string) $stored->post_content !== $content ? [self::CHANGED_ON_SAVE] : [];
}
/**
* The one guarded content write: wp_update_post() (wp_slash, status unchanged) of $content in
* the kses window. $before runs first inside the same window – save() captures the restore
* point there (ruling S37); nothing else ever runs in it (see the class docblock).
*
* @param (callable(): void)|null $before
*/
private function writeContent(int $postId, string $content, ?callable $before = null): int|WP_Error
{
return $this->withoutKses(static function () use ($postId, $content, $before): int|WP_Error {
if ($before !== null) {
$before();
}
return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true);
});
}
/** Why wp_update_post() refused ($result WP_Error or 0), null when it saved. */
private static function refusal(int|WP_Error $result): ?string
{
if ($result instanceof WP_Error) {
return $result->get_error_message();
}
return $result === 0 ? 'returned 0' : null;
}
/**
* Runs $write with kses' filters off for users without unfiltered_html (D7); restored in
* finally, right when $write returns or throws.
*
* @param callable(): (int|WP_Error) $write
*/
private function withoutKses(callable $write): int|WP_Error
{
$kses = ! current_user_can('unfiltered_html')
&& has_filter('content_save_pre', 'wp_filter_post_kses') !== false;
if ($kses) {
kses_remove_filters();
}
try {
return $write();
} finally {
if ($kses) {
kses_init_filters();
}
}
}
/** @throws WriteFailed Always. */
private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never
{
$detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage());
$failures = Rollback::attempt(function () use ($postId, $before): void {
PostGuard::assertNoMissedSchedule($postId, 'restoring would publish a missed schedule');
if (self::refusal($this->writeContent($postId, $before)) !== null) {
throw new RuntimeException('previous content could not be written back');
}
});
if ($failures !== []) {
$detail .= '; rollback: ' . $failures[0];
throw WriteFailed::partial(self::REASON, $cause, $detail, $postId, $revisionId);
}
throw WriteFailed::rolledBack(
self::REASON,
$cause,
$detail,
state: self::keeping($revisionId),
postId: $postId,
revisionId: $revisionId
);
}
/** write_failed must not claim "nothing was saved" while the restore-point revision stays. */
private static function keeping(?int $revisionId): ?string
{
return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId));
}
}