diff --git a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php index bec0bd3..32651bd 100644 --- a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php +++ b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php @@ -11,15 +11,25 @@ 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\Block; +use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint; use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; +use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent; +use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; +use LIT\MCP\Integrations\Divi\BlockTree\Parser; 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\Divi\Pages\VisibleEdits; use LIT\MCP\Integrations\WordPress\Content\NewDraft; +use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; +use LIT\MCP\Log; +use Throwable; use WP_Error; +use WP_Post; defined('ABSPATH') || exit; @@ -33,22 +43,50 @@ defined('ABSPATH') || exit; * 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). + * + * Second mode (E10, answer F2): library_id plus targets replaces further occurrences – only nodes whose content + * fingerprint equals the entry's root ("identical" in lit/library-find-similar) – by instances of that active + * global entry (one readable root, no reference to any entry in any spelling, ruling Q7). A global edit: every + * target is checked first (edit_post and tree_rev through DiviWriter::page(), P8a – the account changes only pages + * it may edit; identical nodes; the instance passes the page's StoredNodeGuard; pages and posts only, no library + * entries or Theme Builder layouts), then a dry run lists the pages (tree_rev, status, live – drafts are listed, + * like P8a), and only confirm: true writes. Every page goes through DiviWriter::commit() with its own tree_rev + * lock, revision and change-log row; a live page (publish, private, future) without revisions first gets + * VisibleEdits' backup, refused for every page before anything is written where it could not be faithful (P9). + * Pages are written in the given order; when page N fails, pages 1..N-1 stay replaced (each with its restore + * point), page N is rolled back by DiviSaver (or reported partial), pages after N are not attempted – the error + * names all three groups (changed_posts with pages, failed_post, unchanged_posts; D10, S10). */ final class LibraryMakeGlobalAbility implements Ability { public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder ' . 'entfernt.'; + public const MAX_TARGETS = 20; + + public const MAX_IDS = 20; + 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. */ + /** Inputs of the first mode, refused next to library_id. */ + private const NODE_INPUT = ['post_id', 'tree_rev', 'id', 'title', 'categories', 'tags']; + + private VisibleEdits $visible; + + /** + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. + * @param VisibleEdits|null $visible Backup of live pages without revisions; null = the default. + */ public function __construct( private LibraryAccess $access, private DiviWriter $writer, private NewLibraryEntry $entries, private ChangeLog $log, - private ?Closure $diviWritable = null + private ?Closure $diviWritable = null, + ?VisibleEdits $visible = null, + private Parser $parser = new Parser() ) { + $this->visible = $visible ?? new VisibleEdits(); } public function name(): string @@ -66,7 +104,11 @@ final class LibraryMakeGlobalAbility implements Ability 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.'; + . 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.' + . ' Gleichartige Vorkommen danach ersetzen: library_id plus targets (je post_id, tree_rev und ids ' + . 'inhaltsgleicher Knoten aus lit/library-find-similar, match "identical"; nur Seiten und Beiträge); das ' + . 'ist zuerst ein Probelauf mit den betroffenen Seiten und braucht dann confirm: true. Jede Seite bekommt ' + . 'eine Revision.'; } public function inputSchema(): array @@ -78,8 +120,33 @@ final class LibraryMakeGlobalAbility implements Ability 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], 'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + 'library_id' => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Globaler Eintrag, dessen inhaltsgleiche Vorkommen ersetzt werden (mit targets).', + ], + 'targets' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_TARGETS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'tree_rev' => ['type' => 'string'], + 'ids' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_IDS, + 'items' => ['type' => 'string'], + ], + ], + 'required' => ['post_id', 'tree_rev', 'ids'], + 'additionalProperties' => false, + ], + ], ]), - 'required' => ['post_id', 'tree_rev', 'id', 'title'], + 'required' => [], 'additionalProperties' => false, ]; } @@ -89,6 +156,7 @@ final class LibraryMakeGlobalAbility implements Ability return ToolResult::schema([ 'library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string'], + 'pages' => ['type' => 'array', 'items' => ['type' => 'object']], ], ['library_id']); } @@ -115,18 +183,25 @@ final class LibraryMakeGlobalAbility implements Ability return $refusal; } - return $this->makeGlobal($input); + return self::replaces($input) ? $this->replaceOccurrences($input) : $this->makeGlobal($input); + } + + /** @param array $input Whether the call replaces occurrences (second mode). */ + private static function replaces(array $input): bool + { + return array_key_exists('library_id', $input) || array_key_exists('targets', $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). + * Rights of lit-library-insert (add_library, library group writable) and – for a new entry only – 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); + $refusal = $this->access->canInsert() ?? (self::replaces($input) ? null : $this->access->canSave(true)); if ($refusal !== null) { return $refusal; } @@ -212,6 +287,297 @@ final class LibraryMakeGlobalAbility implements Ability return $this->result($result, $spec, $libraryId, $created['warnings'], $page, $input); } + /** + * @param array $input + * @return array|WP_Error + * @throws WriteFailed + */ + private function replaceOccurrences(array $input): array|WP_Error + { + $mixed = array_values(array_intersect(self::NODE_INPUT, array_keys($input))); + if ($mixed !== [] || ! array_key_exists('library_id', $input) || ! array_key_exists('targets', $input)) { + return Errors::invalidInput( + 'Zum Ersetzen library_id und targets angeben (ohne post_id, id und title); nichts wurde gespeichert.', + ['library_id' => 'library_id und targets zusammen, ohne ' . implode(', ', $mixed ?: self::NODE_INPUT)] + ); + } + + $root = $this->globalRoot($input['library_id']); + if ($root instanceof WP_Error) { + return $root; + } + [$libraryId, $block] = $root; + + $plans = $this->plans($libraryId, $block, $input['targets']); + if ($plans instanceof WP_Error) { + return $plans; + } + + $affected = array_map(static fn (array $plan): array => [ + 'post_id' => (int) $plan[0]->post->ID, + 'title' => (string) $plan[0]->post->post_title, + 'status' => (string) $plan[0]->post->post_status, + 'live' => self::isLive($plan[0]->post), + 'tree_rev' => $plan[0]->treeRev(), + 'ids' => array_keys($plan[1]), + ], $plans); + if (($input['confirm'] ?? false) !== true) { + $live = count(array_filter(array_column($affected, 'live'))); + + return Errors::confirmRequired( + sprintf( + '%d Vorkommen auf %d %s (%d davon veröffentlicht, privat oder geplant) würden durch das globale ' + . 'Modul %d ersetzt; die Seiten zeigen danach den Inhalt aus der Bibliothek. Nichts wurde ' + . 'gespeichert (Probelauf).', + array_sum(array_map(static fn (array $row): int => count($row['ids']), $affected)), + count($affected), + count($affected) === 1 ? 'Seite' : 'Seiten', + $live, + $libraryId + ), + ['affected' => $affected, 'affected_count' => count($affected), 'live_count' => $live] + ); + } + + foreach ($plans as [$page]) { + $refusal = self::isLive($page->post) ? $this->visible->backupRefusal( + $page->post, + sprintf('Seite %d', (int) $page->post->ID), + 'Revisionen aktivieren (WP_POST_REVISIONS) oder die Seite im Divi-Builder bearbeiten.' + ) : null; + if ($refusal !== null) { + return $refusal; + } + } + + return $this->replace($libraryId, $block, $plans); + } + + /** + * Every target checked before anything is written: page rights and tree_rev (DiviWriter::page(), P8a), + * pages and posts only, each once, identical nodes, the instance through the page's StoredNodeGuard. + * + * @return list}>|WP_Error + */ + private function plans(int $libraryId, Block $root, mixed $targets): array|WP_Error + { + $targets = is_array($targets) ? array_values($targets) : []; + if ($targets === [] || count($targets) > self::MAX_TARGETS) { + return Errors::invalidInput( + sprintf('targets fehlt oder hat mehr als %d Seiten; nichts wurde gespeichert.', self::MAX_TARGETS), + ['targets' => sprintf('1 bis %d Seiten', self::MAX_TARGETS)] + ); + } + + $fingerprint = Fingerprint::content($root); + $plans = []; + $errors = []; + $seen = []; + foreach ($targets as $i => $target) { + $target = is_array($target) ? $target : []; + $page = $this->writer->page([ + 'post_id' => $target['post_id'] ?? null, + 'tree_rev' => $target['tree_rev'] ?? null, + ]); + if ($page instanceof WP_Error) { + return $page; + } + $postId = (int) $page->post->ID; + if ($page->isLibraryItem() || $page->isThemeBuilderLayout() || isset($seen[$postId])) { + $errors["targets.$i.post_id"] = 'nur Seiten und Beiträge, jede einmal (Theme-Builder-Layouts und ' + . 'Bibliothekseinträge einzeln mit lit-library-insert)'; + continue; + } + $seen[$postId] = true; + + $ids = is_array($target['ids'] ?? null) ? array_values($target['ids']) : []; + if ($ids === [] || count($ids) > self::MAX_IDS) { + $errors["targets.$i.ids"] = sprintf('1 bis %d Pfad-IDs', self::MAX_IDS); + } + $nodes = []; + foreach ($ids as $j => $id) { + $node = is_string($id) ? $page->index->find($id) : null; + if ($node === null || $node->isOpaque() || Fingerprint::content($node) !== $fingerprint) { + $errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" ' + . 'aus lit/library-find-similar)'; + continue; + } + $nodes[(string) $id] = $node; + } + + $guard = $this->writer->composition($page)->storedGuard(); + $problems = $guard->problems(GlobalInstance::of($root, $libraryId)); + if ($problems !== []) { + $errors["targets.$i.post_id"] = implode('; ', $problems); + } + $plans[] = [$page, $nodes]; + } + + if ($errors !== []) { + return Errors::invalidInput('Nicht alle Ziele lassen sich ersetzen; nichts wurde gespeichert.', $errors); + } + + return $plans; + } + + /** + * Writes the pages in order (see the class comment for a failure on page N). + * + * @param list}> $plans + * @return array + * @throws WriteFailed + */ + private function replace(int $libraryId, Block $root, array $plans): array + { + $pages = []; + $warnings = []; + $changed = []; + foreach ($plans as $n => [$page, $nodes]) { + $postId = (int) $page->post->ID; + $change = new DiviChange( + sprintf('%d Vorkommen durch das globale Modul %d ersetzt', count($nodes), $libraryId) + ); + foreach ($nodes as $node) { + $instance = GlobalInstance::of($root, $libraryId); + $page->index->parentOf($node)?->replaceChild($node, $instance); + $change->record($instance, 'replaced'); + } + + $rest = array_map(static fn (array $plan): int => (int) $plan[0]->post->ID, array_slice($plans, $n + 1)); + $backup = []; + try { + $backup = self::isLive($page->post) ? $this->visible->backup($page->post) : []; + $saved = $this->writer->commit( + $this->name(), + $page, + $change, + ['post_id' => $postId, 'tree_rev' => $page->treeRev(), 'library_id' => $libraryId] + ); + if ($saved instanceof WP_Error) { + throw WriteFailed::rolledBack(Errors::clientMessage($saved), detail: 'commit refused the page'); + } + } catch (WriteFailed $e) { + $e = VisibleEdits::keepingBackup($e, $backup['backup_id'] ?? null, 'der Seite'); + throw self::failedOn($postId, $e, $pages, $rest); + } catch (Throwable $e) { + Log::failure(sprintf('%s on post %d', $this->name(), $postId), $e); + $unknown = WriteFailed::partial( + 'Interner Fehler beim Speichern dieser Seite (Details im PHP-Error-Log der Website).', + $e, + postId: $postId + ); + throw self::failedOn($postId, $unknown, $pages, $rest); + } + + $pages[] = array_merge([ + 'post_id' => $postId, + 'tree_rev' => (string) ($saved['tree_rev'] ?? ''), + 'revision_id' => $saved['revision_id'] ?? null, + ], $backup); + $post = $page->post; + $changed[] = [(string) $postId, (string) $post->post_type, 'replaced', (string) $post->post_title]; + foreach (is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [] as $warning) { + $warnings[] = sprintf('Eintrag %d: %s', $postId, (string) $warning); + } + } + + $tool = ToolResult::ok(sprintf( + 'Vorkommen auf %d %s durch das globale Modul %d ersetzt (je mit Revision).', + count($pages), + count($pages) === 1 ? 'Seite' : 'Seiten', + $libraryId + )); + foreach ($changed as $entry) { + $tool->changed(...$entry); + } + + return $tool->warnings($warnings) + ->with('library_id', $libraryId) + ->with('pages', $pages) + ->toArray(); + } + + /** + * The failure of page $postId with the state of the whole call: pages already replaced (each with its + * revision or backup), the failed one, the ones not attempted. + * + * @param list> $pages Pages replaced before. + * @param list $rest Pages after the failed one. + */ + private static function failedOn(int $postId, WriteFailed $e, array $pages, array $rest): WriteFailed + { + $done = array_map(static fn (array $row): int => (int) $row['post_id'], $pages); + $details = ['changed_posts' => $done, 'pages' => $pages, 'failed_post' => $postId, 'unchanged_posts' => $rest]; + if ($done === []) { + return $e->withDetails($details); + } + + $state = sprintf( + 'Seite %d wurde nicht geändert. Bereits ersetzt (je mit Revision): %s; unverändert: %s.', + $postId, + implode(', ', $done), + $rest === [] ? 'keine weiteren' : implode(', ', $rest) + ); + + return $e->withDetails( + $details, + 'Den Stand mit lit/library-find-similar neu prüfen und nur die übrigen Seiten mit neuem tree_rev ersetzen.', + $e->rolledBack ? $state : null + ); + } + + /** Pages visitors see (or will see): changes there get VisibleEdits' restore point (P8a statuses). */ + private static function isLive(WP_Post $post): bool + { + return in_array((string) $post->post_status, GlobalUsages::LIVE, true); + } + + /** + * The active global entry $libraryId (LibraryAccess::item(): the library right and edit_post on it) with + * its single readable root, holding no global module and naming no entry in any spelling (P11, Q7). + * + * @return array{0: int, 1: Block}|WP_Error + */ + private function globalRoot(mixed $libraryId): array|WP_Error + { + $id = PostGuard::id($libraryId); + if ($id === null) { + return Errors::invalidInput('library_id ist keine ID; nichts wurde gespeichert.', [ + 'library_id' => 'ID aus lit/library-list', + ]); + } + + $item = $this->access->item($id, false); + if ($item === null) { + return PostGuard::notFound($id); + } + if ($item instanceof WP_Error) { + return $item; + } + + $content = (string) $item->post_content; + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, $content)); + } catch (MalformedMarkup) { + $roots = []; + } + $valid = (string) $item->post_status === 'publish' && $this->access->isGlobal($item) && count($roots) === 1 + && ! $roots[0]->isOpaque() && ! LibraryContent::containsGlobal($roots[0]) + && ! UsageMatcher::referencesAny($content); + if (! $valid) { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist kein aktiver globaler Eintrag mit genau einem Knoten ohne Verweis auf ' + . 'andere Einträge; nichts wurde gespeichert.', + $id + ), + ['library_id' => 'globaler Eintrag (lit/library-list, scope "global")'] + ); + } + + return [$id, $roots[0]]; + } + /** * 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). diff --git a/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php index ec0a97a..74a979c 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php @@ -7,7 +7,9 @@ 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\LibraryContent; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec; use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility; use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry; @@ -15,6 +17,7 @@ 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\Pages\VisibleEdits; use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Support\FakeChangeLog; @@ -40,6 +43,9 @@ final class LibraryMakeGlobalAbilityTest extends TestCase private FakeChangeLog $log; + /** @var list Backups VisibleEdits made. */ + private array $backups = []; + protected function setUp(): void { parent::setUp(); @@ -85,7 +91,12 @@ final class LibraryMakeGlobalAbilityTest extends TestCase ), new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()), $this->log, - static fn (): bool => $diviWritable + static fn (): bool => $diviWritable, + new VisibleEdits(function (WP_Post $post): int { + $this->backups[] = ['post_id' => (int) $post->ID, 'backup_id' => 1071]; + + return 1071; + }) ); } @@ -258,4 +269,225 @@ final class LibraryMakeGlobalAbilityTest extends TestCase self::assertSame(2300, $e->details['library_id']); } } + /** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */ + private function givenCopiesOf1058(): string + { + $root = LibraryContent::roots(LibraryContent::load( + new Parser(), + DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') + ))[0]; + $copy = Serializer::block($root); + $variation = str_replace('Global', 'Lokal', $copy); + LibraryFixtures::given([ + $this->page, + LibraryFixtures::item(1058), + new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team', + 'post_content' => '' . $copy . '']), + new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs', + 'post_content' => '' . $variation . $copy . '']), + ]); + + return $copy; + } + + /** @return array */ + private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array + { + return array_filter([ + 'library_id' => 1058, + 'targets' => [ + ['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8], + ['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9], + ], + 'confirm' => $confirm ?: null, + ], static fn (mixed $value): bool => $value !== null); + } + + public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void + { + $this->givenCopiesOf1058(); + + $dry = $this->ability()->execute(self::targets()); + self::assertToolError($dry, 'confirm_required'); + self::assertSame(2, $dry->get_error_data()['affected_count']); + self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id')); + self::assertSame([], $this->saved); + + $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); + + self::assertTrue($result['ok']); + self::assertSame([8, 9], array_column($result['pages'], 'post_id')); + self::assertSame([811, 811], array_column($result['pages'], 'revision_id')); + self::assertCount(2, $this->saved); + self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]); + self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays'); + self::assertSame([], $this->specs, 'no new entry'); + } + + public function testTargetsMustBeIdenticalEditableAndCurrent(): void + { + $this->givenCopiesOf1058(); + + $variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true)); + self::assertToolError($variation, 'validation_failed'); + self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation)); + + $stale = self::targets(['s0'], ['s1'], true); + $stale['targets'][1]['tree_rev'] = 'alt'; + self::assertToolError($this->ability()->execute($stale), 'rev_mismatch'); + + $library = self::targets(['s0'], ['s1'], true); + $library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']]; + LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]); + self::assertToolError($this->ability()->execute($library), 'validation_failed'); + + LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]); + Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9)); + self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden'); + + self::assertSame([], $this->saved, 'nothing was written in any case'); + } + + public function testOnlyGlobalEntriesWithOneReadableRoot(): void + { + $this->givenCopiesOf1058(); + LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]); + + $normal = self::targets(); + $normal['library_id'] = 1055; + self::assertToolError($this->ability()->execute($normal), 'validation_failed'); + } + + public function testAFailureMidwayNamesThePagesAlreadyChanged(): void + { + $this->givenCopiesOf1058(); + + try { + $this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) + ->execute(self::targets(['s0'], ['s1'], true)); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertSame([8], $e->details['changed_posts']); + } + } + + /** The dry run lists every target with its tree_rev and whether visitors see it (P8a: drafts only listed). */ + public function testTheDryRunListsTreeRevAndLiveState(): void + { + $this->givenCopiesOf1058(); + + $data = $this->ability()->execute(self::targets())->get_error_data(); + + self::assertSame(Rev::of((string) get_post(8)->post_content), $data['affected'][0]['tree_rev']); + self::assertTrue($data['affected'][0]['live']); + self::assertFalse($data['affected'][1]['live']); + self::assertSame(['s1'], $data['affected'][1]['ids']); + self::assertSame(1, $data['live_count']); + } + + /** Ruling Q7: an entry that names another entry in any spelling never becomes an instance. */ + public function testRefusesAnEntryWithAReferenceInAnySpelling(): void + { + $copy = $this->givenCopiesOf1058(); + $quote = chr(92) . '"'; + $content = str_replace( + 'Globaler Abschnitt', + '[et_pb_text global_module=' . $quote . '1059' . $quote . ']x[/et_pb_text]', + DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') + ); + LibraryFixtures::given([LibraryFixtures::item(1058, ['post_content' => $content]), get_post(8), get_post(9)]); + + $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('library_id', self::errorFields($result)); + self::assertSame([], $this->saved); + self::assertNotSame('', $copy); + } + + public function testThemeBuilderLayoutsAreNoTargets(): void + { + $this->givenCopiesOf1058(); + $input = self::targets(['s0'], ['s1'], true); + $body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY); + LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), $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()])) + ); + $input['targets'][1] = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'ids' => ['s0']]; + + $result = $this->ability([], true, false, [], $loader)->execute($input); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('targets.1.post_id', self::errorFields($result)); + self::assertSame([], $this->saved); + } + + /** Replacing needs lit-library-insert's rights (not lit/library-save's) and a writable library group. */ + public function testReplacingNeedsInsertRightsOnly(): void + { + $this->givenCopiesOf1058(); + + self::assertToolError($this->ability(['add_library'])->execute(self::targets([], [], true)), 'forbidden'); + self::assertToolError($this->ability([], false)->execute(self::targets()), 'unsupported'); + self::assertToolError($this->ability(['edit_global_library'])->execute(self::targets()), 'confirm_required'); + self::assertSame([], $this->saved); + } + + /** Live pages without revisions get VisibleEdits' backup – refused before any page is written where it would lose bytes. */ + public function testLivePagesWithoutRevisionsGetABackupOrNothingIsWritten(): void + { + $this->givenCopiesOf1058(); + Functions\when('wp_revisions_enabled')->justReturn(false); + + $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); + self::assertSame([['post_id' => 8, 'backup_id' => 1071]], $this->backups); + self::assertSame(1071, $result['pages'][0]['backup_id']); + self::assertArrayNotHasKey('backup_id', $result['pages'][1], 'drafts are only listed (P8a)'); + + $this->backups = []; + $this->saved = []; + $this->givenCopiesOf1058(); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html'); + self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'unsupported'); + self::assertSame([], $this->backups); + self::assertSame([], $this->saved); + } + + /** A failure on page N: the pages before stay replaced (each with revision), the rest untouched – all named. */ + public function testAFailureMidwayReportsEveryPage(): void + { + $this->givenCopiesOf1058(); + + try { + $this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) + ->execute(self::targets(['s0'], ['s1'], true)); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertSame(9, $e->details['failed_post']); + self::assertSame([], $e->details['unchanged_posts']); + self::assertSame(811, $e->details['pages'][0]['revision_id']); + self::assertStringNotContainsString('Nichts wurde gespeichert', (string) $e->toError()->get_error_message()); + } + self::assertCount(1, $this->saved); + } + + public function testAFailureOnTheFirstPageLeavesEverythingUnchanged(): void + { + $this->givenCopiesOf1058(); + + try { + $this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')]) + ->execute(self::targets(['s0'], ['s1'], true)); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertSame([], $e->details['changed_posts']); + self::assertSame([9], $e->details['unchanged_posts']); + } + self::assertSame([], $this->saved); + } }