Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
174 lines
7.0 KiB
PHP
174 lines
7.0 KiB
PHP
<?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). A library entry
|
||
* as source is an insert from the library (E3, audit L5): it needs LibraryAccess::canInsert()
|
||
* (add_library, writable group "Divi-Bibliothek") and follows lit-library-insert's Tools-tab state.
|
||
*/
|
||
final class DiviNodeCopyAbility extends DiviWriteAbility
|
||
{
|
||
/**
|
||
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always.
|
||
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled (gates
|
||
* library entries as source); null = always.
|
||
*/
|
||
public function __construct(
|
||
DiviWriter $writer,
|
||
private DiviPageLoader $loader,
|
||
private ?Closure $diviWritable = null,
|
||
private ?Closure $libraryInsertable = 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 and, for a library entry as
|
||
* source, the rights of an insert from the library – 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);
|
||
if ($source instanceof WP_Error) {
|
||
return $source;
|
||
}
|
||
|
||
return $source->isLibraryItem() ? $this->libraryInsertRefusal() : null;
|
||
}
|
||
|
||
/** E3/audit L5: copying out of a library entry is inserting from the library (lit-library-insert). */
|
||
private function libraryInsertRefusal(): ?WP_Error
|
||
{
|
||
if ($this->libraryInsertable !== null && ! ($this->libraryInsertable)()) {
|
||
return Errors::unsupported(
|
||
'Einfügen aus der Divi-Bibliothek ist für diese Verbindung abgeschaltet (lit-library-insert '
|
||
. 'deaktiviert oder Gruppe „Divi-Bibliothek“ auf „nur lesen“).',
|
||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||
);
|
||
}
|
||
|
||
return $this->loader->library()?->canInsert();
|
||
}
|
||
|
||
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();
|
||
if ($source->isLibraryItem()) {
|
||
// Finding 9: library entries from Divi's dialog use CRLF; unified before every takeover
|
||
// (LibraryContent::load), as lit-library-insert and lit/library-save do.
|
||
$copy->unifyLineBreaks();
|
||
}
|
||
$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');
|
||
}
|
||
}
|