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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user