Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviWriter.php
T
janniclas.loosenandClaude Opus 5.5 7762b76305 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>
2026-09-26 14:56:47 +02:00

225 lines
8.3 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\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\WordPress\PostChangeResult;
use WP_Error;
/**
* The shared frame of every Divi write (spec §5.6): load with rights and the tree_rev lock
* (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. Not final so abilities
* can be tested with a mock.
*/
class DiviWriter
{
public const OUTLINE_TRUNCATED = 'Die Übersicht der geänderten Abschnitte ist gekürzt; vollständig mit '
. 'lit-divi-outline lesen.';
public function __construct(
private DiviPageLoader $loader,
private DiviSaver $saver,
private ChangeLog $log,
private DesignSystemReader $design,
private DiviComposition $composition
) {
}
/** @return array<string, array<string, mixed>> post_id and tree_rev of every Divi write. */
public static function inputProperties(): array
{
return [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'tree_rev' => [
'type' => 'string',
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
],
];
}
/** @return array<string, array<string, mixed>> Target position of insert and move. */
public static function positionProperties(): array
{
return [
'parent' => [
'type' => 'string',
'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: '
. 'Ende der Seite.',
],
'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'],
'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'],
'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'],
];
}
/**
* The page to change, after rights, type, layout and the tree_rev lock were checked.
*
* @param array<string, mixed> $input
*/
public function page(array $input): DiviPage|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, true);
if ($page instanceof WP_Error) {
return $page;
}
$problem = $this->loader->writable($page);
if ($problem !== null) {
return $problem;
}
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
return Errors::revMismatch(
'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde '
. 'geschrieben.',
DiviPageLoader::OUTLINE_HINT
);
}
return $page;
}
/**
* The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$
* references are checked against Divi's dynamic content options for it (ruling S22).
*/
public function composition(DiviPage $page): DiviComposition
{
return $this->composition->forPost((int) $page->post->ID);
}
/** Section label around $block, for automatic admin labels ("" when none). */
public static function sectionLabel(NodeIndex $index, ?Block $block): string
{
for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) {
if ($node->name() === 'divi/section') {
return NodeSummary::label($node);
}
}
return '';
}
/**
* Saves the changed tree of $page and returns the ToolResult array. Call it only when the
* whole input validated – every error before means nothing is written.
*
* @param array<string, mixed> $input
* @return array<string, mixed>
* @throws WriteFailed
*/
public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array
{
$post = $page->post;
$content = Serializer::document($page->document);
if ($content === (string) $post->post_content) {
return ToolResult::ok('Keine Änderung – die Seite ist bereits so.')
->rev(Rev::ofPost($post))
->with('tree_rev', $page->treeRev())
->warnings($change->warnings())
->toArray();
}
$index = NodeIndex::build($page->document);
$quality = $this->composition->warnings()->collect($index, $change->touched());
$warnings = array_merge($change->warnings(), $quality);
try {
$saved = $this->saver->save($post, $content);
} catch (WriteFailed $e) {
if (! $e->rolledBack) {
$this->log->recordPartial(
$ability,
(string) $post->post_type,
(string) $post->ID,
sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title),
$e->revisionId
);
}
throw $e;
}
$outline = $this->outline($index, $change);
if ($outline['truncated']) {
$warnings[] = self::OUTLINE_TRUNCATED;
}
$result = (new PostChangeResult(
$ability,
$this->log,
(int) $post->ID,
'die Änderung',
array_merge($warnings, $saved['warnings'])
))->afterSave($input);
$updated = $result->reread($post) ?? $post;
$summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary);
$result->log((string) $post->post_type, $summary, $saved['revision_id']);
$status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status);
$tool = $result->result($status, $updated)
->revisionId($saved['revision_id'])
->outline($outline['text'])
->with('tree_rev', Rev::of((string) $updated->post_content))
->urls((string) get_preview_post_link($updated), $page->editUrl());
foreach ($change->entries() as $entry) {
$id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
$tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']);
}
return $tool->toArray();
}
/**
* Outline of the sections that contain changed nodes (breadth limit of ruling S18).
*
* @return array{text: ?string, truncated: bool}
*/
private function outline(NodeIndex $index, DiviChange $change): array
{
$sections = [];
foreach ($change->entries() as $entry) {
$id = $entry['block'] !== null ? $index->idOf($entry['block']) : null;
if ($id !== null) {
$sections[explode('.', $id)[0]] = true;
}
}
if ($sections === []) {
return ['text' => null, 'truncated' => false];
}
$outline = new Outline($this->design->read()->presetNames());
$texts = [];
$truncated = false;
foreach (array_keys($sections) as $sectionId) {
$texts[] = $outline->text($outline->rows($index, (string) $sectionId));
$truncated = $truncated || $outline->wasTruncated();
}
return ['text' => implode("\n", $texts), 'truncated' => $truncated];
}
private static function parentBlock(NodeIndex $index, Block $block): ?Block
{
$parent = $index->parentOf($block);
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
}
}