feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult
DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4), WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live registry (S22), S31 wiring, S35 control strip at apply() entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<?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\WriteFailed;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* Save pipeline of spec §5.6, steps 3–5: restore point, 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 for exactly this call and restored
|
||||
* in finally (kses_init_filters()) – only when they were active. Every value the Divi tools
|
||||
* write was sanitized and every $variable(…)$ re-serialized by the plugin before (AttrWriter,
|
||||
* VariableRef, WpValueSanitizer).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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): array
|
||||
{
|
||||
$postId = (int) $post->ID;
|
||||
$revisionId = RevisionPoint::capture($postId);
|
||||
|
||||
try {
|
||||
$result = $this->update($postId, $content);
|
||||
} catch (Throwable $e) {
|
||||
$this->restore($postId, (string) $post->post_content, $revisionId, $e);
|
||||
}
|
||||
|
||||
if ($result instanceof WP_Error || $result === 0) {
|
||||
$message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0';
|
||||
throw new WriteFailed(
|
||||
self::REASON,
|
||||
true,
|
||||
$postId,
|
||||
$revisionId,
|
||||
null,
|
||||
sprintf('wp_update_post(%d) refused: %s', $postId, $message),
|
||||
rolledBackState: self::keeping($revisionId)
|
||||
);
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
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): void {
|
||||
if (class_exists('ET_Core_PageResource')) {
|
||||
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
|
||||
}
|
||||
},
|
||||
'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];
|
||||
}
|
||||
|
||||
/** wp_update_post() with kses' filters off for users without unfiltered_html (D7). */
|
||||
private function update(int $postId, string $content): 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 wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true);
|
||||
} 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());
|
||||
try {
|
||||
$current = get_post($postId);
|
||||
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
|
||||
throw new RuntimeException('restoring would publish a missed schedule');
|
||||
}
|
||||
|
||||
$restored = $this->update($postId, $before);
|
||||
if ($restored instanceof WP_Error || $restored === 0) {
|
||||
throw new RuntimeException('previous content could not be written back');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$detail .= '; rollback: ' . $e->getMessage();
|
||||
throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail);
|
||||
}
|
||||
|
||||
throw new WriteFailed(
|
||||
self::REASON,
|
||||
true,
|
||||
$postId,
|
||||
$revisionId,
|
||||
$cause,
|
||||
$detail,
|
||||
rolledBackState: self::keeping($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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user