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>
164 lines
5.7 KiB
PHP
164 lines
5.7 KiB
PHP
<?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;
|
||
}
|
||
}
|