diff --git a/src/Integrations/Divi/BlockTree/Block.php b/src/Integrations/Divi/BlockTree/Block.php index 86d633d..15a8824 100644 --- a/src/Integrations/Divi/BlockTree/Block.php +++ b/src/Integrations/Divi/BlockTree/Block.php @@ -121,6 +121,12 @@ final class Block extends Container return str_starts_with($this->name, 'divi/'); } + /** The divi/placeholder wrapper the builder puts around a page's sections (transparent for IDs). */ + public function isPlaceholder(): bool + { + return $this->name === 'divi/placeholder'; + } + /** A global module reference or global layout (edited only in Etappe 3). */ public function isGlobal(): bool { diff --git a/src/Integrations/Divi/BlockTree/NodeIndex.php b/src/Integrations/Divi/BlockTree/NodeIndex.php index dd07012..31cda0f 100644 --- a/src/Integrations/Divi/BlockTree/NodeIndex.php +++ b/src/Integrations/Divi/BlockTree/NodeIndex.php @@ -36,7 +36,7 @@ final class NodeIndex { $root = $document; foreach ($document->children() as $block) { - if ($block->name() === 'divi/placeholder') { + if ($block->isPlaceholder()) { $root = $block; break; } @@ -92,6 +92,26 @@ final class NodeIndex return $parent instanceof Block ? $this->idOf($parent) : null; } + /** The parent node of $block; null at the top level (the root or a placeholder holds it). */ + public function parentBlock(Block $block): ?Block + { + $parent = $this->parentOf($block); + + return $parent instanceof Block && ! $parent->isPlaceholder() ? $parent : null; + } + + /** Admin label of the section around $block (or $block itself), for automatic labels ("" when none). */ + public function sectionLabel(?Block $block): string + { + for ($node = $block; $node !== null; $node = $this->parentBlock($node)) { + if ($node->name() === 'divi/section') { + return NodeSummary::label($node); + } + } + + return ''; + } + /** @return list In document order. */ public function entries(): array { @@ -102,7 +122,7 @@ final class NodeIndex private function walk(Container $container, string $prefix, int $depth, array &$counters): void { foreach ($container->children() as $block) { - if ($prefix === '' && $block->name() === 'divi/placeholder') { + if ($prefix === '' && $block->isPlaceholder()) { $this->walk($block, '', $depth, $counters); continue; } diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index 63ea24a..eaab7c7 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -32,6 +32,29 @@ final class Outline return $this->truncated; } + /** + * The outline text of a write result: the whole page ($sectionIds null) or the given + * sections one after another (none: text null), and whether any breadth cap fired (S18). + * + * @param list|null $sectionIds + * @return array{text: ?string, truncated: bool} + */ + public function summary(NodeIndex $index, ?array $sectionIds = null): array + { + if ($sectionIds === []) { + return ['text' => null, 'truncated' => false]; + } + + $texts = []; + $truncated = false; + foreach ($sectionIds ?? [null] as $sectionId) { + $texts[] = $this->text($this->rows($index, $sectionId)); + $truncated = $truncated || $this->wasTruncated(); + } + + return ['text' => implode("\n", $texts), 'truncated' => $truncated]; + } + /** * @return list, children: int, editable: bool, depth: int, truncated: bool}> diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php index f859721..a9b1ea9 100644 --- a/src/Integrations/Divi/Compose/TreeEditor.php +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -65,7 +65,7 @@ final class TreeEditor return [ 'container' => $container, - 'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null, + 'parent' => $container instanceof Block && ! $container->isPlaceholder() ? $container : null, 'index' => $key === 'before' ? $sibling : $sibling + 1, ]; } @@ -96,7 +96,7 @@ final class TreeEditor /** @param array{container: Container, parent: ?Block, index: int} $position */ public function move(NodeIndex $index, Block $node, array $position): ?string { - for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) { + for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $index->parentBlock($ancestor)) { if ($ancestor === $node) { return 'ein Knoten kann nicht in sich selbst verschoben werden'; } @@ -139,13 +139,6 @@ final class TreeEditor return $copy; } - private function parentBlock(NodeIndex $index, Block $block): ?Block - { - $parent = $index->parentOf($block); - - return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; - } - /** * Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet) * first wraps it in one, the same way DiviContentComposer (Task 9) writes new content – diff --git a/src/Integrations/Divi/Pages/DiviChange.php b/src/Integrations/Divi/Pages/DiviChange.php index 82f64c8..4348a16 100644 --- a/src/Integrations/Divi/Pages/DiviChange.php +++ b/src/Integrations/Divi/Pages/DiviChange.php @@ -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 */ public function touched(): array { diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index 9136eea..28fb54a 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -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']]; } /** diff --git a/src/Integrations/Divi/Pages/DiviDeleteAbility.php b/src/Integrations/Divi/Pages/DiviDeleteAbility.php index f4395a6..9dc0723 100644 --- a/src/Integrations/Divi/Pages/DiviDeleteAbility.php +++ b/src/Integrations/Divi/Pages/DiviDeleteAbility.php @@ -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, diff --git a/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php b/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php index 2c74f5d..ce8c218 100644 --- a/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php +++ b/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php @@ -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'], diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php index 4c6b992..590b2a4 100644 --- a/src/Integrations/Divi/Pages/DiviInsertAbility.php +++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php @@ -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.', [ diff --git a/src/Integrations/Divi/Pages/DiviMoveAbility.php b/src/Integrations/Divi/Pages/DiviMoveAbility.php index d18004c..283e1e6 100644 --- a/src/Integrations/Divi/Pages/DiviMoveAbility.php +++ b/src/Integrations/Divi/Pages/DiviMoveAbility.php @@ -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, diff --git a/src/Integrations/Divi/Pages/DiviSchema.php b/src/Integrations/Divi/Pages/DiviSchema.php new file mode 100644 index 0000000..42e0394 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviSchema.php @@ -0,0 +1,36 @@ +> 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> 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).'], + ]; + } +} diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 8a1facb..f087750 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -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, diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 7aad973..a605943 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -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> 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> 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))); } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php index 5b69e35..ed2fffc 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php @@ -38,6 +38,13 @@ final class BlockTest extends TestCase self::assertSame('divi/button', Block::create('divi/button')->name()); } + public function testOnlyThePlaceholderIsAPlaceholder(): void + { + self::assertTrue(Block::create('divi/placeholder')->isPlaceholder()); + self::assertFalse(Block::create('divi/section')->isPlaceholder()); + self::assertFalse(Block::create('core/placeholder')->isPlaceholder()); + } + public function testSetAttrsRefusesOnAnOpaqueGlobalModule(): void { $document = (new Parser())->parse(''); diff --git a/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php b/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php index 7983334..34c4ea7 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php @@ -45,6 +45,20 @@ final class NodeIndexTest extends TestCase self::assertSame(0, preg_match(NodeIndex::ID_PATTERN, "s1\n")); } + public function testParentBlockSkipsThePlaceholderAndSectionLabelWalksUp(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); + $button = $index->find('s0.r0.c1.m2'); + + self::assertTrue($index->root() instanceof \LIT\MCP\Integrations\Divi\BlockTree\Block + && $index->root()->isPlaceholder()); + self::assertSame($index->find('s0.r0.c1'), $index->parentBlock($button)); + self::assertNull($index->parentBlock($index->find('s0')), 'the placeholder is no parent node'); + self::assertSame('Hero', $index->sectionLabel($button)); + self::assertSame('Hero', $index->sectionLabel($index->find('s0'))); + self::assertSame('', $index->sectionLabel(null)); + } + public function testDocumentWithoutPlaceholderIsItsOwnRoot(): void { $document = (new Parser())->parse(''); diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 53be590..465bb19 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -82,6 +82,22 @@ final class OutlineTest extends TestCase self::assertTrue($outline->wasTruncated()); } + public function testSummaryOfThePageOrOfSections(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); + $outline = new Outline(); + + self::assertSame( + ['text' => $outline->text($outline->rows($index)), 'truncated' => false], + $outline->summary($index) + ); + self::assertSame( + $outline->text($outline->rows($index, 's0')) . "\n" . $outline->text($outline->rows($index, 's1')), + $outline->summary($index, ['s0', 's1'])['text'] + ); + self::assertSame(['text' => null, 'truncated' => false], $outline->summary($index, [])); + } + public function testReadOnlyNodesAreMarked(): void { $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html'))); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviChangeTest.php b/tests/Unit/Integrations/Divi/Pages/DiviChangeTest.php new file mode 100644 index 0000000..4062d31 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviChangeTest.php @@ -0,0 +1,31 @@ +parse( + '' + )); + $row = $index->find('s0.r0'); + $change = (new DiviChange('x')) + ->changed($row, 'updated') + ->removed('s3.r1', Block::create('divi/row')) + ->changed(Block::create('divi/text'), 'inserted'); + + self::assertSame( + ['s0.r0', 's3.r1', null], + array_map(static fn (array $entry): ?string => DiviChange::idOf($entry, $index), $change->entries()) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php index ce0f3e7..5bb1ea8 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -80,8 +80,8 @@ final class DiviWriterTest extends TestCase DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $page = (new DiviPageLoader(DiviFixtures::schema()))->load(7, true); - self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1'))); - self::assertSame('', DiviWriter::sectionLabel($page->index, null)); + self::assertSame('Hero', $page->index->sectionLabel($page->index->find('s0.r0.c1'))); + self::assertSame('', $page->index->sectionLabel(null)); } public function testCompositionIsBoundToThePage(): void