Files
WP-LIT-MCP/src/Integrations/Divi/Compose/TreeEditor.php
T
janniclas.loosenandClaude Opus 5.5 b1dd35abd8 feat: Divi tools edit Theme Builder layouts; visible layouts need confirm
TB layouts load through ThemeBuilderAccess (P12), live layouts (attached +
enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point
of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are
off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22),
also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags
and clears site CSS + TB cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:45:07 +02:00

164 lines
5.7 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\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Container;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
defined('ABSPATH') || exit;
/**
* Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after,
* optional index), move, remove, duplicate – always checked against StructureRules. Works on
* the tree only; saving is DiviWriter's job. Global instances count as their root type
* (blockName, GlobalInstance::effectiveName()). Pure PHP.
*/
final class TreeEditor
{
public function __construct(private StructureRules $rules)
{
}
/**
* Resolves a target position: exactly one of parent, before, after; index only with parent.
* No position at all means "end of the page".
*
* @param array<string, mixed> $input
* @return array{container: Container, parent: ?Block, index: int}|string Position or German error.
*/
public function position(NodeIndex $index, array $input): array|string
{
$given = array_filter(
[
'parent' => $input['parent'] ?? null,
'before' => $input['before'] ?? null,
'after' => $input['after'] ?? null,
],
static fn (mixed $value): bool => is_string($value) && $value !== ''
);
if (count($given) > 1) {
return 'nur eines von parent, before oder after angeben';
}
$at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX;
if ($given === []) {
return ['container' => $index->root(), 'parent' => null, 'index' => $at];
}
$key = (string) array_key_first($given);
$node = $index->find((string) $given[$key]);
if ($node === null) {
return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]);
}
if ($key === 'parent') {
return ['container' => $node, 'parent' => $node, 'index' => $at];
}
$container = $index->parentOf($node);
if ($container === null) {
return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]);
}
$sibling = (int) $container->indexOf($node);
return [
'container' => $container,
'parent' => $container instanceof Block && ! $container->isPlaceholder() ? $container : null,
'index' => $key === 'before' ? $sibling : $sibling + 1,
];
}
/**
* @param array{container: Container, parent: ?Block, index: int} $position
* @param list<Block> $blocks
*/
public function insert(array $position, array $blocks): ?string
{
foreach ($blocks as $block) {
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block))
?? $this->rules->nestedAreaViolation($block);
if ($violation !== null) {
return $violation;
}
}
$container = $this->targetContainer($position['container']);
$start = $position['index'];
foreach (array_values($blocks) as $offset => $block) {
$at = $start === PHP_INT_MAX ? PHP_INT_MAX : $start + $offset;
$container->insertChild($at, $block);
}
return null;
}
/** @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 = $index->parentBlock($ancestor)) {
if ($ancestor === $node) {
return 'ein Knoten kann nicht in sich selbst verschoben werden';
}
}
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node));
if ($violation !== null) {
return $violation;
}
$from = $index->parentOf($node);
if ($from === null) {
return 'der Knoten hat keinen Elternknoten';
}
$old = $from->removeChild($node);
$target = $position['index'];
if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) {
--$target;
}
$position['container']->insertChild($target, $node);
return null;
}
public function remove(NodeIndex $index, Block $node): void
{
$index->parentOf($node)?->removeChild($node);
}
/** Inserts a copy right after $node and returns it. Never touches $node or the copy afterwards. */
public function duplicate(NodeIndex $index, Block $node): Block
{
$copy = $node->copy();
$container = $index->parentOf($node);
if ($container !== null) {
$container->insertChild((int) $container->indexOf($node) + 1, $copy);
}
return $copy;
}
/**
* 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 –
* so a first-section page gets the structure the Visual Builder itself writes.
*/
private function targetContainer(Container $container): Container
{
if (! $container instanceof Document || $container->children() !== []) {
return $container;
}
$placeholder = Block::create('divi/placeholder');
$container->insertChild(0, $placeholder);
return $placeholder;
}
}