refactor: DiviWriteAbility base and shared DiviErrors (H4)
The five Divi write tools extend DiviWriteAbility (final permission and execute, change() per tool, shared output schema). DiviErrors holds the unknown-node, position and compose-error mapping; DiviContentComposer uses the same mapping (ledger T9). ToolNamingTest skips abstract classes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -98,9 +98,7 @@ final class DiviContentComposer implements ContentComposer
|
|||||||
|
|
||||||
$composed = $composition->composer()->compose(array_values($sections), null, 'divi');
|
$composed = $composition->composer()->compose(array_values($sections), null, 'divi');
|
||||||
if ($composed['errors'] !== []) {
|
if ($composed['errors'] !== []) {
|
||||||
return $composed['invalid_paths'] !== []
|
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
|
||||||
? Errors::invalidAttrPath($composed['errors'])
|
|
||||||
: Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$document = new Document();
|
$document = new Document();
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -17,12 +15,8 @@ use WP_Error;
|
|||||||
* nodes inside another node that is deleted anyway do not count twice. The revision in the
|
* nodes inside another node that is deleted anyway do not count twice. The revision in the
|
||||||
* result allows undoing it (lit/revision-restore).
|
* result allows undoing it (lit/revision-restore).
|
||||||
*/
|
*/
|
||||||
final class DiviDeleteAbility implements Ability
|
final class DiviDeleteAbility extends DiviWriteAbility
|
||||||
{
|
{
|
||||||
public function __construct(private DiviWriter $writer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
return 'lit/divi-delete';
|
return 'lit/divi-delete';
|
||||||
@@ -59,41 +53,19 @@ final class DiviDeleteAbility implements Ability
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function outputSchema(): ?array
|
|
||||||
{
|
|
||||||
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function annotations(): array
|
public function annotations(): array
|
||||||
{
|
{
|
||||||
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
|
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDirect(): bool
|
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||||||
{
|
{
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
|
|
||||||
return $page instanceof WP_Error ? $page : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
if ($page instanceof WP_Error) {
|
|
||||||
return $page;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, Block> $nodes */
|
/** @var array<string, Block> $nodes */
|
||||||
$nodes = [];
|
$nodes = [];
|
||||||
foreach (is_array($input['ids'] ?? null) ? $input['ids'] : [] as $id) {
|
foreach (is_array($input['ids'] ?? null) ? $input['ids'] : [] as $id) {
|
||||||
$node = is_string($id) ? $page->index->find($id) : null;
|
$node = is_string($id) ? $page->index->find($id) : null;
|
||||||
if ($node === null) {
|
if ($node === null) {
|
||||||
return DiviPageLoader::unknownNode((string) $id, $page->post);
|
return DiviErrors::unknownNode((string) $id, $page->post);
|
||||||
}
|
}
|
||||||
$nodes[$id] = $node;
|
$nodes[$id] = $node;
|
||||||
}
|
}
|
||||||
@@ -115,14 +87,14 @@ final class DiviDeleteAbility implements Ability
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$editor = $this->writer->composition($page)->editor();
|
$editor = $composition->editor();
|
||||||
$change = new DiviChange(sprintf('%d Divi-Knoten gelöscht', count($nodes)));
|
$change = new DiviChange(sprintf('%d Divi-Knoten gelöscht', count($nodes)));
|
||||||
foreach ($nodes as $id => $node) {
|
foreach ($nodes as $id => $node) {
|
||||||
$change->removed((string) $id, $node);
|
$change->removed((string) $id, $node);
|
||||||
$editor->remove($page->index, $node);
|
$editor->remove($page->index, $node);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->writer->save($this->name(), $page, $change, $input);
|
return $change;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, Block> $nodes */
|
/** @param array<string, Block> $nodes */
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
@@ -14,12 +12,8 @@ use WP_Error;
|
|||||||
* (spec §5.7). The copy's admin label gets " (Kopie)"; read-only nodes (global modules,
|
* (spec §5.7). The copy's admin label gets " (Kopie)"; read-only nodes (global modules,
|
||||||
* foreign blocks) are copied unchanged, as a whole, byte-identical reference.
|
* foreign blocks) are copied unchanged, as a whole, byte-identical reference.
|
||||||
*/
|
*/
|
||||||
final class DiviDuplicateNodeAbility implements Ability
|
final class DiviDuplicateNodeAbility extends DiviWriteAbility
|
||||||
{
|
{
|
||||||
public function __construct(private DiviWriter $writer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
return 'lit/divi-duplicate-node';
|
return 'lit/divi-duplicate-node';
|
||||||
@@ -48,11 +42,6 @@ final class DiviDuplicateNodeAbility implements Ability
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function outputSchema(): ?array
|
|
||||||
{
|
|
||||||
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function annotations(): array
|
public function annotations(): array
|
||||||
{
|
{
|
||||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||||||
@@ -63,31 +52,20 @@ final class DiviDuplicateNodeAbility implements Ability
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||||||
{
|
{
|
||||||
$page = $this->writer->page($input);
|
|
||||||
|
|
||||||
return $page instanceof WP_Error ? $page : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
if ($page instanceof WP_Error) {
|
|
||||||
return $page;
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||||||
$node = $page->index->find($id);
|
$node = $page->index->find($id);
|
||||||
if ($node === null) {
|
if ($node === null) {
|
||||||
return DiviPageLoader::unknownNode($id, $page->post);
|
return DiviErrors::unknownNode($id, $page->post);
|
||||||
}
|
}
|
||||||
|
|
||||||
$copy = $this->writer->composition($page)->editor()->duplicate($page->index, $node);
|
$copy = $composition->editor()->duplicate($page->index, $node);
|
||||||
if ($copy->isDivi() && ! $copy->isOpaque()) {
|
if ($copy->isDivi() && ! $copy->isOpaque()) {
|
||||||
// The raw stored desktop value (never NodeSummary::label()'s display-shortened
|
// The raw stored desktop value (never NodeSummary::label()'s display-shortened
|
||||||
// text): a long or multi-line label must be copied verbatim, only " (Kopie)" added.
|
// text): a long or multi-line label must be copied verbatim, only " (Kopie)" added.
|
||||||
// Setting only the ".desktop.value" leaf keeps every other breakpoint untouched.
|
// Setting only the ".desktop.value" leaf keeps every other breakpoint untouched.
|
||||||
|
// No client input: the stored label plus a constant suffix, so no choke point (S39).
|
||||||
$raw = AttrMap::desktop($node->attrs(), 'module.meta.adminLabel');
|
$raw = AttrMap::desktop($node->attrs(), 'module.meta.adminLabel');
|
||||||
$label = is_string($raw) ? $raw : '';
|
$label = is_string($raw) ? $raw : '';
|
||||||
$copy->setAttrs(AttrMap::set(
|
$copy->setAttrs(AttrMap::set(
|
||||||
@@ -97,8 +75,6 @@ final class DiviDuplicateNodeAbility implements Ability
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$change = (new DiviChange(sprintf('Knoten %s dupliziert', $id)))->changed($copy, 'duplicated');
|
return (new DiviChange(sprintf('Knoten %s dupliziert', $id)))->changed($copy, 'duplicated');
|
||||||
|
|
||||||
return $this->writer->save($this->name(), $page, $change, $input);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
/** The client errors the Divi tools share. */
|
||||||
|
final class DiviErrors
|
||||||
|
{
|
||||||
|
/** A path ID that names no node of $post. */
|
||||||
|
public static function unknownNode(string $id, WP_Post $post): WP_Error
|
||||||
|
{
|
||||||
|
return Errors::notFound(
|
||||||
|
sprintf('Knoten "%s" gibt es in Eintrag %d nicht.', $id, $post->ID),
|
||||||
|
DiviPageLoader::OUTLINE_HINT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parent / before / after / index the tree has no place for (TreeEditor::position()). */
|
||||||
|
public static function position(string $problem): WP_Error
|
||||||
|
{
|
||||||
|
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $problem]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The errors of Composer::compose()/update(): invalid_attr_path when an attribute path is
|
||||||
|
* unknown (the more useful hint), else invalid_input with $message.
|
||||||
|
*
|
||||||
|
* @param array{errors: array<string, string>, invalid_paths: list<string>} $result
|
||||||
|
*/
|
||||||
|
public static function composed(array $result, string $message): WP_Error
|
||||||
|
{
|
||||||
|
return $result['invalid_paths'] !== []
|
||||||
|
? Errors::invalidAttrPath($result['errors'])
|
||||||
|
: Errors::invalidInput($message, $result['errors']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,12 +14,8 @@ use WP_Error;
|
|||||||
* (client values pass its choke point) and enter the tree only through TreeEditor::insert(),
|
* (client values pass its choke point) and enter the tree only through TreeEditor::insert(),
|
||||||
* which also wraps the first section of an empty page in divi/placeholder (ruling S9).
|
* which also wraps the first section of an empty page in divi/placeholder (ruling S9).
|
||||||
*/
|
*/
|
||||||
final class DiviInsertAbility implements Ability
|
final class DiviInsertAbility extends DiviWriteAbility
|
||||||
{
|
{
|
||||||
public function __construct(private DiviWriter $writer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
return 'lit/divi-insert';
|
return 'lit/divi-insert';
|
||||||
@@ -60,41 +54,18 @@ final class DiviInsertAbility implements Ability
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function outputSchema(): ?array
|
|
||||||
{
|
|
||||||
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function annotations(): array
|
public function annotations(): array
|
||||||
{
|
{
|
||||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDirect(): bool
|
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||||||
{
|
{
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
|
|
||||||
return $page instanceof WP_Error ? $page : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
if ($page instanceof WP_Error) {
|
|
||||||
return $page;
|
|
||||||
}
|
|
||||||
|
|
||||||
$composition = $this->writer->composition($page);
|
|
||||||
$editor = $composition->editor();
|
$editor = $composition->editor();
|
||||||
$position = $editor->position($page->index, $input);
|
$position = $editor->position($page->index, $input);
|
||||||
if (is_string($position)) {
|
if (is_string($position)) {
|
||||||
// The nodes are checked against their parent – without a valid position there is none.
|
// The nodes are checked against their parent – without a valid position there is none.
|
||||||
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]);
|
return DiviErrors::position($position);
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = DiviWriter::sectionLabel($page->index, $position['parent']);
|
$context = DiviWriter::sectionLabel($page->index, $position['parent']);
|
||||||
@@ -107,9 +78,7 @@ final class DiviInsertAbility implements Ability
|
|||||||
|
|
||||||
$composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
|
$composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
|
||||||
if ($composed['errors'] !== []) {
|
if ($composed['errors'] !== []) {
|
||||||
return $composed['invalid_paths'] !== []
|
return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
|
||||||
? Errors::invalidAttrPath($composed['errors'])
|
|
||||||
: Errors::invalidInput('Knoten sind ungültig; nichts wurde gespeichert.', $composed['errors']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$violation = $editor->insert($position, $composed['blocks']);
|
$violation = $editor->insert($position, $composed['blocks']);
|
||||||
@@ -125,6 +94,6 @@ final class DiviInsertAbility implements Ability
|
|||||||
$change->changed($block, 'inserted');
|
$change->changed($block, 'inserted');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->writer->save($this->name(), $page, $change, $input);
|
return $change;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,12 +12,8 @@ use WP_Error;
|
|||||||
* another row or section, as long as the structure rules allow it (spec §5.7). Read-only
|
* another row or section, as long as the structure rules allow it (spec §5.7). Read-only
|
||||||
* nodes (global modules, foreign blocks) move as a whole, unchanged.
|
* nodes (global modules, foreign blocks) move as a whole, unchanged.
|
||||||
*/
|
*/
|
||||||
final class DiviMoveAbility implements Ability
|
final class DiviMoveAbility extends DiviWriteAbility
|
||||||
{
|
{
|
||||||
public function __construct(private DiviWriter $writer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
return 'lit/divi-move';
|
return 'lit/divi-move';
|
||||||
@@ -50,45 +44,23 @@ final class DiviMoveAbility implements Ability
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function outputSchema(): ?array
|
|
||||||
{
|
|
||||||
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function annotations(): array
|
public function annotations(): array
|
||||||
{
|
{
|
||||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDirect(): bool
|
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||||||
{
|
{
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
|
|
||||||
return $page instanceof WP_Error ? $page : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
if ($page instanceof WP_Error) {
|
|
||||||
return $page;
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||||||
$node = $page->index->find($id);
|
$node = $page->index->find($id);
|
||||||
if ($node === null) {
|
if ($node === null) {
|
||||||
return DiviPageLoader::unknownNode($id, $page->post);
|
return DiviErrors::unknownNode($id, $page->post);
|
||||||
}
|
}
|
||||||
|
|
||||||
$editor = $this->writer->composition($page)->editor();
|
$editor = $composition->editor();
|
||||||
$position = $editor->position($page->index, $input);
|
$position = $editor->position($page->index, $input);
|
||||||
if (is_string($position)) {
|
if (is_string($position)) {
|
||||||
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]);
|
return DiviErrors::position($position);
|
||||||
}
|
}
|
||||||
|
|
||||||
$problem = $editor->move($page->index, $node, $position);
|
$problem = $editor->move($page->index, $node, $position);
|
||||||
@@ -99,8 +71,6 @@ final class DiviMoveAbility implements Ability
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$change = (new DiviChange(sprintf('Knoten %s verschoben', $id)))->changed($node, 'moved');
|
return (new DiviChange(sprintf('Knoten %s verschoben', $id)))->changed($node, 'moved');
|
||||||
|
|
||||||
return $this->writer->save($this->name(), $page, $change, $input);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ final class DiviNodeGetAbility implements Ability
|
|||||||
|
|
||||||
$block = $page->index->find($id);
|
$block = $page->index->find($id);
|
||||||
if ($block === null) {
|
if ($block === null) {
|
||||||
return DiviPageLoader::unknownNode($id, $page->post);
|
return DiviErrors::unknownNode($id, $page->post);
|
||||||
}
|
}
|
||||||
|
|
||||||
$names = $this->design->read()->presetNames();
|
$names = $this->design->read()->presetNames();
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ final class DiviOutlineAbility implements Ability
|
|||||||
return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']);
|
return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']);
|
||||||
}
|
}
|
||||||
if ($rootId !== null && $page->index->find($rootId) === null) {
|
if ($rootId !== null && $page->index->find($rootId) === null) {
|
||||||
return DiviPageLoader::unknownNode($rootId, $page->post);
|
return DiviErrors::unknownNode($rootId, $page->post);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outline = new Outline($this->design->read()->presetNames());
|
$outline = new Outline($this->design->read()->presetNames());
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use LIT\MCP\Integrations\WordPress\PostGuard;
|
|||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use LIT\MCP\Log;
|
use LIT\MCP\Log;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WP_Post;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
|
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
|
||||||
@@ -91,12 +90,4 @@ class DiviPageLoader
|
|||||||
|
|
||||||
return PostGuard::statusStable($post);
|
return PostGuard::statusStable($post);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function unknownNode(string $id, WP_Post $post): WP_Error
|
|
||||||
{
|
|
||||||
return Errors::notFound(
|
|
||||||
sprintf('Knoten "%s" gibt es in Eintrag %d nicht.', $id, $post->ID),
|
|
||||||
self::OUTLINE_HINT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
|
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -19,12 +17,8 @@ use WP_Error;
|
|||||||
* appear once per call (ruling S14); set and attrs of one change must not reach the same path
|
* appear once per call (ruling S14); set and attrs of one change must not reach the same path
|
||||||
* (ruling S38, checked by Composer::update()).
|
* (ruling S38, checked by Composer::update()).
|
||||||
*/
|
*/
|
||||||
final class DiviUpdateAbility implements Ability
|
final class DiviUpdateAbility extends DiviWriteAbility
|
||||||
{
|
{
|
||||||
public function __construct(private DiviWriter $writer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
return 'lit/divi-update';
|
return 'lit/divi-update';
|
||||||
@@ -77,35 +71,13 @@ final class DiviUpdateAbility implements Ability
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function outputSchema(): ?array
|
|
||||||
{
|
|
||||||
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function annotations(): array
|
public function annotations(): array
|
||||||
{
|
{
|
||||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
|
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDirect(): bool
|
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||||||
{
|
{
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
|
|
||||||
return $page instanceof WP_Error ? $page : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
|
||||||
{
|
|
||||||
$page = $this->writer->page($input);
|
|
||||||
if ($page instanceof WP_Error) {
|
|
||||||
return $page;
|
|
||||||
}
|
|
||||||
|
|
||||||
$changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : [];
|
$changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : [];
|
||||||
if ($changes === []) {
|
if ($changes === []) {
|
||||||
return Errors::invalidInput('Keine Änderungen angegeben; nichts wurde gespeichert.', [
|
return Errors::invalidInput('Keine Änderungen angegeben; nichts wurde gespeichert.', [
|
||||||
@@ -113,9 +85,10 @@ final class DiviUpdateAbility implements Ability
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$composer = $this->writer->composition($page)->composer();
|
$composer = $composition->composer();
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$invalidPaths = false;
|
/** @var list<string> $invalidPaths */
|
||||||
|
$invalidPaths = [];
|
||||||
/** @var array<string, int> $seen id => position of its first change */
|
/** @var array<string, int> $seen id => position of its first change */
|
||||||
$seen = [];
|
$seen = [];
|
||||||
/** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
|
/** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
|
||||||
@@ -150,7 +123,7 @@ final class DiviUpdateAbility implements Ability
|
|||||||
|
|
||||||
$result = $composer->update($block, $change, $field);
|
$result = $composer->update($block, $change, $field);
|
||||||
$errors += $result['errors'];
|
$errors += $result['errors'];
|
||||||
$invalidPaths = $invalidPaths || $result['invalid_paths'] !== [];
|
array_push($invalidPaths, ...$result['invalid_paths']);
|
||||||
$updates[] = [$block, $result['attrs'], $field];
|
$updates[] = [$block, $result['attrs'], $field];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,9 +132,10 @@ final class DiviUpdateAbility implements Ability
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
return $invalidPaths
|
return DiviErrors::composed(
|
||||||
? Errors::invalidAttrPath($errors)
|
['errors' => $errors, 'invalid_paths' => $invalidPaths],
|
||||||
: Errors::invalidInput('Änderungen sind ungültig; nichts wurde gespeichert.', $errors);
|
'Änderungen sind ungültig; nichts wurde gespeichert.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$updates = array_values(array_filter(
|
$updates = array_values(array_filter(
|
||||||
@@ -175,6 +149,6 @@ final class DiviUpdateAbility implements Ability
|
|||||||
$change->changed($block, 'updated');
|
$change->changed($block, 'updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->writer->save($this->name(), $page, $change, $input);
|
return $change;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame of the Divi write tools (spec §5.6): permission and execute both load the page
|
||||||
|
* through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state
|
||||||
|
* from permission), the tool changes the tree in change() – only when its whole input
|
||||||
|
* validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the
|
||||||
|
* result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
|
||||||
|
*/
|
||||||
|
abstract class DiviWriteAbility implements Ability
|
||||||
|
{
|
||||||
|
public function __construct(protected DiviWriter $writer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the tree of $page, or returns why nothing is written. Client values enter the
|
||||||
|
* tree only through $composition (Composer / TreeEditor).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
*/
|
||||||
|
abstract protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error;
|
||||||
|
|
||||||
|
public function outputSchema(): ?array
|
||||||
|
{
|
||||||
|
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDirect(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final public function permission(array $input): bool|WP_Error
|
||||||
|
{
|
||||||
|
$page = $this->writer->page($input);
|
||||||
|
|
||||||
|
return $page instanceof WP_Error ? $page : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final public function execute(array $input): mixed
|
||||||
|
{
|
||||||
|
$page = $this->writer->page($input);
|
||||||
|
if ($page instanceof WP_Error) {
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
$change = $this->change($page, $input, $this->writer->composition($page));
|
||||||
|
if ($change instanceof WP_Error) {
|
||||||
|
return $change;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->writer->save($this->name(), $page, $change, $input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ final class ToolNamingTest extends TestCase
|
|||||||
|
|
||||||
$class = 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], substr((string) $file, strlen($root . '/src/')));
|
$class = 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], substr((string) $file, strlen($root . '/src/')));
|
||||||
$reflection = new ReflectionClass($class);
|
$reflection = new ReflectionClass($class);
|
||||||
if ($reflection->isInterface() || ! $reflection->implementsInterface(Ability::class)) {
|
if (! $reflection->isInstantiable() || ! $reflection->implementsInterface(Ability::class)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user