refactor: small Divi tree helpers out of DiviWriter (M3)

Block::isPlaceholder replaces the divi/placeholder literals (ledger T4);
NodeIndex::parentBlock/sectionLabel replace the two parentBlock copies and
DiviWriter::sectionLabel; DiviChange::idOf and Outline::summary replace the
duplicated id resolution and outline text; DiviSchema holds the shared
input properties. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 18:24:29 +02:00
co-authored by Claude Opus 5.5
parent ecfece719d
commit d2cc82f31b
18 changed files with 188 additions and 86 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
/**
@@ -74,6 +75,17 @@ final class DiviChange
return $this->changed;
}
/**
* Path ID of an entry in the tree after the change: a removed node keeps its ID from before
* the removal; a surviving node is looked up in $index (null if it is not there).
*
* @param array{block: ?Block, id: ?string, type: string, action: string, label: string} $entry
*/
public static function idOf(array $entry, NodeIndex $index): ?string
{
return $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
}
/** @return list<Block> */
public function touched(): array
{
@@ -71,15 +71,14 @@ final class DiviContentComposer implements ContentComposer
$index = NodeIndex::build($layout['document']);
$warnings = $composition->warnings()->collect($index, $layout['blocks']);
$outline = new Outline($this->design->read()->presetNames());
$text = $outline->text($outline->rows($index));
if ($outline->wasTruncated()) {
$outline = (new Outline($this->design->read()->presetNames()))->summary($index);
if ($outline['truncated']) {
$warnings[] = DiviWriter::OUTLINE_TRUNCATED;
}
$saved = $this->saver->saveNew($postId, Serializer::document($layout['document']));
return ['warnings' => array_merge($warnings, $saved), 'outline' => $text];
return ['warnings' => array_merge($warnings, $saved), 'outline' => (string) $outline['text']];
}
/**
@@ -38,7 +38,7 @@ final class DiviDeleteAbility extends DiviWriteAbility
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'properties' => array_merge(DiviSchema::write(), [
'ids' => [
'type' => 'array',
'minItems' => 1,
@@ -34,7 +34,7 @@ final class DiviDuplicateNodeAbility extends DiviWriteAbility
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'properties' => array_merge(DiviSchema::write(), [
'id' => ['type' => 'string', 'description' => 'Pfad-ID des zu duplizierenden Knotens.'],
]),
'required' => ['post_id', 'tree_rev', 'id'],
@@ -39,7 +39,7 @@ final class DiviInsertAbility extends DiviWriteAbility
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'properties' => array_merge(DiviSchema::write(), [
'nodes' => [
'type' => 'array',
'minItems' => 1,
@@ -48,7 +48,7 @@ final class DiviInsertAbility extends DiviWriteAbility
'description' => 'Einzufügende Knoten in Reihenfolge; Abschnitte mit rows/modules, Zeilen mit '
. 'columns und modules, Module mit Kurzform-Feldern, items (Kind-Module) und attrs.',
],
], DiviWriter::positionProperties()),
], DiviSchema::position()),
'required' => ['post_id', 'tree_rev', 'nodes'],
'additionalProperties' => false,
];
@@ -68,7 +68,7 @@ final class DiviInsertAbility extends DiviWriteAbility
return DiviErrors::position($position);
}
$context = DiviWriter::sectionLabel($page->index, $position['parent']);
$context = $page->index->sectionLabel($position['parent']);
$nodes = is_array($input['nodes'] ?? null) ? array_values($input['nodes']) : [];
if ($nodes === []) {
return Errors::invalidInput('Keine Knoten angegeben; nichts wurde gespeichert.', [
@@ -35,9 +35,9 @@ final class DiviMoveAbility extends DiviWriteAbility
return [
'type' => 'object',
'properties' => array_merge(
DiviWriter::inputProperties(),
DiviSchema::write(),
['id' => ['type' => 'string', 'description' => 'Pfad-ID des zu verschiebenden Knotens.']],
DiviWriter::positionProperties()
DiviSchema::position()
),
'required' => ['post_id', 'tree_rev', 'id'],
'additionalProperties' => false,
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
/** Input schema properties the Divi write tools share. */
final class DiviSchema
{
/** @return array<string, array<string, mixed>> post_id and tree_rev of every Divi write. */
public static function write(): 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 position(): 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).'],
];
}
}
@@ -41,7 +41,7 @@ final class DiviUpdateAbility extends DiviWriteAbility
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'properties' => array_merge(DiviSchema::write(), [
'changes' => [
'type' => 'array',
'minItems' => 1,
+6 -61
View File
@@ -9,9 +9,7 @@ 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;
@@ -40,33 +38,6 @@ class DiviWriter
) {
}
/** @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.
*
@@ -104,18 +75,6 @@ class DiviWriter
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.
@@ -178,15 +137,16 @@ class DiviWriter
->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']);
$tool->changed(DiviChange::idOf($entry, $index) ?? '?', $entry['type'], $entry['action'], $entry['label']);
}
return $tool->toArray();
}
/**
* Outline of the sections that contain changed nodes (breadth limit of ruling S18).
* Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed
* node has no block any more, but its ID from before the removal still names the (surviving)
* section.
*
* @return array{text: ?string, truncated: bool}
*/
@@ -194,9 +154,7 @@ class DiviWriter
{
$sections = [];
foreach ($change->entries() as $entry) {
// Same id resolution as the "changed" entries below: a removed node has no block
// any more, but its id from before the removal still names the (surviving) section.
$id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
$id = DiviChange::idOf($entry, $index);
if ($id !== null) {
$sections[explode('.', $id)[0]] = true;
}
@@ -207,20 +165,7 @@ class DiviWriter
}
$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;
return $outline->summary($index, array_map('strval', array_keys($sections)));
}
}