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
@@ -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));
}
}
+33 -6
View File
@@ -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))
@@ -60,7 +60,13 @@ final class DiviIntegrationTest extends TestCase
self::assertSame('Divi-Bibliothek', $library->label());
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
['lit/library-list', 'lit/library-save', 'lit/library-insert', 'lit/library-find-similar'],
[
'lit/library-list',
'lit/library-save',
'lit/library-insert',
'lit/library-find-similar',
'lit/library-make-global',
],
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
);
foreach ($library->abilities() as $ability) {
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
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\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Throwable;
use WP_Post;
final class LibraryMakeGlobalAbilityTest extends TestCase
{
private WP_Post $page;
/** @var list<LibraryItemSpec> */
private array $specs = [];
/** @var list<array{int, string, SaveScope}> */
private array $entryContents = [];
/** @var list<string> Page contents DiviWriter saved. */
private array $saved = [];
private FakeChangeLog $log;
protected function setUp(): void
{
parent::setUp();
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
LibraryFixtures::given([$this->page]);
Functions\when('wp_revisions_enabled')->justReturn(true);
$this->log = DiviWriterFixture::log();
}
/**
* @param list<string> $deniedRoles
* @param list<Throwable> $pageFailures
*/
private function ability(
array $deniedRoles = [],
bool $diviWritable = true,
bool $unfiltered = false,
array $pageFailures = [],
?DiviPageLoader $loader = null,
?DiviWriter $writer = null
): LibraryMakeGlobalAbility {
$store = LibraryFixtures::store();
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
$this->specs[] = $spec;
return 2300;
});
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array {
$this->entryContents[] = [$id, $content, $scope];
return [];
});
$access = LibraryFixtures::access($deniedRoles, true, $store);
return new LibraryMakeGlobalAbility(
$access,
$writer ?? DiviWriterFixture::writer(
$this->saved,
$pageFailures,
$unfiltered,
$loader ?? LibraryFixtures::loader($access)
),
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
$this->log,
static fn (): bool => $diviWritable
);
}
/** @return array<string, mixed> */
private function input(array $extra = []): array
{
return array_merge(
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
$extra
);
}
public function testReplacesTheNodeByAnInstanceOfTheNewGlobalEntry(): void
{
$result = $this->ability()->execute($this->input());
self::assertTrue($result['ok']);
self::assertSame(2300, $result['library_id']);
self::assertTrue($this->specs[0]->global);
self::assertSame(
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
$this->entryContents[0][1],
'the entry holds exactly the node, framed like the builder'
);
self::assertCount(1, $this->saved);
self::assertStringContainsString(
'<!-- wp:divi/global-layout {"globalModule":"2300","blockName":"divi/section","localAttrs":',
$this->saved[0]
);
self::assertStringNotContainsString(
'<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"',
$this->saved[0],
'the plain section is gone'
);
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, 'lit/library-find-similar')));
}
public function testEditorCannotMakeACodeModuleGlobal(): void
{
$this->page = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
);
LibraryFixtures::given([$this->page]);
$result = $this->ability()->execute($this->input(['id' => 's0']));
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Code-Modul', implode(' ', self::errorFields($result)));
self::assertSame([], $this->specs);
self::assertSame([], $this->saved);
}
/** All or nothing: the page save failing removes the new entry again. */
public function testFailedPageSaveDeletesTheNewEntry(): void
{
Functions\expect('wp_delete_post')->once()->with(2300, true)->andReturn(new WP_Post(['ID' => 2300]));
try {
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute($this->input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertCount(1, $this->specs);
}
public function testRefusesNodesThatAreOrHoldGlobalModules(): void
{
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
self::assertToolError($this->ability()->execute($this->input(['id' => 's0'])), 'validation_failed');
self::assertSame([], $this->specs);
}
public function testNeedsSaveGlobalAndInsertRightsAndTheDiviGate(): void
{
self::assertToolError($this->ability(['edit_global_library'])->execute($this->input()), 'forbidden');
self::assertToolError($this->ability(['add_library'])->execute($this->input()), 'forbidden');
self::assertToolError($this->ability([], false)->execute($this->input()), 'unsupported');
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'alt'])), 'rev_mismatch');
self::assertSame([], $this->specs);
self::assertFalse($this->ability()->isDirect());
}
public function testLogsTheNewEntry(): void
{
$this->ability()->execute($this->input());
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
self::assertSame('2300', $this->log->records[0]['object_id']);
self::assertStringContainsString('global', $this->log->records[0]['summary']);
}
/**
* Ruling Q7: a Divi 4 shortcode in a text value renders another entry (or, casting to 0, the newest
* layout – the entry being created); such a node never becomes a global entry.
*/
public function testRefusesAShortcodeReferenceInsideAValue(): void
{
$quote = chr(92) . '"';
foreach (['1058', 'abc'] as $ref) {
$this->page = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
. $quote . $ref . $quote . ']x[/et_pb_text]"}}}} /-->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
);
LibraryFixtures::given([$this->page]);
$result = $this->ability([], true, true)->execute($this->input(['id' => 's0']));
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('global_module', implode(' ', self::errorFields($result)));
}
self::assertSame([], $this->specs);
self::assertSame([], $this->saved);
}
public function testLibraryEntriesAndThemeBuilderLayoutsAreNoSource(): void
{
$item = LibraryFixtures::item(1055);
LibraryFixtures::given([$item]);
$fromLibrary = $this->ability()->execute(
['post_id' => 1055, 'tree_rev' => Rev::of((string) $item->post_content), 'id' => 's0', 'title' => 'x']
);
self::assertToolError($fromLibrary, 'validation_failed');
self::assertArrayHasKey('post_id', self::errorFields($fromLibrary));
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
LibraryFixtures::given([$body]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
$loader = new DiviPageLoader(
DiviFixtures::schema(),
new Parser(),
LibraryFixtures::access(),
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
);
$fromLayout = $this->ability([], true, false, [], $loader)->execute(
['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'id' => 's0', 'title' => 'x']
);
self::assertToolError($fromLayout, 'validation_failed');
self::assertStringContainsString('Theme-Builder', $fromLayout->get_error_message());
self::assertSame([], $this->specs);
self::assertSame([], $this->saved);
}
/** A page write that may have been saved keeps the entry – the page may already show it. */
public function testAPartialPageWriteKeepsTheEntry(): void
{
Functions\expect('wp_delete_post')->never();
$failures = [WriteFailed::partial('Die Divi-Seite konnte nicht gespeichert werden.', postId: 7)];
$design = DiviWriterFixture::designReader();
$writer = new DiviWriter(
LibraryFixtures::loader(),
DiviWriterFixture::saver($this->saved, $failures),
FakeChangeLog::any(),
$design,
DiviWriterFixture::composition($design)
);
try {
$this->ability([], true, false, [], null, $writer)->execute($this->input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(2300, $e->details['library_id']);
}
}
}
@@ -237,4 +237,26 @@ final class LibrarySaveAbilityTest extends TestCase
self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed');
self::assertSame([], $this->specs);
}
/**
* Ruling Q7: a global entry never renders another entry, in any spelling Divi renders – here a Divi 4
* shortcode inside a text value; a normal entry may hold it (it is copied, not shown as a module).
*/
public function testGlobalScopeRefusesReferencesInAnySpelling(): void
{
$quote = chr(92) . '"';
$this->page = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
. $quote . '1058' . $quote . ']x[/et_pb_text]"}}}} /-->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
);
LibraryFixtures::given([$this->page]);
$global = $this->ability()->execute($this->input(['id' => 's0', 'scope' => 'global']));
self::assertToolError($global, 'validation_failed');
self::assertSame([], $this->specs);
self::assertTrue($this->ability()->execute($this->input(['id' => 's0']))['ok']);
}
}
@@ -115,6 +115,45 @@ final class UsageMatcherTest extends TestCase
self::assertSame($shows, UsageMatcher::shows($content, 123));
}
/**
* Ruling Q7: referencesAny() refuses every reference Divi renders, to any entry – what shows() finds for
* some ID, and the values that cast to 0 (the newest layout – possibly the entry being created).
*
* @dataProvider contents
*/
public function testReferencesAnyFindsEveryReferenceToAnyEntry(string $content, bool $shows): void
{
$any = UsageMatcher::shows($content, 123) || UsageMatcher::shows($content, 124)
|| UsageMatcher::shows($content, 1234) || UsageMatcher::shows($content, 1230);
self::assertSame($any, UsageMatcher::referencesAny($content));
}
/** @dataProvider wildcards */
public function testReferencesAnyFindsValuesThatCastToZero(string $content): void
{
self::assertTrue(UsageMatcher::referencesAny($content));
}
/** @return array<string, array{string, bool}> */
public static function anyContents(): array
{
return [
'divi 5 non-numeric id' => ['<!-- wp:divi/global-layout {"globalModule":"abc","blockName":"divi/text"} /-->', true],
'divi 5 numeric id' => ['<!-- wp:divi/global-layout {"globalModule":7} /-->', true],
'divi 5 textual id in raw text' => ['<p>"globalModule":"9"</p>', true],
'divi 4 other tag' => ['[et_pb_blurb global_module="55"][/et_pb_blurb]', true],
'divi 4 zero' => ['[et_pb_section global_module="0"][/et_pb_section]', false],
'plain text' => ['<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"<p>Global</p>"}}}} /-->', false],
];
}
/** @dataProvider anyContents */
public function testReferencesAnyFailsClosed(string $content, bool $references): void
{
self::assertSame($references, UsageMatcher::referencesAny($content));
}
public function testTheEscapeBytesAreReal(): void
{
self::assertSame(6, strlen(self::u('0067')));