From 44c1804c4cdbab725849a7d535bc73857f9cf565 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 21:35:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/library-make-global=20=E2=80=93=20n?= =?UTF-8?q?ode=20to=20global=20module,=20all=20or=20nothing=20(E10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Divi/Library/LibraryIntegration.php | 11 +- .../Divi/Library/LibraryMakeGlobalAbility.php | 281 ++++++++++++++++++ .../Divi/Library/LibrarySaveAbility.php | 118 ++------ .../Divi/Library/NewLibraryEntry.php | 152 ++++++++++ .../Divi/Library/UsageMatcher.php | 39 ++- .../Integrations/Divi/DiviIntegrationTest.php | 8 +- .../Library/LibraryMakeGlobalAbilityTest.php | 261 ++++++++++++++++ .../Divi/Library/LibrarySaveAbilityTest.php | 22 ++ .../Divi/Library/UsageMatcherTest.php | 39 +++ 9 files changed, 830 insertions(+), 101 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php create mode 100644 src/Integrations/Divi/Library/NewLibraryEntry.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index 469380b..a975f47 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -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; } diff --git a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php new file mode 100644 index 0000000..bec0bd3 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php @@ -0,0 +1,281 @@ + '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 $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 $input + * @return array|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 $saved + * @param list $entryWarnings + * @param array $input + * @return array + */ + 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; + } +} diff --git a/src/Integrations/Divi/Library/LibrarySaveAbility.php b/src/Integrations/Divi/Library/LibrarySaveAbility.php index 5e3e1f0..eae87be 100644 --- a/src/Integrations/Divi/Library/LibrarySaveAbility.php +++ b/src/Integrations/Divi/Library/LibrarySaveAbility.php @@ -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 * @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 $input */ private static function isGlobal(array $input): bool { return ($input['scope'] ?? 'normal') === 'global'; } - - /** @return list|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)); - } } diff --git a/src/Integrations/Divi/Library/NewLibraryEntry.php b/src/Integrations/Divi/Library/NewLibraryEntry.php new file mode 100644 index 0000000..ee27b5d --- /dev/null +++ b/src/Integrations/Divi/Library/NewLibraryEntry.php @@ -0,0 +1,152 @@ + $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} + * @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|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)); + } +} diff --git a/src/Integrations/Divi/Library/UsageMatcher.php b/src/Integrations/Divi/Library/UsageMatcher.php index 96c47c0..b239dc2 100644 --- a/src/Integrations/Divi/Library/UsageMatcher.php +++ b/src/Integrations/Divi/Library/UsageMatcher.php @@ -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 $attrs Whether a globalModule key at any depth names $id. */ - private static function names(array $attrs, int $id): bool + /** + * @param array $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)) diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index ad3ac78..b60f5c9 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -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) { diff --git a/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php new file mode 100644 index 0000000..ec0a97a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php @@ -0,0 +1,261 @@ + */ + private array $specs = []; + + /** @var list */ + private array $entryContents = []; + + /** @var list 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 $deniedRoles + * @param list $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 */ + 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( + '' + . '' + . '' + ); + 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( + '' + . '' + . '' + ); + 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']); + } + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php index 1f35080..65983b8 100644 --- a/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php @@ -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( + '' + . '' + . '' + ); + 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']); + } } diff --git a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php index dbac8cd..e2afe57 100644 --- a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php +++ b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php @@ -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 */ + public static function anyContents(): array + { + return [ + 'divi 5 non-numeric id' => ['', true], + 'divi 5 numeric id' => ['', true], + 'divi 5 textual id in raw text' => ['

"globalModule":"9"

', 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' => ['', 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')));