diff --git a/src/Integrations/Divi/Design/PageRewriter.php b/src/Integrations/Divi/Design/PageRewriter.php new file mode 100644 index 0000000..9cb0296 --- /dev/null +++ b/src/Integrations/Divi/Design/PageRewriter.php @@ -0,0 +1,533 @@ +|null $postIds Only these posts; null = every usage. + * @return array{pages: list, warnings: list}>, + * pages_count: int, nodes_count: int, skipped: list, + * blocked_ids: list, blocked_hidden_count: int, hidden_count: int}|WP_Error + */ + public function preview(UsageQuery $query, Closure $rewrite, ?array $postIds): array|WP_Error + { + $ids = self::ids($postIds); + $report = $this->usages->reportFor($query); + if ($ids === null && $report->tooMany) { + return self::tooMany($report); + } + + $skipped = []; + $pages = []; + $pagesCount = 0; + $nodesCount = 0; + foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) { + ++$pagesCount; + $nodesCount += count($change['result']['changed']); + if (count($pages) < self::PAGE_LIMIT) { + $pages[] = self::listing($change); + } + } + + $blocked = self::blocked($report, $ids); + + return [ + 'pages' => $pages, + 'pages_count' => $pagesCount, + 'nodes_count' => $nodesCount, + 'skipped' => $skipped, + 'blocked_ids' => $blocked['ids'], + 'blocked_hidden_count' => $blocked['hidden'], + 'hidden_count' => $ids === null ? $report->hidden : 0, + ]; + } + + /** + * Writes at most PAGE_LIMIT posts – call it only after the caller's confirm. Each saved post gets its revision + * (DiviSaver) and a change log entry "Divi "": $summary". + * + * @param Closure(): NodeRewrite $rewrite A fresh strategy per post. + * @param list<int>|null $postIds Only these posts; null = every usage. + * @return array{saved: list<array{post_id: int, revision_id: ?int, nodes: int}>, remaining: int, + * warnings: list<string>, skipped: list<array{post_id: int, reason: string}>}|WP_Error + * unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved) + * @throws WriteFailed When saving the first post fails. + * @throws DiviLocked When Divi is not released – before the first post is written. + */ + public function apply( + string $ability, + UsageQuery $query, + Closure $rewrite, + ?array $postIds, + string $summary + ): array|WP_Error { + $ids = self::ids($postIds); + $refusal = $this->refusal($this->usages->reportFor($query), $ids); + if ($refusal !== null) { + return $refusal; + } + + $skipped = []; + $saved = []; + $warnings = []; + $remaining = 0; + foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) { + if (count($saved) >= self::PAGE_LIMIT) { + ++$remaining; + continue; + } + + try { + $saved[] = $this->save($ability, $change, $summary, $warnings); + } catch (WriteFailed | DiviLocked $e) { + return $this->failed($ability, $change['page']->post, $e, $saved); + } + } + + return ['saved' => $saved, 'remaining' => $remaining, 'warnings' => $warnings, 'skipped' => $skipped]; + } + + /** + * Every candidate post that the rewrite changes, one after the other (ID order). Candidates are read in chunks + * with a primed post cache that is dropped afterwards (audit L1). Posts left alone with a reason are added to + * $skipped; post_ids that changed nothing and have no other reason get NO_CHANGE at the end. + * + * @param Closure(): NodeRewrite $rewrite + * @param list<int>|null $ids + * @param list<array{post_id: int, reason: string}> $skipped + * @return Generator<int, array{page: DiviPage, result: array{changed: list<array{id: string, module: string, + * label: string}>, warnings: list<string>, skipped: list<array{id: string, reason: string}>}, + * content: string, scope: SaveScope}> + */ + private function changes(UsageQuery $query, Closure $rewrite, ?array $ids, array &$skipped): Generator + { + $rows = $this->usages->rowsFor($query); + if ($ids !== null) { + $rows = array_filter($rows, static fn (array $row): bool => in_array($row['id'], $ids, true)); + } + + $seen = []; + foreach (array_chunk(array_column($rows, 'id'), GlobalUsages::CONTENT_CHUNK) as $chunk) { + $fresh = _get_non_cached_ids($chunk, 'posts'); + _prime_post_caches($chunk, false, false); + try { + foreach ($chunk as $id) { + $before = count($skipped); + $change = $this->change((int) $id, $rewrite, $skipped); + if ($change !== null || count($skipped) > $before) { + $seen[] = (int) $id; + } + if ($change !== null) { + yield $change; + } + } + } finally { + foreach ($fresh as $freshId) { + wp_cache_delete((int) $freshId, 'posts'); + } + } + } + + foreach (array_diff($ids ?? [], $seen) as $id) { + $skipped[] = ['post_id' => $id, 'reason' => self::NO_CHANGE]; + } + } + + /** + * Post $id loaded fresh and rewritten in memory; null when nothing changes or the post is left alone (then + * with a reason in $skipped, unless the caller may not see it). + * + * @param Closure(): NodeRewrite $rewrite + * @param list<array{post_id: int, reason: string}> $skipped + * @return array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>, + * warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string, + * scope: SaveScope}|null + */ + private function change(int $id, Closure $rewrite, array &$skipped): ?array + { + if (! current_user_can('read_post', $id)) { + return null; + } + + $page = $this->loader->loadWritable($id); + if ($page instanceof WP_Error) { + // A hidden library entry or Theme Builder layout reads like a missing ID (P12): never named. + if (Errors::codeOf($page) !== 'not_found') { + $skipped[] = ['post_id' => $id, 'reason' => Errors::clientMessage($page)]; + } + + return null; + } + + $result = (new TreeRewrite($rewrite()))->apply($page->index); + foreach ($result['skipped'] as $node) { + $skipped[] = ['post_id' => $id, 'reason' => $node['id'] . ': ' . $node['reason']]; + } + + $content = Serializer::document($page->document); + if ($result['changed'] === [] || $content === (string) $page->post->post_content) { + return null; + } + + $scope = $this->scope($page); + if (is_string($scope)) { + $skipped[] = ['post_id' => $id, 'reason' => $scope]; + + return null; + } + + return ['page' => $page, 'result' => $result, 'content' => $content, 'scope' => $scope]; + } + + /** + * The save scope of $page as DiviWriter chooses it, or why the rewriter leaves $page alone: a library entry + * shown on live posts the caller may not edit (P8a), a site-wide entry or a Theme Builder layout without + * revisions (no restore point). + */ + private function scope(DiviPage $page): SaveScope|string + { + $post = $page->post; + $id = (int) $post->ID; + if ($page->isThemeBuilderLayout()) { + return self::withoutRevisions($post, sprintf('Theme-Builder-Layout %d', $id)) + ?? SaveScope::ThemeBuilderLayout; + } + + if (! $page->isLibraryItem()) { + return SaveScope::Page; + } + + $report = $this->usages->report($id); + $global = $this->loader->library()?->isGlobal($post) ?? false; + if (! $global && ! $report->exists()) { + return SaveScope::LibraryItem; + } + + if ($report->blocks()) { + return self::blockedEntry($id, $report); + } + + return self::withoutRevisions($post, sprintf('Bibliothekseintrag %d', $id)) ?? SaveScope::GlobalItem; + } + + /** + * Saves one changed post, then lit_mcp_after_save and the change log (failures there are warnings – the post + * is saved). Warnings join $warnings with the post ID in front. + * + * @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>, + * warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string, + * scope: SaveScope} $change + * @param list<string> $warnings + * @return array{post_id: int, revision_id: ?int, nodes: int} + * @throws WriteFailed + * @throws DiviLocked + */ + private function save(string $ability, array $change, string $summary, array &$warnings): array + { + $post = $change['page']->post; + $id = (int) $post->ID; + $saved = $this->saver->save($post, $change['content'], $change['scope']); + + $notes = array_merge($change['result']['warnings'], $saved['warnings']); + FollowUp::run( + static fn () => do_action('lit_mcp_after_save', $id, $ability, ['post_id' => $id]), + $ability . ' lit_mcp_after_save', + 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.', + $notes + ); + FollowUp::run( + fn () => $this->log->record( + $ability, + (string) $post->post_type, + (string) $id, + sprintf('Divi "%s": %s', $post->post_title, $summary), + $saved['revision_id'] + ), + $ability . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', + $notes + ); + foreach ($notes as $note) { + $warnings[] = sprintf('Eintrag %d: %s', $id, $note); + } + + return [ + 'post_id' => $id, + 'revision_id' => $saved['revision_id'], + 'nodes' => count($change['result']['changed']), + ]; + } + + /** + * Saving $post failed after the posts in $saved: they stay saved (each with its revision) – partial_write + * with data.saved. A post the saver could not roll back is logged as partial (like DiviWriter). + * + * @param list<array{post_id: int, revision_id: ?int, nodes: int}> $saved + * @throws WriteFailed $e itself when nothing was saved before (the registrar answers as for every Divi write). + * @throws DiviLocked $e itself when nothing was saved before. + */ + private function failed(string $ability, WP_Post $post, WriteFailed|DiviLocked $e, array $saved): WP_Error + { + $id = (int) $post->ID; + $partial = $e instanceof WriteFailed && ! $e->rolledBack; + $revisionId = $e instanceof WriteFailed ? $e->revisionId : null; + if ($partial) { + $this->log->recordPartial( + $ability, + (string) $post->post_type, + (string) $id, + sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $revisionId + ); + } + + if ($saved === []) { + throw $e; + } + + Log::failure(sprintf('%s: post %d after %d saved posts', $ability, $id, count($saved)), $e); + $reason = $e instanceof WriteFailed ? $e->reason : Errors::clientMessage($e->toError()); + $count = count($saved); + $failedPost = $partial + ? sprintf('Eintrag %d ist möglicherweise teilweise geändert', $id) + . ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId)) + : sprintf('Eintrag %d blieb unverändert.', $id); + + return Errors::partialWrite( + sprintf('Eintrag %d: %s', $id, $reason), + $id, + $revisionId, + sprintf( + '%d %s davor %s gespeichert (siehe saved, je mit Revision); %s', + $count, + $count === 1 ? 'Eintrag' : 'Einträge', + $count === 1 ? 'ist' : 'sind', + $failedPost + ), + sprintf( + 'Den Aufruf nicht einfach wiederholen. Zuerst Eintrag %d mit lit-divi-outline prüfen und dem Nutzer ' + . 'Bescheid geben; danach denselben Aufruf mit confirm: true erneut senden – bereits geänderte ' + . 'Module trifft er nicht mehr.', + $id + ), + ['saved' => $saved] + ); + } + + /** Ruling P8a like GlobalEdits::pagesRefusal(): above HARD_CAP or with a blocked live usage nothing is written. */ + private function refusal(UsageReport $report, ?array $ids): ?WP_Error + { + if ($ids === null && $report->tooMany) { + return self::tooMany($report); + } + + $blocked = self::blocked($report, $ids); + if ($blocked['ids'] === [] && $blocked['hidden'] === 0) { + return null; + } + + return Errors::make( + 'forbidden', + sprintf( + 'Die Änderung betrifft veröffentlichte, private oder geplante Einträge, die dieses Konto nicht ' + . 'bearbeiten darf (%s); nichts wurde gespeichert.', + self::blockedList($blocked['ids'], $blocked['hidden']) + ), + 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' + . 'oder Administrator), oder sie mit post_ids auf die eigenen Einträge beschränken.', + 403, + ['blocked_ids' => $blocked['ids']] + + ($blocked['hidden'] > 0 ? ['blocked_hidden_count' => $blocked['hidden']] : []) + ); + } + + private static function tooMany(UsageReport $report): WP_Error + { + return Errors::unsupported( + sprintf( + 'Die Änderung betrifft %d veröffentlichte, private oder geplante Einträge – mehr als %d; so große ' + . 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.', + $report->live, + GlobalUsages::HARD_CAP + ), + 'Die Änderung mit post_ids auf einzelne Einträge beschränken oder die Module im Divi-Builder ändern.' + ); + } + + /** + * The blocking posts that count: all of the report, or with post_ids only those among them (unreadable ones + * cannot be matched to IDs; loadWritable() refuses them one by one). + * + * @param list<int>|null $ids + * @return array{ids: list<int>, hidden: int} + */ + private static function blocked(UsageReport $report, ?array $ids): array + { + if ($ids === null) { + return ['ids' => $report->blocked, 'hidden' => $report->blockedHidden]; + } + + return ['ids' => array_values(array_intersect($report->blocked, $ids)), 'hidden' => 0]; + } + + /** Why library entry $id stays unchanged: pages that show it block the edit (P8a, audit L1). */ + private static function blockedEntry(int $id, UsageReport $report): string + { + if ($report->tooMany) { + return sprintf( + 'Bibliothekseintrag %d erscheint auf mehr als %d veröffentlichten, privaten oder geplanten Einträgen; ' + . 'er bleibt unverändert – ihn im Divi-Builder bearbeiten.', + $id, + GlobalUsages::HARD_CAP + ); + } + + return sprintf( + 'Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto ' + . 'nicht bearbeiten darf (%s); er bleibt unverändert.', + $id, + self::blockedList($report->blocked, $report->blockedHidden) + ); + } + + /** Why $post stays unchanged when revisions are off for it (no restore point, no backup copy in bulk). */ + private static function withoutRevisions(WP_Post $post, string $label): ?string + { + if (wp_revisions_enabled($post)) { + return null; + } + + return sprintf( + 'Revisionen sind für %s abgeschaltet (kein Wiederherstellungspunkt); unverändert gelassen – Revisionen ' + . 'aktivieren (WP_POST_REVISIONS) oder im Divi-Builder bearbeiten.', + $label + ); + } + + /** + * Readable blocking posts by ID (at most LISTED_IDS), unreadable ones as a count – as GlobalEdits names them. + * + * @param list<int> $ids + */ + private static function blockedList(array $ids, int $hidden): string + { + $parts = []; + if ($ids !== []) { + $more = count($ids) - self::LISTED_IDS; + $parts[] = 'IDs ' . implode(', ', array_slice($ids, 0, self::LISTED_IDS)) + . ($more > 0 ? sprintf(' und %d weitere', $more) : ''); + } + if ($hidden > 0) { + $parts[] = sprintf( + '%d %s dieses Konto nicht lesen darf', + $hidden, + $hidden === 1 ? 'Eintrag, den' : 'Einträge, die' + ); + } + + return implode('; ', $parts); + } + + /** + * @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>, + * warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string, + * scope: SaveScope} $change + * @return array{post_id: int, title: string, type: string, status: string, + * nodes: list<array{id: string, module: string, label: string}>, warnings: list<string>} + */ + private static function listing(array $change): array + { + $post = $change['page']->post; + + return [ + 'post_id' => (int) $post->ID, + 'title' => (string) $post->post_title, + 'type' => (string) $post->post_type, + 'status' => (string) $post->post_status, + 'nodes' => $change['result']['changed'], + 'warnings' => $change['result']['warnings'], + ]; + } + + /** + * @param array<mixed>|null $postIds + * @return list<int>|null + */ + private static function ids(?array $postIds): ?array + { + return $postIds === null ? null : array_values(array_unique(array_map('intval', $postIds))); + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 99b4a66..0ff12e3 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -17,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; +use LIT\MCP\Integrations\Divi\Design\PageRewriter; use LIT\MCP\Integrations\Divi\Design\PresetComposer; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; @@ -260,6 +261,15 @@ final class DiviIntegration implements Integration return new DesignChange(new SnapshotStore(), $this->log); } + /** + * The page rewriter of lit/divi-presets-assign and lit/divi-design-detach (Etappe 4b, spec §6.6): usages with + * this integration's Theme Builder access, posts through $loader (shared with the other Divi tools). + */ + private function pageRewriter(DiviPageLoader $loader): PageRewriter + { + return new PageRewriter($this->globalUsages(), $loader, new DiviSaver(), $this->log); + } + private function presetComposer(): PresetComposer { $composition = $this->composition(); diff --git a/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php b/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php new file mode 100644 index 0000000..5f1281a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php @@ -0,0 +1,561 @@ +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; + +use Brain\Monkey\Functions; +use Closure; +use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; +use LIT\MCP\Integrations\Divi\Design\NodeRewrite; +use LIT\MCP\Integrations\Divi\Design\PageRewriter; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Integrations\Divi\Library\UsageQuery; +use LIT\MCP\Integrations\Divi\Library\UsageReport; +use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Pages\SaveScope; +use LIT\MCP\Integrations\Divi\Release\DiviLocked; +use LIT\MCP\Integrations\Divi\Release\ReleaseState; +use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; +use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\LibraryFixtures; +use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +use Throwable; +use WP_Post; + +/** Spec §6.6, rulings R1, R8, R9: the page rewriter behind lit/divi-presets-assign and lit/divi-design-detach. */ +final class PageRewriterTest extends TestCase +{ + /** The overlap note the test strategy adds to every block it changes. */ + public const NOTE = 'lokale Werte überdecken „CTA“'; + + private const ABILITY = 'lit/divi-presets-assign'; + + private const SUMMARY = 'Preset „CTA“ zugewiesen'; + + private const BUTTON = '<!-- wp:divi/button {"builderVersion":"5.2.1"} /-->'; + + private const TEXT = '<!-- wp:divi/text {"builderVersion":"5.2.1"} /-->'; + + private const INSTANCE = '<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/button"} /-->'; + + /** @var list<array{int, SaveScope}> Post ID and scope of every save. */ + private array $saved = []; + + /** @var list<Throwable|null> Thrown by the n-th save instead of saving (null = save). */ + private array $failures = []; + + private FakeChangeLog $log; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->justReturn(null); + Functions\when('wp_cache_delete')->justReturn(true); + Functions\when('wp_revisions_enabled')->justReturn(true); + $this->log = new FakeChangeLog(1); + } + + /** One section, row and column holding $modules (IDs s0.r0.c0.m0, …). */ + private static function layout(string ...$modules): string + { + return '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} -->' + . '<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->' + . implode('', $modules) + . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'; + } + + /** @param array<string, mixed> $fields */ + private static function page(int $id, string $content, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => $id, + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => 'Seite ' . $id, + 'post_content' => $content, + ], $fields)); + } + + /** @param list<int> $blocked */ + private static function report( + int $count, + array $blocked = [], + int $live = 0, + bool $tooMany = false, + int $hidden = 0 + ): UsageReport { + return new UsageReport($count, [], $hidden, $live, $blocked, 0, $tooMany); + } + + /** + * The rewriter over $posts: they are the candidates (ID order) and what get_post() finds; the caller has every + * capability. $report answers reportFor() (default: all posts, nothing blocked), $libraryReports report($id). + * + * @param list<WP_Post> $posts + * @param array<int, UsageReport> $libraryReports + */ + private function rewriter(array $posts, ?UsageReport $report = null, array $libraryReports = []): PageRewriter + { + LibraryFixtures::given($posts); + $rows = array_map( + static fn (WP_Post $post): array => [ + 'id' => (int) $post->ID, + 'status' => (string) $post->post_status, + 'type' => (string) $post->post_type, + ], + $posts + ); + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('rowsFor')->andReturn($rows); + $usages->allows('reportFor')->andReturn($report ?? self::report(count($rows))); + $usages->allows('report')->andReturnUsing( + static fn (int $id): UsageReport => $libraryReports[$id] ?? self::report(0) + ); + + return new PageRewriter($usages, LibraryFixtures::loader(), $this->saver(), $this->log); + } + + /** Records post ID and scope, puts the content on the post and reports revision 900 + n. */ + private function saver(): DiviSaver + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + $failure = array_shift($this->failures); + if ($failure !== null) { + throw $failure; + } + $this->saved[] = [(int) $post->ID, $scope]; + $post->post_content = $content; + + return ['revision_id' => 900 + count($this->saved), 'warnings' => []]; + }); + + return $saver; + } + + /** + * A strategy like AssignRewrite: every block of $modules gets the preset stack ["p-cta"] (a block that has it + * stays), each changed block an overlap note; global instances of these modules concern the change. + * + * @param list<string> $modules + * @return Closure(): NodeRewrite + */ + private static function assign(array $modules = ['divi/button']): Closure + { + return static fn (): NodeRewrite => new class ($modules) implements NodeRewrite { + /** @param list<string> $modules */ + public function __construct(private array $modules) + { + } + + public function rewrite(Block $block): ?array + { + $attrs = $block->attrs(); + if (! in_array($block->name(), $this->modules, true) || self::assigned($attrs)) { + return null; + } + + return ['modulePreset' => ['p-cta']] + $attrs; + } + + public function notes(Block $block, string $nodeId): array + { + return self::assigned($block->attrs()) ? [$nodeId . ': ' . PageRewriterTest::NOTE] : []; + } + + public function concernsInstance(Block $instance): bool + { + return in_array(GlobalInstance::effectiveName($instance), $this->modules, true); + } + + /** @param array<string, mixed> $attrs */ + private static function assigned(array $attrs): bool + { + return ($attrs['modulePreset'] ?? null) === ['p-cta']; + } + }; + } + + private static function query(): UsageQuery + { + return Mockery::mock(UsageQuery::class); + } + + /** Runs $call with error_log() going to a temp file (Log::failure() writes there). */ + private static function quietly(Closure $call): mixed + { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $file); + try { + return $call(); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + } + + public function testPreviewListsPagesAndNodesWithoutWriting(): void + { + $rewriter = $this->rewriter( + [ + self::page(101, self::layout(self::BUTTON, self::TEXT, self::BUTTON)), + self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']), + ], + self::report(2, [], 1, false, 3) + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + + self::assertSame(2, $preview['pages_count']); + self::assertSame(3, $preview['nodes_count']); + self::assertSame([101, 102], array_column($preview['pages'], 'post_id')); + self::assertSame('Seite 101', $preview['pages'][0]['title']); + self::assertSame('page', $preview['pages'][0]['type']); + self::assertSame('draft', $preview['pages'][0]['status']); + self::assertSame('publish', $preview['pages'][1]['status']); + self::assertSame(['s0.r0.c0.m0', 's0.r0.c0.m2'], array_column($preview['pages'][0]['nodes'], 'id')); + self::assertSame( + ['s0.r0.c0.m0: ' . self::NOTE, 's0.r0.c0.m2: ' . self::NOTE], + $preview['pages'][0]['warnings'] + ); + self::assertSame([], $preview['skipped']); + self::assertSame([], $preview['blocked_ids']); + self::assertSame(0, $preview['blocked_hidden_count']); + self::assertSame(3, $preview['hidden_count']); + self::assertSame([], $this->saved, 'the dry run writes nothing'); + self::assertSame([], $this->log->records); + } + + /** Ruling P8a like GlobalEdits: one blocked live usage stops the whole write; post_ids limit the check. */ + public function testBlockedLivePageStopsTheWholeWrite(): void + { + $rewriter = $this->rewriter( + [ + self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']), + self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']), + ], + self::report(2, [102], 2) + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + self::assertSame([102], $preview['blocked_ids']); + self::assertSame(2, $preview['pages_count']); + + $error = self::assertToolError( + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY), + 'forbidden' + ); + self::assertSame([102], $error['blocked_ids']); + self::assertStringContainsString('IDs 102', $error['message']); + self::assertStringContainsString('nichts wurde gespeichert', $error['message']); + self::assertSame([], $this->saved); + self::assertSame([], $this->log->records); + + $limited = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101], self::SUMMARY); + + self::assertSame([101], array_column($limited['saved'], 'post_id')); + self::assertSame([[101, SaveScope::Page]], $this->saved); + } + + public function testTooManyLiveUsagesAreUnsupported(): void + { + $rewriter = $this->rewriter( + [self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish'])], + self::report(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true) + ); + + $error = self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported'); + self::assertStringContainsString(sprintf('mehr als %d', GlobalUsages::HARD_CAP), $error['message']); + self::assertToolError( + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY), + 'unsupported' + ); + self::assertSame([], $this->saved); + } + + /** A6: 51 pages – 50 saved, remaining 1; the next call saves the last one, remaining 0. */ + public function testWritesAtMostFiftyPagesAndTheNextCallTheRest(): void + { + $posts = array_map( + static fn (int $id): WP_Post => self::page($id, self::layout(self::BUTTON)), + range(101, 151) + ); + $rewriter = $this->rewriter($posts); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + self::assertSame(51, $preview['pages_count']); + self::assertCount(PageRewriter::PAGE_LIMIT, $preview['pages']); + + $first = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertCount(PageRewriter::PAGE_LIMIT, $first['saved']); + self::assertSame(['post_id' => 101, 'revision_id' => 901, 'nodes' => 1], $first['saved'][0]); + self::assertSame(1, $first['remaining']); + self::assertSame('Eintrag 101: s0.r0.c0.m0: ' . self::NOTE, $first['warnings'][0]); + self::assertSame([], $first['skipped']); + self::assertSame(range(101, 150), array_column($this->saved, 0)); + self::assertCount(PageRewriter::PAGE_LIMIT, $this->log->records); + self::assertSame( + [ + 'ability' => self::ABILITY, + 'object_type' => 'page', + 'object_id' => '101', + 'summary' => 'Divi "Seite 101": ' . self::SUMMARY, + 'revision_id' => 901, + 'snapshot_id' => null, + 'result' => 'ok', + ], + $this->log->records[0] + ); + self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $posts[0]->post_content); + + $second = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame([['post_id' => 151, 'revision_id' => 951, 'nodes' => 1]], $second['saved']); + self::assertSame(0, $second['remaining']); + self::assertSame([], $second['skipped']); + } + + public function testPageWithoutChangeIsNotSavedOrCounted(): void + { + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::TEXT)), + self::page(102, self::layout(self::BUTTON)), + ]); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame(1, $preview['pages_count']); + self::assertSame([102], array_column($result['saved'], 'post_id')); + self::assertSame(0, $result['remaining']); + self::assertSame([], $result['skipped']); + self::assertSame(['102'], array_column($this->log->records, 'object_id')); + } + + /** R8: pages loadWritable() refuses (Divi 4, unparsable – fail-closed candidates) are skipped, not remaining. */ + public function testRefusedPagesAreSkippedWithTheLoaderMessage(): void + { + $rewriter = $this->rewriter([ + self::page( + 101, + '[et_pb_section][et_pb_text]Alt[/et_pb_text][/et_pb_section]', + ['post_status' => 'publish'] + ), + self::page(102, '<!-- wp:divi/section --><!-- wp:divi/row -->'), + self::page(103, self::layout(self::BUTTON)), + ]); + + $result = self::quietly( + fn (): array => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY) + ); + + self::assertSame([103], array_column($result['saved'], 'post_id')); + self::assertSame(0, $result['remaining']); + self::assertSame([101, 102], array_column($result['skipped'], 'post_id')); + self::assertStringContainsString('Divi-4-Inhalt', $result['skipped'][0]['reason']); + self::assertStringContainsString('kein gültiges Block-Markup', $result['skipped'][1]['reason']); + } + + /** Spec §6.6: a failed page keeps the pages saved before – partial_write with their revisions. */ + public function testFailedPageIsPartial(): void + { + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::BUTTON)), + self::page(102, self::layout(self::BUTTON)), + self::page(103, self::layout(self::BUTTON)), + ]); + $this->failures = [null, null, WriteFailed::rolledBack(DiviSaver::REASON, postId: 103)]; + + $result = self::quietly( + fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY) + ); + + $error = self::assertToolError($result, 'partial_write'); + self::assertSame( + [ + ['post_id' => 101, 'revision_id' => 901, 'nodes' => 1], + ['post_id' => 102, 'revision_id' => 902, 'nodes' => 1], + ], + $error['saved'] + ); + self::assertSame(103, $error['post_id']); + self::assertStringContainsString('Eintrag 103: ' . DiviSaver::REASON, $error['message']); + self::assertStringContainsString('2 Einträge davor sind gespeichert', $error['message']); + self::assertStringContainsString('Eintrag 103 blieb unverändert.', $error['message']); + $this->log->assertRecorded( + ['object_id' => '101', 'revision_id' => 901], + ['object_id' => '102', 'revision_id' => 902] + ); + } + + /** Like DiviWriter: a page the saver could not roll back is logged as partial. */ + public function testPageThatWasNotRolledBackIsLoggedAsPartial(): void + { + $this->log = FakeChangeLog::any(); + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::BUTTON)), + self::page(102, self::layout(self::BUTTON)), + ]); + $this->failures = [null, WriteFailed::partial(DiviSaver::REASON, postId: 102, revisionId: 812)]; + + $result = self::quietly( + fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY) + ); + + $error = self::assertToolError($result, 'partial_write'); + self::assertSame(812, $error['revision_id']); + self::assertStringContainsString('1 Eintrag davor ist gespeichert', $error['message']); + self::assertStringContainsString( + 'Eintrag 102 ist möglicherweise teilweise geändert (Stand vorher: Revision 812).', + $error['message'] + ); + $this->log->assertRecorded(['object_id' => '101']); + $this->log->assertPartials([ + 'object_id' => '102', + 'summary' => 'Divi "Seite 102": Änderung nur teilweise zurückgenommen', + 'revision_id' => 812, + ]); + } + + /** Spec §5.5: an unreleased Divi before the first page is the release refusal (AbilityRegistrar). */ + public function testLockedDiviBeforeTheFirstPageIsThrown(): void + { + $rewriter = $this->rewriter([self::page(101, self::layout(self::BUTTON))]); + $this->failures = [new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.16.0'))]; + + $this->expectException(DiviLocked::class); + + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + } + + /** R8: a global instance is never rewritten; it is skipped with its node ID, the rest of the page is saved. */ + public function testGlobalInstanceIsSkippedAndKeptAsItIs(): void + { + $post = self::page(101, self::layout(self::INSTANCE, self::BUTTON)); + $rewriter = $this->rewriter([$post]); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + + self::assertSame(['s0.r0.c0.m0'], array_column($preview['pages'][0]['nodes'], 'id')); + self::assertCount(1, $preview['skipped']); + self::assertSame(101, $preview['skipped'][0]['post_id']); + self::assertStringStartsWith('s0.r0.c0.g0: ', $preview['skipped'][0]['reason']); + + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame([101], array_column($result['saved'], 'post_id')); + self::assertStringContainsString(self::INSTANCE, (string) $post->post_content); + self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $post->post_content); + } + + /** R8: library entries are usages of their own, saved with DiviWriter's scope. */ + public function testLibraryEntriesAreSavedWithTheirScope(): void + { + $rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]); + + $result = $rewriter->apply( + self::ABILITY, + self::query(), + self::assign(['divi/blurb', 'divi/text']), + null, + self::SUMMARY + ); + + self::assertSame([1057, 1059], array_column($result['saved'], 'post_id')); + self::assertSame([[1057, SaveScope::LibraryItem], [1059, SaveScope::GlobalItem]], $this->saved); + $this->log->assertRecorded( + ['object_type' => 'et_pb_layout', 'object_id' => '1057'], + ['object_type' => 'et_pb_layout', 'object_id' => '1059'] + ); + } + + /** Audit L2: a normal entry that pages show as instance is saved site-wide as well. */ + public function testReferencedLibraryEntryIsSavedSiteWide(): void + { + $rewriter = $this->rewriter([LibraryFixtures::item(1057)], null, [1057 => self::report(1)]); + + $rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/blurb']), null, self::SUMMARY); + + self::assertSame([[1057, SaveScope::GlobalItem]], $this->saved); + } + + /** P8a for the pages that show an entry: blocked → the entry is skipped, the others are saved. */ + public function testLibraryEntryShownOnBlockedPagesIsSkipped(): void + { + $rewriter = $this->rewriter( + [LibraryFixtures::item(1059), self::page(101, self::layout(self::TEXT))], + null, + [1059 => self::report(1, [7], 1)] + ); + + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/text']), null, self::SUMMARY); + + self::assertSame([[101, SaveScope::Page]], $this->saved); + self::assertSame([1059], array_column($result['skipped'], 'post_id')); + self::assertStringContainsString('Bibliothekseintrag 1059', $result['skipped'][0]['reason']); + self::assertStringContainsString('IDs 7', $result['skipped'][0]['reason']); + } + + /** No backup copies in a bulk write: a site-wide entry without revisions is skipped. */ + public function testSiteWideEntryWithoutRevisionsIsSkipped(): void + { + Functions\when('wp_revisions_enabled')->justReturn(false); + $rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]); + + $result = $rewriter->apply( + self::ABILITY, + self::query(), + self::assign(['divi/blurb', 'divi/text']), + null, + self::SUMMARY + ); + + self::assertSame([[1057, SaveScope::LibraryItem]], $this->saved); + self::assertSame([1059], array_column($result['skipped'], 'post_id')); + self::assertStringContainsString( + 'Revisionen sind für Bibliothekseintrag 1059 abgeschaltet', + $result['skipped'][0]['reason'] + ); + } + + public function testPostIdsLimitTheCandidatesAndNameUnchangedIds(): void + { + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::BUTTON)), + self::page(102, self::layout(self::BUTTON)), + ]); + + $preview = $rewriter->preview(self::query(), self::assign(), [102, 999]); + + self::assertSame([102], array_column($preview['pages'], 'post_id')); + self::assertSame([['post_id' => 999, 'reason' => PageRewriter::NO_CHANGE]], $preview['skipped']); + self::assertSame(0, $preview['hidden_count']); + } + + /** Audit L4: candidates the caller may not read are neither loaded nor named, only counted. */ + public function testUnreadableCandidatesAreOnlyCounted(): void + { + $rewriter = $this->rewriter( + [self::page(101, self::layout(self::BUTTON)), self::page(102, self::layout(self::BUTTON))], + self::report(2, [], 0, false, 1) + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'read_post' && ($args[0] ?? null) === 102) + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame([101], array_column($preview['pages'], 'post_id')); + self::assertSame([], $preview['skipped']); + self::assertSame(1, $preview['hidden_count']); + self::assertSame([[101, SaveScope::Page]], $this->saved); + self::assertSame([], $result['skipped']); + } +}