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,224 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user