diff --git a/src/Integrations/Divi/Design/DetachTarget.php b/src/Integrations/Divi/Design/DetachTarget.php new file mode 100644 index 0000000..22c9b26 --- /dev/null +++ b/src/Integrations/Divi/Design/DetachTarget.php @@ -0,0 +1,55 @@ +|null $postIds Only these posts; null = every usage. + * @param Closure(): NodeRewrite $rewrite A fresh strategy per post. + * @param (Closure(PresetBook): PresetBook)|null $presets The preset step; null = none. + * @param string $nestedIn Where the preset step removes references (messages). + * @param list $notes Warnings of every answer. + * @param string $doneHint The next step once nothing references the target. + */ + public function __construct( + public readonly string $subject, + public readonly ?array $postIds, + public readonly UsageQuery $query, + public readonly Closure $rewrite, + public readonly ?Closure $presets, + public readonly string $nestedIn, + public readonly array $notes, + public readonly string $doneHint + ) { + } + + /** The change in messages and in the change log of every saved post. */ + public function change(): string + { + return 'Entkoppeln von ' . $this->subject; + } + + /** + * Whether nothing is left to detach: all usages, nothing skipped, and no usage this account may not read. + * + * @param list> $skipped + */ + public function finished(array $skipped, int $hidden): bool + { + return $this->postIds === null && $skipped === [] && $hidden === 0; + } +} diff --git a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php index ba7e2db..6d5aeeb 100644 --- a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php @@ -151,7 +151,7 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures self::VARIABLES . ' Bis dahin nur Presets angeben (preset).' ); } - $target = $this->target($input); + $target = $this->presetTarget($input); if ($target instanceof WP_Error) { return $target; } @@ -163,10 +163,8 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures * The preset to detach, resolved on the live inventory. * * @param array $input - * @return array{scope: PresetScope, id: string, name: string, default: bool, nested: bool, - * post_ids: list|null, query: PresetUsageQuery, rewrite: Closure(): NodeRewrite, change: string}|WP_Error */ - private function target(array $input): array|WP_Error + private function presetTarget(array $input): DetachTarget|WP_Error { $preset = $input['preset'] ?? null; if (! is_array($preset)) { @@ -204,76 +202,69 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures $id = (string) $found['id']; $label = is_string($found['name'] ?? null) ? $found['name'] : $id; + $nested = $postIds === null && $book->nestedUsers($id) !== []; - return [ - 'scope' => $scope, - 'id' => $id, - 'name' => $label, - 'default' => $book->defaultId($scope) === $id, - 'nested' => $postIds === null && $book->nestedUsers($id) !== [], - 'post_ids' => $postIds, + return new DetachTarget( + sprintf('%s „%s“', $scope->label(), $label), + $postIds, // Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay. - 'query' => new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]), - 'rewrite' => static fn (): NodeRewrite => new DetachRewrite($scope, $id), - 'change' => sprintf('Entkoppeln von %s „%s“', $scope->label(), $label), - ]; + new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]), + static fn (): NodeRewrite => new DetachRewrite($scope, $id), + $nested ? static fn (PresetBook $presets): PresetBook => $presets->withoutNestedRef($id) : null, + 'anderen Presets', + $book->defaultId($scope) === $id ? [sprintf(self::DEFAULT_NOTE, $label, $scope->name)] : [], + self::DELETE_HINT + ); } /** - * ONE confirm_required with the pages and, when other presets nest the preset, the preset step's diff and + * ONE confirm_required with the pages and, when presets nest the target, the preset step's diff and * design_rev; a normal result when nothing references it any more. * - * @param array $target * @return array|WP_Error */ - private function dryRun(array $target): array|WP_Error + private function dryRun(DetachTarget $target): array|WP_Error { - $preview = $this->pages->preview($target['query'], $target['rewrite'], $target['post_ids']); + $preview = $this->pages->preview($target->query, $target->rewrite, $target->postIds); if ($preview instanceof WP_Error) { return $preview; } - $presets = $target['nested'] ? $this->presetDryRun($target) : []; + $presets = $target->presets !== null ? $this->presetDryRun($target) : []; if ($presets instanceof WP_Error) { return $presets; } - $notes = self::notes($target); if ($presets === [] && PageRewriteReport::isEmpty($preview)) { - $summary = sprintf( - 'Keine Verweise auf %s „%s“ gefunden – nichts zu tun.', - $target['scope']->label(), - $target['name'] - ); - - $done = self::finished($target, $preview['skipped'], $preview['hidden_count']); + $summary = sprintf('Keine Verweise auf %s gefunden – nichts zu tun.', $target->subject); + $done = $target->finished($preview['skipped'], $preview['hidden_count']); return PageRewriteReport::nothingToDo( - $summary . ($done ? ' ' . self::DELETE_HINT : ''), + $summary . ($done ? ' ' . $target->doneHint : ''), $preview, - $notes + $target->notes ); } - $message = PageRewriteReport::dryRunMessage($preview, $target['change']); + $message = PageRewriteReport::dryRunMessage($preview, $target->change()); if ($presets !== []) { $message .= sprintf( - ' Zuerst entfernt der Aufruf die Verweise in %d anderen Presets (diff, mit Snapshot); dafür die ' - . 'design_rev mit confirm: true mitsenden.', - count($presets['diff']) + ' Zuerst entfernt der Aufruf die Verweise in %d %s (diff, mit Snapshot); dafür die design_rev mit ' + . 'confirm: true mitsenden.', + count($presets['diff']), + $target->nestedIn ); } - return Errors::confirmRequired($message, $preview + $presets + ['warnings' => $notes]); + return Errors::confirmRequired($message, $preview + $presets + ['warnings' => $target->notes]); } /** * The preset step's dry run through DesignChange::run() without confirm: its confirm_required carries diff and - * design_rev; a result without error means nothing nests the preset any more. + * design_rev; a result without error means no preset nests the target any more. * - * @param array $target * @return array{diff: list>, design_rev: string}|array{}|WP_Error */ - private function presetDryRun(array $target): array|WP_Error + private function presetDryRun(DetachTarget $target): array|WP_Error { $result = $this->change->run($this->name(), new PresetArea($this->writer), [], $this->nestedPlan($target)); if (! $result instanceof WP_Error) { @@ -287,15 +278,12 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures return ['diff' => $data['diff'] ?? [], 'design_rev' => (string) ($data['design_rev'] ?? '')]; } - /** - * @param array $target - * @return Closure(array): (DesignPlan|WP_Error) - */ - private function nestedPlan(array $target): Closure + /** @return Closure(array): (DesignPlan|WP_Error) */ + private function nestedPlan(DetachTarget $target): Closure { return function (array $raw) use ($target): DesignPlan|WP_Error { $before = PresetBook::fromDivi($raw); - $after = $before->withoutNestedRef($target['id']); + $after = $target->presets === null ? $before : ($target->presets)($before); $diff = PresetDiff::between($before, $after); if ($diff === []) { return new DesignPlan($after->data(), [], PresetDiff::summary([])); @@ -311,7 +299,7 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures $after->data(), $diff, PresetDiff::summary($diff), - $this->usages->reportFor($target['query']) + $this->usages->reportFor($target->query) ); }; } @@ -320,15 +308,14 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures * Preset step first (stops on any error – nothing written), then at most PAGE_LIMIT pages. * * @param array $input - * @param array $target * @return array|WP_Error * @throws WriteFailed When the preset step fails, or the first page fails without a preset step. * @throws DiviLocked Likewise. */ - private function confirmed(array $input, array $target): array|WP_Error + private function confirmed(array $input, DetachTarget $target): array|WP_Error { $presets = null; - if ($target['nested']) { + if ($target->presets !== null) { $presets = $this->change->run( $this->name(), new PresetArea($this->writer), @@ -344,10 +331,10 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures try { $pages = $this->pages->apply( $this->name(), - $target['query'], - $target['rewrite'], - $target['post_ids'], - $target['change'] + $target->query, + $target->rewrite, + $target->postIds, + $target->change() ); } catch (WriteFailed | DiviLocked $e) { // Thrown when the first page fails: the registrar would answer "nothing was saved" – untrue after the @@ -357,25 +344,24 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures } Log::failure($this->name(), $e); - return self::afterPresets(self::firstPageError($e), $presets); + return self::afterPresets(self::firstPageError($e), $presets, $target); } if ($pages instanceof WP_Error) { - return $wrotePresets ? self::afterPresets($pages, $presets) : $pages; + return $wrotePresets ? self::afterPresets($pages, $presets, $target) : $pages; } - $summary = PageRewriteReport::savedSummary($pages, $target['change']); + $summary = PageRewriteReport::savedSummary($pages, $target->change()); if ($wrotePresets) { $summary = $presets['summary'] . ' ' . $summary; } - $done = self::finished($target, $pages['skipped'], $pages['hidden_count']); - if ($pages['remaining'] === 0 && $done) { - $summary .= ' ' . self::DELETE_HINT; + if ($pages['remaining'] === 0 && $target->finished($pages['skipped'], $pages['hidden_count'])) { + $summary .= ' ' . $target->doneHint; } return ToolResult::ok($summary) ->snapshotId($presets['snapshot_id'] ?? null) ->logId($presets['log_id'] ?? null) - ->warnings(array_merge($presets['warnings'] ?? [], $pages['warnings'], self::notes($target))) + ->warnings(array_merge($presets['warnings'] ?? [], $pages['warnings'], $target->notes)) ->with('saved', $pages['saved']) ->with('remaining', $pages['remaining']) ->with('skipped', $pages['skipped']) @@ -407,14 +393,15 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures * * @param array $presets The preset step's ToolResult. */ - private static function afterPresets(WP_Error $error, array $presets): WP_Error + private static function afterPresets(WP_Error $error, array $presets, DetachTarget $target): WP_Error { $data = (array) $error->get_error_data(); return Errors::make( Errors::codeOf($error), Errors::clientMessage($error) . sprintf( - ' Die Verweise in anderen Presets sind bereits entfernt (Snapshot %s).', + ' Die Verweise in %s sind bereits entfernt (Snapshot %s).', + $target->nestedIn, (string) $presets['snapshot_id'] ), (string) ($data['hint'] ?? ''), @@ -424,27 +411,6 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures ); } - /** - * 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, int $hidden): bool - { - return $target['post_ids'] === null && $skipped === [] && $hidden === 0; - } - - /** - * @param array $target - * @return list - */ - private static function notes(array $target): array - { - return $target['default'] ? [sprintf(self::DEFAULT_NOTE, $target['name'], $target['scope']->name)] : []; - } - /** @param array $errors */ private static function invalid(array $errors): WP_Error { diff --git a/tests/Unit/Integrations/Divi/Design/DetachTargetTest.php b/tests/Unit/Integrations/Divi/Design/DetachTargetTest.php new file mode 100644 index 0000000..327ad02 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DetachTargetTest.php @@ -0,0 +1,45 @@ +|null $postIds */ + private static function target(?array $postIds): DetachTarget + { + $scope = new PresetScope('module', 'divi/button'); + + return new DetachTarget( + 'Modul-Preset divi/button „CTA primär“', + $postIds, + new PresetUsageQuery([['scope' => $scope, 'id' => 'aaaaaaaaaa', 'default' => false]]), + static fn (): NodeRewrite => new DetachRewrite($scope, 'aaaaaaaaaa'), + null, + 'anderen Presets', + [], + 'Jetzt kann lit/divi-presets-delete das Preset löschen.' + ); + } + + public function testTheChangeNamesTheSubject(): void + { + self::assertSame('Entkoppeln von Modul-Preset divi/button „CTA primär“', self::target(null)->change()); + } + + public function testFinishedOnlyWithoutPostIdsSkippedOrHiddenUsages(): void + { + self::assertTrue(self::target(null)->finished([], 0)); + self::assertFalse(self::target([12])->finished([], 0), 'post_ids leave other usages'); + self::assertFalse(self::target(null)->finished([['post_id' => 12, 'reason' => 'x']], 0)); + self::assertFalse(self::target(null)->finished([], 3)); + } +}