Files
WP-LIT-MCP/src/Integrations/Divi/Library/LibrarySaveAbility.php
T
janniclas.loosenandClaude Opus 5.5 44c1804c4c feat: lit/library-make-global – node to global module, all or nothing (E10)
- 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
2026-09-27 21:35:48 +02:00

219 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
}