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>
246 lines
11 KiB
PHP
246 lines
11 KiB
PHP
<?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));
|
||
}
|
||
}
|