Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
159 lines
5.9 KiB
PHP
159 lines
5.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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::commit() stores it (library entries:
|
||
* GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
|
||
*/
|
||
abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
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;
|
||
|
||
/**
|
||
* What the tool needs whatever entry it changes; diviFeatures() adds what the entry post_id names (a library
|
||
* entry or Theme Builder layout is saved with its own capability, D5).
|
||
*
|
||
* @return list<DiviFeature>
|
||
*/
|
||
abstract protected function baseFeatures(): array;
|
||
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
return FeatureSets::merge($this->baseFeatures(), FeatureSets::ofEntry($input['post_id'] ?? null, true));
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'tree_rev' => ['type' => 'string'],
|
||
// Global library entries (answer F2): the pages that show the entry, and the backup
|
||
// made when revisions are off (also for live Theme Builder layouts, ruling Q2).
|
||
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'affected_count' => ['type' => 'integer'],
|
||
'hidden_count' => ['type' => 'integer'],
|
||
'backup_id' => ['type' => 'integer'],
|
||
// Library entries and Theme Builder layouts: their revision in WordPress' revision screen (audit L3 –
|
||
// lit/revision-restore takes only content types).
|
||
'restore_url' => ['type' => 'string'],
|
||
// Theme Builder layouts: the enabled templates that show the layout – those the user may edit; the
|
||
// others only as a count (ruling Q22).
|
||
'templates' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'hidden_templates' => ['type' => 'integer'],
|
||
], ['tree_rev']);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
final public function permission(array $input): bool|WP_Error
|
||
{
|
||
$refusal = $this->precheck($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->writer->page($input);
|
||
|
||
return $page instanceof WP_Error ? $page : true;
|
||
}
|
||
|
||
final public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->precheck($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$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 $this->writer->notice($page, $change);
|
||
}
|
||
|
||
return $this->writer->commit($this->name(), $page, $change, $input);
|
||
}
|
||
|
||
/**
|
||
* Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in
|
||
* permission() and again in execute(). Null = none.
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
protected function precheck(array $input): ?WP_Error
|
||
{
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Ruling X3: the loop scope at $block – its nearest loop-enabled ancestor-or-self (LoopContext, X1); none at
|
||
* the top level. $newAttrs (node ID => attrs) replaces the stored attrs of nodes this call changes. The index
|
||
* is the tree loaded for this call, never a client value. Opaque blocks are never entered by NodeIndex, so one
|
||
* can only be $block itself – and every tool refuses to write into or onto it.
|
||
*
|
||
* @param array<string, array<string, mixed>> $newAttrs
|
||
*/
|
||
protected static function loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope
|
||
{
|
||
$chain = [];
|
||
for ($node = $block; $node !== null; $node = $index->parentBlock($node)) {
|
||
array_unshift($chain, $newAttrs[(string) $index->idOf($node)] ?? $node->attrs());
|
||
}
|
||
|
||
return LoopContext::fromChain($chain);
|
||
}
|
||
|
||
/** @param list<string> $names */
|
||
protected static function loopWarning(array $names, LoopScope $from, LoopScope $to): string
|
||
{
|
||
$where = $to->active()
|
||
? sprintf(
|
||
'jetzt in einer Divi-Schleife vom Typ: %s (vorher: %s); passen sie nicht zu dieser Art, zeigt Divi '
|
||
. 'sie leer oder mit Daten eines anderen Objekts mit derselben ID',
|
||
$to->label(),
|
||
$from->active() ? $from->label() : 'keine Schleife'
|
||
)
|
||
: 'jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer';
|
||
|
||
return sprintf(
|
||
'Schleifen-Inhalte (%s) liegen %s. Zurückverschieben oder die Inhalte anpassen.',
|
||
implode(', ', $names),
|
||
$where
|
||
);
|
||
}
|
||
}
|