diff --git a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php index 99a6b6c..2465cd6 100644 --- a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php @@ -223,8 +223,10 @@ final class DiviDesignDetachAbility implements Ability $target['name'] ); + $done = self::finished($target, $preview['skipped'], $preview['hidden_count']); + return PageRewriteReport::nothingToDo( - $summary . (self::finished($target, $preview['skipped']) ? ' ' . self::DELETE_HINT : ''), + $summary . ($done ? ' ' . self::DELETE_HINT : ''), $preview, $notes ); @@ -343,7 +345,8 @@ final class DiviDesignDetachAbility implements Ability if ($wrotePresets) { $summary = $presets['summary'] . ' ' . $summary; } - if ($pages['remaining'] === 0 && self::finished($target, $pages['skipped'])) { + $done = self::finished($target, $pages['skipped'], $pages['hidden_count']); + if ($pages['remaining'] === 0 && $done) { $summary .= ' ' . self::DELETE_HINT; } @@ -354,6 +357,7 @@ final class DiviDesignDetachAbility implements Ability ->with('saved', $pages['saved']) ->with('remaining', $pages['remaining']) ->with('skipped', $pages['skipped']) + ->with('hidden_count', $pages['hidden_count']) ->with('diff', $presets['diff'] ?? []) ->with('design_rev', $presets['design_rev'] ?? null) ->toArray(); @@ -399,14 +403,15 @@ final class DiviDesignDetachAbility implements Ability } /** - * Whether nothing is left to detach before lit/divi-presets-delete: all usages, nothing skipped. + * Whether nothing is left to detach before lit/divi-presets-delete: all usages, nothing skipped, and no usage + * this account may not read (delete would still answer in_use). * * @param array $target * @param list> $skipped */ - private static function finished(array $target, array $skipped): bool + private static function finished(array $target, array $skipped, int $hidden): bool { - return $target['post_ids'] === null && $skipped === []; + return $target['post_ids'] === null && $skipped === [] && $hidden === 0; } /** diff --git a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php index 61e6e82..859ff89 100644 --- a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php @@ -147,6 +147,7 @@ final class DiviPresetsAssignAbility implements Ability ->with('saved', $result['saved']) ->with('remaining', $result['remaining']) ->with('skipped', $result['skipped']) + ->with('hidden_count', $result['hidden_count']) ->toArray(); } diff --git a/src/Integrations/Divi/Design/PageRewriteReport.php b/src/Integrations/Divi/Design/PageRewriteReport.php index c302370..dbe7f52 100644 --- a/src/Integrations/Divi/Design/PageRewriteReport.php +++ b/src/Integrations/Divi/Design/PageRewriteReport.php @@ -58,10 +58,22 @@ final class PageRewriteReport if ($result['skipped'] !== []) { return $summary . sprintf(' Übersprungen: %d (siehe skipped).', count($result['skipped'])); } + if (($result['hidden_count'] ?? 0) > 0) { + return $summary . ' ' . self::hiddenNote($result['hidden_count']); + } return $summary . ' Alle Fundstellen sind erledigt.'; } + /** The caveat when posts this account may not read could still carry references. */ + public static function hiddenNote(int $hidden): string + { + return sprintf( + 'Hinweis: %d für diesen Zugang nicht sichtbare Fundstellen können noch Verweise enthalten.', + $hidden + ); + } + /** * @param array $preview * @param list $warnings @@ -97,6 +109,10 @@ final class PageRewriteReport ], ], 'remaining' => ['type' => 'integer'], + 'hidden_count' => [ + 'type' => 'integer', + 'description' => 'Fundstellen, die dieses Konto nicht lesen darf (nicht geändert).', + ], 'skipped' => [ 'type' => 'array', 'items' => [ diff --git a/src/Integrations/Divi/Design/PageRewriter.php b/src/Integrations/Divi/Design/PageRewriter.php index 1f66425..ace195e 100644 --- a/src/Integrations/Divi/Design/PageRewriter.php +++ b/src/Integrations/Divi/Design/PageRewriter.php @@ -123,8 +123,9 @@ class PageRewriter * @param Closure(): NodeRewrite $rewrite A fresh strategy per post. * @param list|null $postIds Only these posts; null = every usage. * @return array{saved: list, remaining: int, - * warnings: list, skipped: list}|WP_Error - * unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved) + * warnings: list, skipped: list, hidden_count: int}|WP_Error + * hidden_count = usages this account may not read (never rewritten, as in preview(); 0 with post_ids); + * errors: 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. */ @@ -136,7 +137,8 @@ class PageRewriter string $summary ): array|WP_Error { $ids = self::ids($postIds); - $refusal = $this->refusal($this->usages->reportFor($query), $ids); + $report = $this->usages->reportFor($query); + $refusal = $this->refusal($report, $ids); if ($refusal !== null) { return $refusal; } @@ -158,7 +160,13 @@ class PageRewriter } } - return ['saved' => $saved, 'remaining' => $remaining, 'warnings' => $warnings, 'skipped' => $skipped]; + return [ + 'saved' => $saved, + 'remaining' => $remaining, + 'warnings' => $warnings, + 'skipped' => $skipped, + 'hidden_count' => $ids === null ? $report->hidden : 0, + ]; } /** diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php index f7a2e20..196d07e 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php @@ -303,6 +303,36 @@ final class DiviDesignDetachAbilityTest extends TestCase ); } + public function testUnreadablePostsKeepTheDeleteHintBack(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0, [], 3)); + + $result = $this->ability($pages)->execute([ + 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], + 'confirm' => true, + ]); + + self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']); + self::assertStringNotContainsString('erledigt', $result['summary']); + self::assertStringContainsString('3 für diesen Zugang nicht sichtbare Fundstellen', $result['summary']); + self::assertSame(3, $result['hidden_count']); + } + + public function testNoUnreadablePostsKeepTheDeleteHint(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0)); + + $result = $this->ability($pages)->execute([ + 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], + 'confirm' => true, + ]); + + self::assertStringContainsString('lit/divi-presets-delete', $result['summary']); + self::assertStringNotContainsString('nicht sichtbare', $result['summary']); + } + public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void { $pages = Mockery::mock(PageRewriter::class); diff --git a/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php b/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php index 4cc6ebe..67d12f2 100644 --- a/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php +++ b/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php @@ -45,14 +45,20 @@ final class PageRewriteReportTest extends TestCase * @param list $skipped * @return array */ - public static function applied(int $saved, int $remaining, array $skipped = []): array + public static function applied(int $saved, int $remaining, array $skipped = [], int $hidden = 0): array { $rows = []; for ($i = 0; $i < $saved; ++$i) { $rows[] = ['post_id' => 12 + $i, 'revision_id' => 900 + $i, 'nodes' => 2]; } - return ['saved' => $rows, 'remaining' => $remaining, 'warnings' => [], 'skipped' => $skipped]; + return [ + 'saved' => $rows, + 'remaining' => $remaining, + 'warnings' => [], + 'skipped' => $skipped, + 'hidden_count' => $hidden, + ]; } public function testEmptyMeansNothingToWriteNothingBlockedNothingHidden(): void diff --git a/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php b/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php index 97532f6..f247ce8 100644 --- a/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php +++ b/tests/Unit/Integrations/Divi/Design/PageRewriterTest.php @@ -618,5 +618,60 @@ final class PageRewriterTest extends TestCase self::assertSame(1, $preview['hidden_count']); self::assertSame([[101, SaveScope::Page]], $this->saved); self::assertSame([], $result['skipped']); + self::assertSame(1, $result['hidden_count'], 'detach must not say "all done" with unreadable usages'); + } + + /** Unreadable live usages the caller may not edit refuse the call (forbidden) as a count; preview lists them. */ + public function testUnreadableBlockedLivePagesRefuseTheCallAsACount(): void + { + $report = new UsageReport(2, [], 1, 2, [102], 2, false); + $rewriter = $this->rewriter( + [ + self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']), + self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']), + ], + $report + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + self::assertSame([102], $preview['blocked_ids']); + self::assertSame(2, $preview['blocked_hidden_count']); + + $error = self::assertToolError( + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY), + 'forbidden' + ); + self::assertSame([102], $error['blocked_ids']); + self::assertSame(2, $error['blocked_hidden_count']); + self::assertStringContainsString('2 Einträge, die dieses Konto nicht lesen darf', $error['message']); + self::assertSame([], $this->saved); + self::assertSame([], $this->log->records); + } + + /** + * Above HARD_CAP live usages the per-row edit check does not run (blocked stays empty): post_ids are not refused, + * and a named live page the caller may not edit is skipped by loadWritable() instead of forbidden. + */ + public function testPostIdsAboveTheHardCapAreNotRefusedAndAnUneditablePageIsSkipped(): 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(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true) + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? null) === 101) + ); + + self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported'); + $preview = $rewriter->preview(self::query(), self::assign(), [101, 102]); + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101, 102], self::SUMMARY); + + self::assertSame([], $preview['blocked_ids']); + self::assertSame([102], array_column($result['saved'], 'post_id')); + self::assertSame([101], array_column($result['skipped'], 'post_id')); + self::assertSame([[102, SaveScope::Page]], $this->saved); } }