From d66ad36c2eae9827aba5681785b1a7f3b7b5be5b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:15:58 +0200 Subject: [PATCH] feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/ExposurePolicy.php | 6 + src/Integrations/Divi/DiviIntegration.php | 11 +- src/Integrations/Divi/Library/GlobalEdits.php | 200 ++++++++++ .../Divi/Library/GlobalUsages.php | 88 +++++ .../Divi/Library/LibraryAccess.php | 45 ++- .../Divi/Pages/DiviDeleteAbility.php | 1 - .../Divi/Pages/DiviPageLoader.php | 11 +- .../Divi/Pages/DiviReplaceTextAbility.php | 37 +- src/Integrations/Divi/Pages/DiviSaver.php | 41 ++- src/Integrations/Divi/Pages/DiviSchema.php | 5 + .../Divi/Pages/DiviWriteAbility.php | 38 +- src/Integrations/Divi/Pages/DiviWriter.php | 58 ++- src/Integrations/Divi/Pages/SaveScope.php | 33 ++ src/Plugin.php | 6 +- tests/Stubs/EtCorePageResource.php | 19 + tests/Support/DiviWriterFixture.php | 7 +- tests/Unit/Abilities/ExposurePolicyTest.php | 8 + .../Divi/Library/GlobalEditsTest.php | 345 ++++++++++++++++++ .../Divi/Library/GlobalUsagesTest.php | 65 ++++ .../Divi/Library/LibraryAccessTest.php | 29 ++ .../Divi/Pages/DiviPageLoaderTest.php | 23 +- .../Integrations/Divi/Pages/DiviSaverTest.php | 33 ++ tests/bootstrap.php | 1 + 23 files changed, 1066 insertions(+), 44 deletions(-) create mode 100644 src/Integrations/Divi/Library/GlobalEdits.php create mode 100644 src/Integrations/Divi/Library/GlobalUsages.php create mode 100644 src/Integrations/Divi/Pages/SaveScope.php create mode 100644 tests/Stubs/EtCorePageResource.php create mode 100644 tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index a5c8c3c..f323ab5 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -181,6 +181,12 @@ final class ExposurePolicy return isset($this->readonlyGroups[$group]); } + /** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */ + public function groupWritable(string $group): bool + { + return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED; + } + /** * Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The * group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9). diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 80508a7..5539338 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -11,6 +11,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; @@ -98,7 +100,14 @@ final class DiviIntegration implements Integration { $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); $composition = new DiviComposition($this->modules, $this->design); - $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); + $writer = new DiviWriter( + $loader, + new DiviSaver(), + $this->log, + $this->design, + $composition, + new GlobalEdits($this->access, new GlobalUsages()) + ); return [ new DiviOutlineAbility($loader, $this->design), diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php new file mode 100644 index 0000000..5daa105 --- /dev/null +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -0,0 +1,200 @@ +backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item); + $this->trashEnabled = $trashEnabled + ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + } + + /** + * @param array $input + * @return array{scope: SaveScope, details: array}|WP_Error + * @throws WriteFailed When the backup could not be made (nothing was saved). + */ + public function prepare(DiviPage $page, array $input): array|WP_Error + { + $item = $page->post; + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + try { + $before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + $before = []; + } + + $violation = LibraryContent::shapeViolation( + $before, + LibraryContent::roots($page->document), + $this->access->store()->layoutType($id), + $global + ); + if ($violation !== null) { + return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]); + } + + if (! $global) { + return ['scope' => SaveScope::LibraryItem, 'details' => []]; + } + + $usages = $this->usages->of($id); + $details = ['affected' => $usages['items'], 'affected_count' => $usages['count']]; + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + sprintf( + 'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' + . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', + $id, + $usages['count'], + $usages['count'] === 1 ? 'Seite' : 'Seiten' + ), + $details + ); + } + + $refusal = $this->pagesRefusal($id) ?? $this->backupRefusal($item); + if ($refusal !== null) { + return $refusal; + } + + if (! wp_revisions_enabled($item)) { + $details['backup_id'] = ($this->backup)($item); + } + + return ['scope' => SaveScope::GlobalItem, 'details' => $details]; + } + + /** + * A tool's own confirm_required (lit-divi-delete) on a global entry, extended by the affected + * pages – so the one confirm the client sends covers both questions. + */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + if (Errors::codeOf($error) !== 'confirm_required' || ! $this->access->isGlobal($page->post)) { + return $error; + } + + $usages = $this->usages->of((int) $page->post->ID); + $data = $error->get_error_data(); + $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; + + return Errors::confirmRequired( + Errors::clientMessage($error) + . sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']), + array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']]) + ); + } + + /** Ruling P8: every published page that shows entry $id must be editable by the user. */ + private function pagesRefusal(int $id): ?WP_Error + { + $blocked = array_values(array_filter( + $this->usages->publishedIds($id), + static fn (int $pageId): bool => ! current_user_can('edit_post', $pageId) + )); + if ($blocked === []) { + return null; + } + + $listed = implode(', ', array_slice($blocked, 0, self::LISTED_IDS)); + $more = count($blocked) - self::LISTED_IDS; + + return Errors::forbidden( + sprintf( + 'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten Einträgen, die dieses Konto nicht ' + . 'bearbeiten darf (IDs %s%s); nichts wurde gespeichert.', + $id, + $listed, + $more > 0 ? sprintf(' und %d weitere', $more) : '' + ), + 'Die Änderung von jemandem vornehmen lassen, der alle diese Seiten bearbeiten darf (z. B. Redakteur ' + . 'oder Administrator).' + ); + } + + /** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */ + private function backupRefusal(WP_Post $item): ?WP_Error + { + if (wp_revisions_enabled($item)) { + return null; + } + + $hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder ' + . 'bearbeiten.'; + if (! current_user_can('unfiltered_html')) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses ' + . 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.', + (int) $item->ID + ), + $hint + ); + } + + if (! ($this->trashEnabled)()) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert ' + . '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde ' + . 'gespeichert.', + (int) $item->ID + ), + $hint + ); + } + + return null; + } +} diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php new file mode 100644 index 0000000..810fbe8 --- /dev/null +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -0,0 +1,88 @@ +" – Divi's own way to find instances (string scan, + * BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it. + */ +class GlobalUsages +{ + public const LIMIT = 50; + + private const WHERE = "post_content LIKE %s AND post_type <> 'revision' " + . "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')"; + + public function __construct(private ?\wpdb $db = null) + { + } + + /** @return array{items: list, count: int} */ + public function of(int $libraryId): array + { + $db = $this->db(); + $like = self::like($db, $libraryId); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $count = (int) $db->get_var( + $db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::WHERE, $like) + ); + $rows = $db->get_results( + $db->prepare( + 'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' . self::WHERE + . ' ORDER BY ID ASC LIMIT %d', + $like, + self::LIMIT + ), + 'ARRAY_A' + ); + // phpcs:enable + + $items = []; + foreach (is_array($rows) ? $rows : [] as $row) { + $id = (int) $row['ID']; + $items[] = [ + 'post_id' => $id, + 'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)', + 'status' => (string) $row['post_status'], + 'type' => (string) $row['post_type'], + ]; + } + + return ['items' => $items, 'count' => max($count, count($items))]; + } + + /** + * IDs of every published post that shows $libraryId – no limit (ruling P8 checks edit_post on + * each of them before a confirmed global edit). + * + * @return list + */ + public function publishedIds(int $libraryId): array + { + $db = $this->db(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $ids = $db->get_col($db->prepare( + 'SELECT ID FROM ' . $db->posts . " WHERE post_content LIKE %s AND post_type <> 'revision' " + . "AND post_status = 'publish' ORDER BY ID ASC", + self::like($db, $libraryId) + )); + // phpcs:enable + + return array_values(array_map('intval', is_array($ids) ? $ids : [])); + } + + private function db(): \wpdb + { + return $this->db ?? $GLOBALS['wpdb']; + } + + private static function like(\wpdb $db, int $libraryId): string + { + return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%'; + } +} diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index 0a4d656..f097fed 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -53,7 +53,7 @@ class LibraryAccess * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling * P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID * that does not exist, so its type and existence stay unknown; only a user with the library - * right who lacks edit_post on the entry gets forbidden (like pages). + * right who lacks edit_post on the entry gets forbidden (like pages). $forWrite adds canEdit(). */ public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null { @@ -67,7 +67,7 @@ class LibraryAccess return PostGuard::notFound((int) $post->ID); } - return $this->canRead($post) ?? $post; + return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post; } /** Listing the library: edit_posts and the Divi role option divi_library. */ @@ -93,6 +93,47 @@ class LibraryAccess : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); } + /** + * Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live + * entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route + * does not check edit_post on the entry, F9; LIT MCP does). + */ + public function canEdit(WP_Post $item): ?WP_Error + { + $refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate(); + if ($refusal !== null) { + return $refusal; + } + + if ((string) $item->post_status !== 'publish') { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.', + (int) $item->ID, + (string) $item->post_status + ), + ['post_id' => 'nur aktive Bibliothekseinträge'], + 'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.' + ); + } + + return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null; + } + + /** Refusal while the Tools tab keeps this connection from writing to the library. */ + public function writableGate(): ?WP_Error + { + if ($this->writable === null || ($this->writable)()) { + return null; + } + + return Errors::forbidden( + 'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf ' + . '„nur lesen“ oder deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + public function isGlobal(WP_Post $item): bool { return $this->store->isGlobal((int) $item->ID); diff --git a/src/Integrations/Divi/Pages/DiviDeleteAbility.php b/src/Integrations/Divi/Pages/DiviDeleteAbility.php index 1b2a9be..0d52bea 100644 --- a/src/Integrations/Divi/Pages/DiviDeleteAbility.php +++ b/src/Integrations/Divi/Pages/DiviDeleteAbility.php @@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility 'items' => ['type' => 'string'], 'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).', ], - 'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'], ]), 'required' => ['post_id', 'tree_rev', 'ids'], 'additionalProperties' => false, diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 5bcd09f..5da88a3 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -44,8 +44,8 @@ final class DiviPageLoader /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { - // Library entries (E1): only for reading so far, and only with a library access. - $item = $forWrite ? null : $this->library?->item($postId, false); + // Library entries (E1): with a library access, for reading and writing. + $item = $this->library?->item($postId, $forWrite); $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); if ($post instanceof WP_Error) { return $post; @@ -93,13 +93,14 @@ final class DiviPageLoader } /** - * Additional checks for writing: the type must use the Divi builder, the content must be a - * Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2). + * Additional checks for writing: the type must use the Divi builder (library entries always do), + * the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule + * (spec §2). */ public function writable(DiviPage $page): ?WP_Error { $post = $page->post; - if (! $this->schema->diviEnabled((string) $post->post_type)) { + if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) { return Errors::unsupported( sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type), 'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).' diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 88bf14e..7a8ce54 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -29,9 +29,11 @@ defined('ABSPATH') || exit; * * Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is * computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of - * it is written; DiviWriter::save() stores it with its own restore-point revision and change - * log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged - * and reported there, the others go on. Inserted text is cleaned by the field kind through + * it is written; DiviWriter::commit() stores it with its own restore-point revision and change + * log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never + * changed here – the target reports the pages it shows on, answer F2). The batch reports every + * page (spec §8.2, ruling S10): any Throwable of one page is logged and reported there, the + * others go on. Inserted text is cleaned by the field kind through * ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched. */ final class DiviReplaceTextAbility implements Ability @@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability $change->record($block, 'updated'); } - $saved = $this->writer->save($this->name(), $page, $change, $input); + // Ruling P1: through commit() like every Divi tool – library entries keep their shape and get + // no Divi flags. $input never carries confirm, so a global entry only answers with its dry + // run (the pages it would change, answer F2) and is never saved from here. + $saved = $this->writer->commit($this->name(), $page, $change, $input); + if ($saved instanceof WP_Error) { + return $base + self::refused($saved); + } return $base + [ 'ok' => true, @@ -378,6 +386,27 @@ final class DiviReplaceTextAbility implements Ability ]; } + /** + * A page commit() refused: its message, and for a global library entry the pages it shows on. + * + * @return array + */ + private static function refused(WP_Error $error): array + { + $data = $error->get_error_data(); + if (Errors::codeOf($error) !== 'confirm_required' || ! is_array($data)) { + return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.']; + } + + return [ + 'ok' => false, + 'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, ' + . 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', + 'affected' => $data['affected'] ?? [], + 'affected_count' => $data['affected_count'] ?? 0, + ]; + } + /** * Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's * own composition (ruling S22). diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 940af63..ac6d639 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -50,6 +50,9 @@ defined('ABSPATH') || exit; * pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings * S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md. * + * SaveScope: Bibliothekseinträge bekommen keine Divi-Flags; ein globaler Eintrag leert zusätzlich den CSS-Cache + * aller Seiten und den Theme-Builder-Cache. + * * If saving throws, the previous content is written back (WriteFailed: rolled back or not). * Not final so abilities can be tested with a mock. */ @@ -64,7 +67,7 @@ class DiviSaver * @return array{revision_id: ?int, warnings: list} * @throws WriteFailed */ - public function save(WP_Post $post, string $content): array + public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array { $postId = (int) $post->ID; $revisionId = null; @@ -90,17 +93,27 @@ class DiviSaver } $warnings = []; + if ($scope->setsFlags()) { + FollowUp::run( + static fn () => DiviFlags::set($postId), + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + } FollowUp::run( - static fn () => DiviFlags::set($postId), - 'divi builder flags', - 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' - . 'einmal im Divi-Builder öffnen und speichern.', - $warnings - ); - FollowUp::run( - static function () use ($postId): void { - if (class_exists('ET_Core_PageResource')) { - \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + static function () use ($postId, $scope): void { + if (! class_exists('ET_Core_PageResource')) { + return; + } + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + // A global library entry is part of every page that shows it (spec §5.6 step 5). + if ($scope->clearsSiteCss()) { + \ET_Core_PageResource::remove_static_resources('all', 'all', true); + if (function_exists('et_theme_builder_clear_wp_cache')) { + et_theme_builder_clear_wp_cache('all'); + } } }, 'divi static css', @@ -128,14 +141,16 @@ class DiviSaver * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException */ - public function saveNew(int $postId, string $content): array + public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array { $refusal = self::refusal($this->writeContent($postId, $content)); if ($refusal !== null) { throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); } - DiviFlags::set($postId); + if ($scope->setsFlags()) { + DiviFlags::set($postId); + } $stored = get_post($postId); diff --git a/src/Integrations/Divi/Pages/DiviSchema.php b/src/Integrations/Divi/Pages/DiviSchema.php index 8d4913e..bf6f97c 100644 --- a/src/Integrations/Divi/Pages/DiviSchema.php +++ b/src/Integrations/Divi/Pages/DiviSchema.php @@ -18,6 +18,11 @@ final class DiviSchema 'type' => 'string', 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', ], + 'confirm' => [ + 'type' => 'boolean', + 'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer ' + . 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.', + ], ]; } diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index 5a90b13..fac7ac2 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -14,8 +14,8 @@ defined('ABSPATH') || exit; * The frame of the Divi write tools (spec §5.6): permission and execute both load the page * through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state * from permission), the tool changes the tree in change() – only when its whole input - * validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the - * result. The composition is bound to the page (DiviWriter::composition(), ruling S22). + * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries: + * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22). */ abstract class DiviWriteAbility implements Ability { @@ -33,7 +33,14 @@ abstract class DiviWriteAbility implements Ability public function outputSchema(): ?array { - return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + return ToolResult::schema([ + 'tree_rev' => ['type' => 'string'], + // Global library entries (answer F2): the pages that show the entry, and the backup + // made when revisions are off. + 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], + 'affected_count' => ['type' => 'integer'], + 'backup_id' => ['type' => 'integer'], + ], ['tree_rev']); } public function isDirect(): bool @@ -43,6 +50,11 @@ abstract class DiviWriteAbility implements Ability final public function permission(array $input): bool|WP_Error { + $refusal = $this->precheck($input); + if ($refusal !== null) { + return $refusal; + } + $page = $this->writer->page($input); return $page instanceof WP_Error ? $page : true; @@ -50,6 +62,11 @@ abstract class DiviWriteAbility implements Ability final public function execute(array $input): mixed { + $refusal = $this->precheck($input); + if ($refusal !== null) { + return $refusal; + } + $page = $this->writer->page($input); if ($page instanceof WP_Error) { return $page; @@ -57,9 +74,20 @@ abstract class DiviWriteAbility implements Ability $change = $this->change($page, $input, $this->writer->composition($page)); if ($change instanceof WP_Error) { - return $change; + return $this->writer->notice($page, $change); } - return $this->writer->save($this->name(), $page, $change, $input); + return $this->writer->commit($this->name(), $page, $change, $input); + } + + /** + * Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in + * permission() and again in execute(). Null = none. + * + * @param array $input + */ + protected function precheck(array $input): ?WP_Error + { + return null; } } diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 42a1e11..5c0fda0 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use WP_Error; @@ -23,7 +24,8 @@ defined('ABSPATH') || exit; * (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the * tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver, * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, - * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. + * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point + * of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup). */ final class DiviWriter { @@ -35,7 +37,8 @@ final class DiviWriter private DiviSaver $saver, private ChangeLog $log, private DesignSystemReader $design, - private DiviComposition $composition + private DiviComposition $composition, + private ?GlobalEdits $globals = null ) { } @@ -79,8 +82,13 @@ final class DiviWriter * @return array * @throws WriteFailed */ - public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array - { + public function save( + string $ability, + DiviPage $page, + DiviChange $change, + array $input, + SaveScope $scope = SaveScope::Page + ): array { $post = $page->post; $content = Serializer::document($page->document); if ($content === (string) $post->post_content) { @@ -97,7 +105,7 @@ final class DiviWriter $warnings = array_merge($change->warnings(), $quality); try { - $saved = $this->saver->save($post, $content); + $saved = $this->saver->save($post, $content, $scope); } catch (WriteFailed $e) { if (! $e->rolledBack) { $this->log->recordPartial( @@ -140,6 +148,46 @@ final class DiviWriter return $tool->toArray(); } + /** + * save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one + * needs confirm (dry run first), gets a backup when revisions are off and clears every page's + * cache; the result then names the affected pages. + * + * @param array $input + * @return array|WP_Error + * @throws WriteFailed + */ + public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error + { + if (! $page->isLibraryItem()) { + return $this->save($ability, $page, $change, $input); + } + + if ($this->globals === null) { + return Errors::unsupported( + 'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.', + 'Den Eintrag im Divi-Builder bearbeiten.' + ); + } + + if (Serializer::document($page->document) === (string) $page->post->post_content) { + return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem); + } + + $prepared = $this->globals->prepare($page, $input); + if ($prepared instanceof WP_Error) { + return $prepared; + } + + return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']); + } + + /** A tool's confirm_required on a global library entry, extended by the affected pages. */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error; + } + /** * Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed * node has no block any more, but its ID from before the removal still names the (surviving) diff --git a/src/Integrations/Divi/Pages/SaveScope.php b/src/Integrations/Divi/Pages/SaveScope.php new file mode 100644 index 0000000..8841307 --- /dev/null +++ b/src/Integrations/Divi/Pages/SaveScope.php @@ -0,0 +1,33 @@ +groupEnabled(LibraryAccess::GROUP); }; - $divi = new DiviIntegration($schema, $changeLog, libraryVisible: $libraryVisible); // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // when registry and policy exist), so the Divi group's switches stop every Divi layout write. $diviWritable = static function () use (&$registry, &$policy): bool { @@ -86,6 +85,11 @@ final class Plugin && $policy instanceof ExposurePolicy && $policy->allows('lit/divi-insert', $registry->abilities()); }; + // The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9). + $libraryWritable = static function () use (&$policy): bool { + return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); + }; + $divi = new DiviIntegration($schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), diff --git a/tests/Stubs/EtCorePageResource.php b/tests/Stubs/EtCorePageResource.php new file mode 100644 index 0000000..1b09b7f --- /dev/null +++ b/tests/Stubs/EtCorePageResource.php @@ -0,0 +1,19 @@ + */ + public static array $removed = []; + + public static function remove_static_resources($postId, $owner, $force = false): void + { + self::$removed[] = [$postId, $owner, (bool) $force]; + } + } +} diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php index e37e2ec..07deb20 100644 --- a/tests/Support/DiviWriterFixture.php +++ b/tests/Support/DiviWriterFixture.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; @@ -98,7 +99,8 @@ final class DiviWriterFixture array &$saved, array &$failures = [], bool $unfiltered = false, - ?DiviPageLoader $loader = null + ?DiviPageLoader $loader = null, + ?GlobalEdits $globals = null ): DiviWriter { $design = self::designReader(); @@ -107,7 +109,8 @@ final class DiviWriterFixture self::saver($saved, $failures), self::log(), $design, - self::composition($design, $unfiltered) + self::composition($design, $unfiltered), + $globals ); } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index 5951d2f..3c695e8 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -321,4 +321,12 @@ final class ExposurePolicyTest extends TestCase self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available'); } + + public function testGroupWritable(): void + { + self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library')); + } } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php new file mode 100644 index 0000000..b158ee8 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -0,0 +1,345 @@ + Content and scope handed to the saver. */ + private array $saved = []; + + private int $backups = 0; + + /** @var list Published pages that show the global entry (ruling P8). */ + private array $published = [1053]; + + private bool $trash = true; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_revisions_enabled')->justReturn(true); + } + + private function usages(): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->andReturn([ + 'items' => [ + ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], + ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], + ], + 'count' => 2, + ]); + $usages->allows('publishedIds')->andReturnUsing(fn (): array => $this->published); + + return $usages; + } + + /** @param list $deniedRoles */ + private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + $this->saved[] = [$content, $scope]; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $access = LibraryFixtures::access($deniedRoles, $writable); + $design = DiviWriterFixture::designReader(); + $globals = new GlobalEdits( + $access, + $this->usages(), + function (WP_Post $item): int { + ++$this->backups; + + return 1071; + }, + trashEnabled: fn (): bool => $this->trash + ); + + return new DiviWriter( + LibraryFixtures::loader($access), + $saver, + DiviWriterFixture::log(), + $design, + DiviWriterFixture::composition($design), + $globals + ); + } + + private static function rev(int $id): string + { + return Rev::of((string) LibraryFixtures::item($id)->post_content); + } + + /** @return array */ + private static function relabel(int $id, bool $confirm = false): array + { + return array_merge( + ['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]], + $confirm ? ['confirm' => true] : [] + ); + } + + /** A user with every capability but $cap on the posts $ids. */ + private static function denyOn(string $cap, array $ids): void + { + Functions\when('current_user_can')->alias( + static fn (string $asked, mixed ...$args): bool => ! ($asked === $cap + && ($ids === [] || in_array((int) ($args[0] ?? 0), $ids, true))) + ); + } + + public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + + $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057)); + + self::assertTrue($result['ok']); + self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]); + self::assertStringStartsWith('