- NewLibraryEntry: spec and creation of a new library entry, shared by lit/library-save and lit/library-make-global (NewDraft, P7, StoredNodeGuard) - UsageMatcher::referencesAny() (ruling Q7): a global entry never names any entry in any spelling Divi renders (P23/P24 forms, Divi 4 shortcodes inside Divi 5 values, values casting to 0); used by library-save scope global and make-global - make-global: entry created, node replaced by GlobalInstance::of, page through DiviWriter::commit; refused or rolled-back page deletes the entry, a partial page write keeps it (the page may show it); sources are pages and posts only Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
219 lines
8.0 KiB
PHP
219 lines
8.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-save: a node of a Divi page (or library entry) becomes a new Divi Library entry,
|
||
* normal or global (spec §5.7, E4). The entry gets the node's stored bytes in the
|
||
* divi/placeholder frame (LibraryContent::wrap, line breaks between blocks unified like
|
||
* LibraryContent::load), saved through DiviSaver::saveNew with SaveScope::LibraryItem (the one
|
||
* content write, D7; no Divi flags). The node is checked by StoredNodeGuard (E6) because it moves
|
||
* to another post; a global entry names no other entry in any spelling (ruling Q7). Spec and creation
|
||
* are NewLibraryEntry, shared with lit/library-make-global. The source is never written – linking it
|
||
* to the entry is lit/library-make-global. All or nothing through NewDraft (ruling P6): any failure
|
||
* after the insert deletes the new entry, a failed delete is an honest partial_write.
|
||
*/
|
||
final class LibrarySaveAbility implements Ability
|
||
{
|
||
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
|
||
|
||
private const SCOPES = ['normal', 'global'];
|
||
|
||
private NewLibraryEntry $entries;
|
||
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private DiviWriter $writer,
|
||
DiviSaver $saver,
|
||
private ChangeLog $log,
|
||
DiviModules $modules
|
||
) {
|
||
$this->entries = new NewLibraryEntry($access, $saver, $modules);
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-save';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'In der Divi-Bibliothek speichern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – '
|
||
. 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die '
|
||
. 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit '
|
||
. 'lit-library-insert (mode "global") auf Seiten einfügen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => [
|
||
'type' => 'integer',
|
||
'minimum' => 1,
|
||
'description' => 'Seite (oder Eintrag) mit dem Knoten.',
|
||
],
|
||
'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'],
|
||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
|
||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||
'scope' => ['type' => 'string', 'enum' => self::SCOPES, 'description' => 'Standard: normal.'],
|
||
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
],
|
||
'required' => ['post_id', 'tree_rev', 'id', 'title'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema(
|
||
['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']],
|
||
['library_id', 'tree_rev']
|
||
);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::write(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$refusal = $this->access->canSave(self::isGlobal($input));
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->loader->load($input['post_id'] ?? null, false);
|
||
|
||
return $page instanceof WP_Error ? $page : true;
|
||
}
|
||
|
||
/** @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6). */
|
||
public function execute(array $input): mixed
|
||
{
|
||
if (! in_array($input['scope'] ?? 'normal', self::SCOPES, true)) {
|
||
return Errors::invalidInput('scope ist „normal“ oder „global“; nichts wurde angelegt.', [
|
||
'scope' => 'normal oder global',
|
||
]);
|
||
}
|
||
|
||
$refusal = $this->access->canSave(self::isGlobal($input));
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->loader->load($input['post_id'] ?? null, false);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
|
||
return Errors::revMismatch(
|
||
'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.',
|
||
DiviPageLoader::OUTLINE_HINT
|
||
);
|
||
}
|
||
|
||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||
$node = $page->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $page->post);
|
||
}
|
||
|
||
$spec = $this->entries->spec(
|
||
$page,
|
||
$node,
|
||
$input,
|
||
self::isGlobal($input),
|
||
$this->writer->composition($page)->storedGuard()
|
||
);
|
||
if ($spec instanceof WP_Error) {
|
||
return $spec;
|
||
}
|
||
|
||
return $this->create($page, $spec, $node, $input);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function create(DiviPage $page, LibraryItemSpec $spec, Block $node, array $input): array
|
||
{
|
||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||
$new = $this->entries->create($spec, $node, $draft);
|
||
$libraryId = $new['id'];
|
||
|
||
$followUp = (new PostSaveFollowUp(
|
||
$this->name(),
|
||
$this->log,
|
||
$libraryId,
|
||
'der Bibliothekseintrag',
|
||
$new['warnings']
|
||
))->afterSave($input);
|
||
$created = $followUp->reread();
|
||
$kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : '');
|
||
$followUp->log(
|
||
DiviLibraryStore::POST_TYPE,
|
||
sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID)
|
||
);
|
||
|
||
$content = $created !== null ? (string) $created->post_content : NewLibraryEntry::content($node);
|
||
$result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created)
|
||
->with('library_id', $libraryId)
|
||
->with('tree_rev', Rev::of($content))
|
||
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
|
||
if ($spec->global) {
|
||
$result->warning(
|
||
'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert '
|
||
. '(mode "global") einfügen.'
|
||
);
|
||
}
|
||
|
||
return $result->toArray();
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
private static function isGlobal(array $input): bool
|
||
{
|
||
return ($input['scope'] ?? 'normal') === 'global';
|
||
}
|
||
}
|