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:
@@ -17,8 +17,8 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit-library-insert (direct) and, with the
|
||||
* content schema, lit/library-find-similar (E11).
|
||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit-library-insert (direct), with the
|
||||
* content schema lit/library-find-similar (E11), and lit/library-make-global (E10).
|
||||
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
||||
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
||||
* Divi 5 builder, like the Divi group.
|
||||
@@ -76,6 +76,13 @@ final class LibraryIntegration implements Integration
|
||||
if ($this->schema !== null) {
|
||||
$abilities[] = new LibraryFindSimilarAbility($this->access, $this->loader, $this->schema);
|
||||
}
|
||||
$abilities[] = new LibraryMakeGlobalAbility(
|
||||
$this->access,
|
||||
$this->writer,
|
||||
new NewLibraryEntry($this->access, $this->saver, $this->modules),
|
||||
$this->log,
|
||||
$this->diviWritable
|
||||
);
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,15 @@ 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\BlockTree\LibraryContent;
|
||||
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\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Term;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
@@ -34,8 +30,9 @@ defined('ABSPATH') || exit;
|
||||
* 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. The source is never written – linking it to the entry is
|
||||
* lit/library-make-global (Etappe 3b). All or nothing through NewDraft (ruling P6): any failure
|
||||
* 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
|
||||
@@ -44,16 +41,17 @@ final class LibrarySaveAbility implements Ability
|
||||
|
||||
private const SCOPES = ['normal', 'global'];
|
||||
|
||||
private const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
|
||||
private NewLibraryEntry $entries;
|
||||
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviPageLoader $loader,
|
||||
private DiviWriter $writer,
|
||||
private DiviSaver $saver,
|
||||
DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private DiviModules $modules
|
||||
DiviModules $modules
|
||||
) {
|
||||
$this->entries = new NewLibraryEntry($access, $saver, $modules);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -158,43 +156,18 @@ final class LibrarySaveAbility implements Ability
|
||||
return DiviErrors::unknownNode($id, $page->post);
|
||||
}
|
||||
|
||||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||||
if ($title === '') {
|
||||
return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']);
|
||||
}
|
||||
|
||||
$spec = LibraryItemSpec::forNode(
|
||||
$spec = $this->entries->spec(
|
||||
$page,
|
||||
$node,
|
||||
$this->modules->catalog(),
|
||||
$title,
|
||||
$input,
|
||||
self::isGlobal($input),
|
||||
$this->builtFor($page)
|
||||
$this->writer->composition($page)->storedGuard()
|
||||
);
|
||||
if (is_string($spec)) {
|
||||
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||||
if ($spec instanceof WP_Error) {
|
||||
return $spec;
|
||||
}
|
||||
|
||||
$problems = $this->writer->composition($page)->storedGuard()->problems($node);
|
||||
if ($problems !== []) {
|
||||
return Errors::invalidInput(
|
||||
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
|
||||
. 'angelegt.',
|
||||
['id' => implode('; ', $problems)]
|
||||
);
|
||||
}
|
||||
|
||||
$categories = $this->termIds('layout_category', $input['categories'] ?? []);
|
||||
$tags = $this->termIds('layout_tag', $input['tags'] ?? []);
|
||||
foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) {
|
||||
if (is_string($ids)) {
|
||||
return Errors::invalidInput(
|
||||
'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.',
|
||||
[$field => $ids]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->create($page, $spec->withTerms($categories, $tags), self::content($node), $input);
|
||||
return $this->create($page, $spec, $node, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,20 +175,19 @@ final class LibrarySaveAbility implements Ability
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array
|
||||
private function create(DiviPage $page, LibraryItemSpec $spec, Block $node, array $input): array
|
||||
{
|
||||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||||
$libraryId = $this->access->store()->create($spec, $draft);
|
||||
$new = $this->entries->create($spec, $node, $draft);
|
||||
$libraryId = $new['id'];
|
||||
|
||||
$warnings = [];
|
||||
try {
|
||||
$warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem);
|
||||
} catch (Throwable $e) {
|
||||
$draft->discard($libraryId, $e, self::REASON_LAYOUT);
|
||||
}
|
||||
|
||||
$followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings))
|
||||
->afterSave($input);
|
||||
$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(
|
||||
@@ -223,9 +195,10 @@ final class LibrarySaveAbility implements Ability
|
||||
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($created !== null ? (string) $created->post_content : $content))
|
||||
->with('tree_rev', Rev::of($content))
|
||||
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
|
||||
if ($spec->global) {
|
||||
$result->warning(
|
||||
@@ -237,48 +210,9 @@ final class LibrarySaveAbility implements Ability
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
/** The node's stored bytes framed like the builder's "add to library", line breaks unified. */
|
||||
private static function content(Block $node): string
|
||||
{
|
||||
$copy = $node->copy();
|
||||
$copy->unifyLineBreaks();
|
||||
|
||||
return LibraryContent::wrap($copy);
|
||||
}
|
||||
|
||||
/** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */
|
||||
private function builtFor(DiviPage $page): string
|
||||
{
|
||||
if (! $page->isLibraryItem()) {
|
||||
return (string) $page->post->post_type;
|
||||
}
|
||||
|
||||
$builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true);
|
||||
|
||||
return $builtFor !== '' ? $builtFor : 'page';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private static function isGlobal(array $input): bool
|
||||
{
|
||||
return ($input['scope'] ?? 'normal') === 'global';
|
||||
}
|
||||
|
||||
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
|
||||
private function termIds(string $taxonomy, mixed $names): array|string
|
||||
{
|
||||
$ids = [];
|
||||
foreach (is_array($names) ? $names : [] as $name) {
|
||||
$term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false;
|
||||
if (! $term instanceof WP_Term) {
|
||||
return sprintf(
|
||||
'„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).',
|
||||
is_string($name) ? $name : '?'
|
||||
);
|
||||
}
|
||||
$ids[] = (int) $term->term_id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Term;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A new Divi Library entry from a node of an entry (E4; lit/library-save and lit/library-make-global): the spec
|
||||
* (title, LibraryItemSpec::forNode, StoredNodeGuard – the node moves to another post, E6 –, existing categories
|
||||
* and tags) and the creation (DiviLibraryStore::create, then the node's bytes in the divi/placeholder frame with
|
||||
* line breaks unified, stored by DiviSaver::saveNew with SaveScope::LibraryItem – the one content write, D7).
|
||||
* A global entry never names another entry in any spelling Divi renders (ruling Q7, UsageMatcher::referencesAny):
|
||||
* no nesting, and no value that makes Divi render the newest layout – the new entry itself.
|
||||
* All or nothing through NewDraft (ruling P6).
|
||||
*/
|
||||
final class NewLibraryEntry
|
||||
{
|
||||
public const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
|
||||
|
||||
/** Ruling Q7: why a node that names a library entry in any spelling cannot become a global entry. */
|
||||
public const NESTED = 'Der Knoten verweist auf einen Bibliothekseintrag (globales Modul, auch als Divi-4-Shortcode '
|
||||
. 'mit global_module in einem Text); als normalen Eintrag speichern oder den Verweis entfernen.';
|
||||
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviSaver $saver,
|
||||
private DiviModules $modules
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input title, categories, tags */
|
||||
public function spec(
|
||||
DiviPage $page,
|
||||
Block $node,
|
||||
array $input,
|
||||
bool $global,
|
||||
StoredNodeGuard $guard
|
||||
): LibraryItemSpec|WP_Error {
|
||||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||||
if ($title === '') {
|
||||
return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']);
|
||||
}
|
||||
|
||||
$spec = LibraryItemSpec::forNode($node, $this->modules->catalog(), $title, $global, $this->builtFor($page));
|
||||
if (is_string($spec)) {
|
||||
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||||
}
|
||||
|
||||
// Ruling Q7: forNode() sees instances; Divi also renders references in other spellings (P23/P24).
|
||||
if ($global && UsageMatcher::referencesAny(self::content($node))) {
|
||||
return Errors::invalidInput(
|
||||
'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
|
||||
. 'Verschachtelung); nichts wurde angelegt.',
|
||||
['id' => self::NESTED]
|
||||
);
|
||||
}
|
||||
|
||||
$problems = $guard->problems($node);
|
||||
if ($problems !== []) {
|
||||
return Errors::invalidInput(
|
||||
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
|
||||
. 'angelegt.',
|
||||
['id' => implode('; ', $problems)]
|
||||
);
|
||||
}
|
||||
|
||||
$categories = self::termIds('layout_category', $input['categories'] ?? []);
|
||||
$tags = self::termIds('layout_tag', $input['tags'] ?? []);
|
||||
foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) {
|
||||
if (is_string($ids)) {
|
||||
return Errors::invalidInput(
|
||||
'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.',
|
||||
[$field => $ids]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $spec->withTerms($categories, $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, warnings: list<string>}
|
||||
* @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6).
|
||||
*/
|
||||
public function create(LibraryItemSpec $spec, Block $node, NewDraft $draft): array
|
||||
{
|
||||
$id = $this->access->store()->create($spec, $draft);
|
||||
$warnings = [];
|
||||
try {
|
||||
$warnings = $this->saver->saveNew($id, self::content($node), SaveScope::LibraryItem);
|
||||
} catch (Throwable $e) {
|
||||
$draft->discard($id, $e, self::REASON_LAYOUT);
|
||||
}
|
||||
|
||||
return ['id' => $id, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
/** The node's stored bytes framed like the builder's "add to library", line breaks unified. */
|
||||
public static function content(Block $node): string
|
||||
{
|
||||
$copy = $node->copy();
|
||||
$copy->unifyLineBreaks();
|
||||
|
||||
return LibraryContent::wrap($copy);
|
||||
}
|
||||
|
||||
/** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */
|
||||
private function builtFor(DiviPage $page): string
|
||||
{
|
||||
if (! $page->isLibraryItem()) {
|
||||
return (string) $page->post->post_type;
|
||||
}
|
||||
|
||||
$builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true);
|
||||
|
||||
return $builtFor !== '' ? $builtFor : 'page';
|
||||
}
|
||||
|
||||
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
|
||||
private static function termIds(string $taxonomy, mixed $names): array|string
|
||||
{
|
||||
$ids = [];
|
||||
foreach (is_array($names) ? $names : [] as $name) {
|
||||
$term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false;
|
||||
if (! $term instanceof WP_Term) {
|
||||
return sprintf(
|
||||
'„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).',
|
||||
is_string($name) ? $name : '?'
|
||||
);
|
||||
}
|
||||
$ids[] = (int) $term->term_id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ defined('ABSPATH') || exit;
|
||||
* newest layout (et_pb_load_global_module, WP_Query p=0), so it counts for EVERY entry;
|
||||
* - an opener with JSON escapes (or a globalModule/global_module) that cannot be decoded, or content
|
||||
* the regex engine cannot scan, counts as a possible reference.
|
||||
* referencesAny() asks the same for every entry at once (ruling Q7): content that becomes a global entry
|
||||
* must not render another entry – nor, through a value that casts to 0, the newest layout, which may be
|
||||
* that entry itself.
|
||||
* Pure PHP.
|
||||
*/
|
||||
final class UsageMatcher
|
||||
@@ -34,7 +37,25 @@ final class UsageMatcher
|
||||
|
||||
public static function shows(string $content, int $id): bool
|
||||
{
|
||||
$textual = preg_match('/"globalModule":"0*' . $id . '"/', $content);
|
||||
return self::matches($content, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $content renders any library entry, in every spelling shows() knows (ruling Q7): a
|
||||
* globalModule key with any value, a global_module attribute Divi does not skip; fail closed.
|
||||
*/
|
||||
public static function referencesAny(string $content): bool
|
||||
{
|
||||
return self::matches($content, null);
|
||||
}
|
||||
|
||||
/** shows() for entry $id, or for any entry when $id is null. */
|
||||
private static function matches(string $content, ?int $id): bool
|
||||
{
|
||||
$textual = preg_match(
|
||||
$id === null ? '/"globalModule"\s*:/' : '/"globalModule":"0*' . $id . '"/',
|
||||
$content
|
||||
);
|
||||
if ($textual !== 0) {
|
||||
return true; // A match, or a regex failure (fail closed).
|
||||
}
|
||||
@@ -73,8 +94,8 @@ final class UsageMatcher
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a Divi 4 shortcode in $text renders entry $id (or, casting to 0, any entry). */
|
||||
private static function shortcodeShows(string $text, int $id): bool
|
||||
/** Whether a Divi 4 shortcode in $text renders entry $id (null: any entry); a value casting to 0 renders any. */
|
||||
private static function shortcodeShows(string $text, ?int $id): bool
|
||||
{
|
||||
if (stripos($text, 'global_module') === false) {
|
||||
return false;
|
||||
@@ -95,7 +116,7 @@ final class UsageMatcher
|
||||
continue;
|
||||
}
|
||||
$cast = (int) $value;
|
||||
if ($cast === 0 || $cast === $id) {
|
||||
if ($id === null || $cast === 0 || $cast === $id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -140,10 +161,16 @@ final class UsageMatcher
|
||||
return $atts;
|
||||
}
|
||||
|
||||
/** @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id. */
|
||||
private static function names(array $attrs, int $id): bool
|
||||
/**
|
||||
* @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id (null: any
|
||||
* globalModule key, whatever its value – fail closed).
|
||||
*/
|
||||
private static function names(array $attrs, ?int $id): bool
|
||||
{
|
||||
foreach ($attrs as $key => $value) {
|
||||
if ($id === null && $key === 'globalModule') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
$key === 'globalModule'
|
||||
&& (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1))
|
||||
|
||||
Reference in New Issue
Block a user