feat: lit/divi-node-copy between pages and library entries

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 03:12:57 +02:00
co-authored by Claude Opus 5.5
parent 7fe91c2bdb
commit 6f6c691b5d
6 changed files with 436 additions and 3 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). `lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`).
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
+6 -1
View File
@@ -21,6 +21,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
@@ -53,12 +54,15 @@ final class DiviIntegration implements Integration
* (group "Divi-Bibliothek" writable); null = always. * (group "Divi-Bibliothek" writable); null = always.
* @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group
* "Divi-Bibliothek" not disabled, ruling P9); null = always. * "Divi-Bibliothek" not disabled, ruling P9); null = always.
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy
* follows it (ruling P9, E9); null = always.
*/ */
public function __construct( public function __construct(
private ContentSchema $schema, private ContentSchema $schema,
private ChangeLog $log, private ChangeLog $log,
?Closure $libraryWritable = null, ?Closure $libraryWritable = null,
?Closure $libraryVisible = null ?Closure $libraryVisible = null,
private ?Closure $diviWritable = null
) { ) {
$this->modules = new DiviModules(); $this->modules = new DiviModules();
$this->design = new DesignSystemReader(); $this->design = new DesignSystemReader();
@@ -113,6 +117,7 @@ final class DiviIntegration implements Integration
new DiviDeleteAbility($writer), new DiviDeleteAbility($writer),
new DiviDuplicateNodeAbility($writer), new DiviDuplicateNodeAbility($writer),
new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviReplaceTextAbility($this->schema, $loader, $writer),
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable),
]; ];
} }
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-node-copy (spec §5.7, E6): a node with everything inside from one Divi entry (page or
* library entry) to a position in another. The copy keeps its bytes – global instances stay linked –
* and passes StoredNodeGuard of the target (builder-only values never move to another post for users
* without unfiltered_html). Source: edit rights via DiviPageLoader (PostGuard / LibraryAccess, P9
* hiding) and its own source_tree_rev, because path IDs are only valid for the state they were read
* in; the source is only read, so it needs no lock. Target: like lit-divi-insert (tree_rev lock,
* StructureRules, all or nothing, DiviWriter::commit with GlobalEdits – a global target entry never
* gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9).
*/
final class DiviNodeCopyAbility extends DiviWriteAbility
{
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */
public function __construct(
DiviWriter $writer,
private DiviPageLoader $loader,
private ?Closure $diviWritable = null
) {
parent::__construct($writer);
}
public function name(): string
{
return 'lit/divi-node-copy';
}
public function label(): string
{
return 'Divi-Knoten kopieren';
}
public function description(): string
{
return 'Kopiert einen Divi-Knoten samt Inhalt von einem Eintrag (source_post_id, source_id und '
. 'source_tree_rev aus lit-divi-outline der Quelle) an eine Position eines anderen Eintrags (post_id, '
. 'tree_rev, parent/before/after). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Quelle '
. 'und Ziel dürfen auch Bibliothekseinträge sein. Alles oder nichts.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviSchema::write(), [
'source_post_id' => ['type' => 'integer', 'minimum' => 1],
'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'],
'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'],
], DiviSchema::position()),
'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'],
'additionalProperties' => false,
];
}
public function annotations(): array
{
return Annotations::write(false);
}
public function isDirect(): bool
{
return false;
}
/** lit/divi-insert's Tools-tab state, then the source's own rights – in permission() and execute(). */
protected function precheck(array $input): ?WP_Error
{
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
return Errors::unsupported(
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
. 'lit-divi-insert deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
$source = $this->loader->load($input['source_post_id'] ?? null, false);
return $source instanceof WP_Error ? $source : null;
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$source = $this->loader->load($input['source_post_id'] ?? null, false);
if ($source instanceof WP_Error) {
return $source;
}
if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) {
return Errors::revMismatch(
'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde '
. 'gespeichert.',
'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.'
);
}
$id = is_string($input['source_id'] ?? null) ? $input['source_id'] : '';
$node = $source->index->find($id);
if ($node === null) {
return DiviErrors::unknownNode($id, $source->post);
}
$copy = $node->copy();
$problems = $composition->storedGuard()->problems($copy);
if ($problems !== []) {
return Errors::invalidInput(
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
. 'gespeichert.',
['source_id' => implode('; ', $problems)]
);
}
$editor = $composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
return DiviErrors::position($position);
}
$violation = $editor->insert($position, [$copy]);
if ($violation !== null) {
return Errors::invalidInput(
'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.',
['source_id' => $violation]
);
}
return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID)))
->record($copy, 'inserted');
}
}
+7 -1
View File
@@ -89,7 +89,13 @@ final class Plugin
$libraryWritable = static function () use (&$policy): bool { $libraryWritable = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
}; };
$divi = new DiviIntegration($schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible); $divi = new DiviIntegration(
$schema,
$changeLog,
$libraryWritable,
libraryVisible: $libraryVisible,
diviWritable: $diviWritable
);
$registry = new IntegrationRegistry( $registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'), new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
@@ -29,6 +29,7 @@ final class DiviIntegrationTest extends TestCase
'lit/divi-delete', 'lit/divi-delete',
'lit/divi-duplicate-node', 'lit/divi-duplicate-node',
'lit/divi-replace-text', 'lit/divi-replace-text',
'lit/divi-node-copy',
]; ];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
@@ -67,6 +68,25 @@ final class DiviIntegrationTest extends TestCase
} }
} }
/** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
public function testNodeCopyFollowsLitDiviInsert(): void
{
$integration = new DiviIntegration(
Mockery::mock(ContentSchema::class),
FakeChangeLog::forbidden(),
diviWritable: static fn (): bool => false
);
$copy = null;
foreach ($integration->abilities() as $ability) {
if ($ability->name() === 'lit/divi-node-copy') {
$copy = $ability;
}
}
self::assertNotNull($copy);
self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported');
}
public function testGivesContentCreateTheDiviShortForm(): void public function testGivesContentCreateTheDiviShortForm(): void
{ {
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class DiviNodeCopyAbilityTest extends TestCase
{
private WP_Post $target;
private WP_Post $source;
/** @var list<string> */
private array $saved = [];
protected function setUp(): void
{
parent::setUp();
$this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
$this->source = LibraryFixtures::page(
DiviFixtures::markup('stage3/page--globale-instanzen.html'),
['ID' => 8, 'post_title' => 'Quelle']
);
LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]);
}
private function ability(
bool $diviWritable = true,
bool $unfiltered = false,
?LibraryAccess $access = null
): DiviNodeCopyAbility {
$access ??= LibraryFixtures::access();
$loader = LibraryFixtures::loader($access);
$failures = [];
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('of')->andReturn(['items' => [], 'count' => 0]);
$globals = new GlobalEdits($access, $usages);
return new DiviNodeCopyAbility(
DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals),
$loader,
static fn (): bool => $diviWritable
);
}
/** @return array<string, mixed> */
private function input(array $extra = []): array
{
return array_merge([
'post_id' => 7,
'tree_rev' => Rev::of((string) $this->target->post_content),
'source_post_id' => 8,
'source_tree_rev' => Rev::of((string) $this->source->post_content),
'source_id' => 's1',
'after' => 's0',
], $extra);
}
private function sourceBytes(string $id): string
{
return Serializer::block(
NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find($id)
);
}
public function testCopiesANodeByteForByteBetweenPages(): void
{
$bytes = $this->sourceBytes('s1');
$result = $this->ability()->execute($this->input());
self::assertTrue($result['ok']);
self::assertCount(1, $this->saved, 'only the target is written');
self::assertStringContainsString($bytes, $this->saved[0]);
self::assertSame('s1', $result['changed'][0]['id']);
self::assertFalse($this->ability()->isDirect());
}
public function testLeavesTheRestOfTheTargetByteIdentical(): void
{
$original = (string) $this->target->post_content;
$bytes = $this->sourceBytes('s1');
$this->ability()->execute($this->input());
self::assertSame($original, str_replace($bytes, '', $this->saved[0]));
self::assertSame(
DiviFixtures::markup('stage3/page--globale-instanzen.html'),
(string) $this->source->post_content,
'the source is only read'
);
}
public function testGlobalInstancesStayLinked(): void
{
$result = $this->ability()->execute($this->input(['source_id' => 'g0']));
self::assertTrue($result['ok']);
self::assertStringContainsString('<!-- wp:divi/global-layout {"globalModule":"1058"', $this->saved[0]);
self::assertStringContainsString($this->sourceBytes('g0'), $this->saved[0]);
}
public function testStaleSourceOrTargetWritesNothing(): void
{
self::assertToolError(
$this->ability()->execute($this->input(['source_tree_rev' => 'abcdef012345'])),
'rev_mismatch'
);
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
self::assertToolError($this->ability()->execute($this->input(['source_id' => 's9'])), 'not_found');
self::assertSame([], $this->saved);
}
public function testEditorCannotCarryBuilderOnlyValuesToAnotherPage(): void
{
$this->source = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} -->'
. '<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
['ID' => 8]
);
LibraryFixtures::given([$this->target, $this->source]);
$result = $this->ability()->execute($this->input(['source_id' => 's0']));
self::assertToolError($result, 'validation_failed');
self::assertSame([], $this->saved);
}
public function testAnAdminMayCopyBuilderOnlyValues(): void
{
$this->source = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} -->'
. '<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
['ID' => 8]
);
LibraryFixtures::given([$this->target, $this->source]);
$result = $this->ability(unfiltered: true)->execute($this->input(['source_id' => 's0']));
self::assertTrue($result['ok']);
self::assertStringContainsString('body{display:none}', $this->saved[0]);
}
public function testTheSourceNeedsItsOwnRights(): void
{
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8)
);
self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
self::assertToolError($this->ability()->execute($this->input()), 'forbidden');
self::assertSame([], $this->saved);
}
public function testFollowsLitDiviInsertExposure(): void
{
$ability = $this->ability(diviWritable: false);
self::assertToolError($ability->permission($this->input()), 'unsupported');
self::assertToolError($ability->execute($this->input()), 'unsupported');
self::assertSame([], $this->saved);
}
public function testCopiesIntoALibraryEntry(): void
{
$item = LibraryFixtures::item(1055);
$result = $this->ability()->execute($this->input([
'post_id' => 1055,
'tree_rev' => Rev::of((string) $item->post_content),
'source_id' => 's0.r0.c0.m0',
'after' => null,
'parent' => 's0.r0.c0',
]));
self::assertTrue($result['ok']);
self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]);
}
public function testCopiesFromALibraryEntry(): void
{
$item = LibraryFixtures::item(1055);
$result = $this->ability()->execute($this->input([
'source_post_id' => 1055,
'source_tree_rev' => Rev::of((string) $item->post_content),
'source_id' => 's0',
]));
self::assertTrue($result['ok']);
self::assertStringContainsString('Library-Blurb', $this->saved[0]);
}
public function testAHiddenLibraryIsNoSource(): void
{
$item = LibraryFixtures::item(1055);
$access = new LibraryAccess(
LibraryFixtures::store(),
LibraryFixtures::roles(),
static fn (): bool => true,
static fn (): bool => false
);
$result = $this->ability(access: $access)->execute($this->input([
'source_post_id' => 1055,
'source_tree_rev' => Rev::of((string) $item->post_content),
'source_id' => 's0',
]));
self::assertToolError($result, 'not_found');
self::assertSame([], $this->saved);
}
public function testNeverNestsAnInstanceInAGlobalEntry(): void
{
$item = LibraryFixtures::item(1058);
LibraryFixtures::given([$this->target, $this->source, $item]);
$result = $this->ability()->execute($this->input([
'post_id' => 1058,
'tree_rev' => Rev::of((string) $item->post_content),
'source_id' => 's0.r0.c0.g0',
'after' => null,
'parent' => 's0.r0.c0',
'confirm' => true,
]));
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('keine globalen Module', $result->get_error_message());
self::assertSame([], $this->saved);
}
public function testRefusesAPlaceThatBreaksTheStructure(): void
{
$result = $this->ability()->execute($this->input(['after' => null, 'parent' => 's0.r0.c0']));
self::assertToolError($result, 'validation_failed');
self::assertSame([], $this->saved);
}
public function testRefusesAMissingSourceId(): void
{
self::assertToolError($this->ability()->execute($this->input(['source_id' => null])), 'not_found');
self::assertSame([], $this->saved);
}
}