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
This commit is contained in:
2026-09-27 21:35:48 +02:00
co-authored by Claude Opus 5.5
parent 4f70886006
commit 44c1804c4c
9 changed files with 830 additions and 101 deletions
@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/library-make-global (spec §5.7, E10): a node becomes a global module – a new global library entry holding
* exactly its bytes (NewLibraryEntry, the lit/library-save path: rights with scope "global", ruling P7,
* StoredNodeGuard, no reference to any entry in any spelling, ruling Q7) and the node replaced on its page by the
* instance the Visual Builder writes (GlobalInstance::of). All or nothing: the page is written through
* DiviWriter::commit() (tree_rev lock, revision); when it is refused or rolled back, the new entry is deleted
* again (NewDraft) – only a page write that may have been saved (partial) keeps the entry, because the page may
* already show it. Sources are pages and posts only: no library entries (no nesting, ruling P11) and no Theme
* Builder layouts (controller ruling; there lit/library-save and lit-library-insert). Needs the rights of
* lit/library-save with scope "global" and of lit-library-insert, and follows lit-divi-insert (audit M2).
*/
final class LibraryMakeGlobalAbility implements Ability
{
public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder '
. 'entfernt.';
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */
public function __construct(
private LibraryAccess $access,
private DiviWriter $writer,
private NewLibraryEntry $entries,
private ChangeLog $log,
private ?Closure $diviWritable = null
) {
}
public function name(): string
{
return 'lit/library-make-global';
}
public function label(): string
{
return 'In globales Modul umwandeln';
}
public function description(): string
{
return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) auf einer Seite oder '
. 'in einem Beitrag in ein globales Modul um: legt einen globalen Bibliothekseintrag mit genau diesem '
. 'Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – alles oder nichts. Danach zeigt die '
. 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviSchema::write(), [
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
'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']);
}
public function annotations(): array
{
return Annotations::destructive(false);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return $this->precheck($input) ?? true;
}
/** @throws WriteFailed */
public function execute(array $input): mixed
{
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
return $this->makeGlobal($input);
}
/**
* Rights of lit-library-insert (add_library, library group writable) and of lit/library-save with scope
* "global" (publish_posts, save_library, edit_global_library), and lit-divi-insert's Tools-tab state (M2).
*
* @param array<string, mixed> $input
*/
private function precheck(array $input): ?WP_Error
{
$refusal = $this->access->canInsert() ?? $this->access->canSave(true);
if ($refusal !== null) {
return $refusal;
}
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.'
);
}
return null;
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function makeGlobal(array $input): array|WP_Error
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviErrors::unknownNode($id, $page->post);
}
$refusal = self::sourceRefusal($page);
if ($refusal !== null) {
return $refusal;
}
// LibraryItemSpec::forNode() refuses instances and nodes holding one, NewLibraryEntry every other
// spelling of a reference (rulings P11, Q7, B17) – one rule, no copy here.
$spec = $this->entries->spec($page, $node, $input, true, $this->writer->composition($page)->storedGuard());
if ($spec instanceof WP_Error) {
return $spec;
}
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
$created = $this->entries->create($spec, $node, $draft);
$libraryId = $created['id'];
$root = $node->copy();
$root->unifyLineBreaks();
$instance = GlobalInstance::of($root, $libraryId);
$page->index->parentOf($node)?->replaceChild($node, $instance);
$change = (new DiviChange(sprintf(
'Knoten %s in das globale Modul „%s“ (%d) umgewandelt',
$id,
$spec->title,
$libraryId
)))->record($instance, 'replaced');
try {
$result = $this->writer->commit($this->name(), $page, $change, $input);
} catch (WriteFailed $e) {
if (! $e->rolledBack) {
// The page may already show the instance: deleting the entry would leave it empty.
throw $e->withDetails(
['library_id' => $libraryId],
sprintf(
'Den Aufruf nicht einfach wiederholen. Der neue globale Eintrag %d bleibt bestehen, weil die '
. 'Seite ihn möglicherweise schon zeigt; den Stand mit lit-divi-outline prüfen und dem '
. 'Nutzer Bescheid geben.',
$libraryId
)
);
}
$draft->discard($libraryId, $e, self::REASON_PAGE);
}
if ($result instanceof WP_Error) {
return $draft->refuse($libraryId, $result);
}
return $this->result($result, $spec, $libraryId, $created['warnings'], $page, $input);
}
/**
* Only pages and posts: a library entry would nest global modules (P11), a Theme Builder layout is left to
* lit/library-save plus lit-library-insert (controller ruling on the plan's open point).
*/
private static function sourceRefusal(DiviPage $page): ?WP_Error
{
if ($page->isLibraryItem()) {
return Errors::invalidInput(
'Knoten in Bibliothekseinträgen lassen sich nicht in globale Module umwandeln (keine '
. 'Verschachtelung); nichts wurde angelegt.',
['post_id' => 'eine Seite oder ein Beitrag']
);
}
if ($page->isThemeBuilderLayout()) {
return Errors::invalidInput(
'Knoten in Theme-Builder-Layouts wandelt lit/library-make-global nicht um; nichts wurde angelegt.',
['post_id' => 'eine Seite oder ein Beitrag – im Layout mit lit/library-save (scope "global") und '
. 'lit-library-insert (mode "global")']
);
}
return null;
}
/**
* The page's Divi write result plus the new entry: lit_mcp_after_save and a change-log row for the entry
* (like lit/library-save), library_id and the hint on further occurrences.
*
* @param array<string, mixed> $saved
* @param list<string> $entryWarnings
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function result(
array $saved,
LibraryItemSpec $spec,
int $libraryId,
array $entryWarnings,
DiviPage $page,
array $input
): array {
$entry = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $entryWarnings))
->afterSave($input)
->log(DiviLibraryStore::POST_TYPE, sprintf(
'Bibliothek: „%s“ angelegt (%s, global) aus Eintrag %d und dort als Instanz eingesetzt',
$spec->title,
self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType,
(int) $page->post->ID
));
// The entry's warnings, including those of its follow-up (listener, change log).
$warnings = $entry->result('', null)->toArray()['warnings'];
$saved['library_id'] = $libraryId;
$saved['warnings'] = array_merge(
is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [],
$warnings,
[sprintf(
'Gleichartige Vorkommen: lit/library-find-similar mit library_id %d; inhaltsgleiche („identical“) '
. 'mit lit/library-make-global (library_id, targets) durch das globale Modul ersetzen.',
$libraryId
)]
);
return $saved;
}
}