refactor: DetachTarget ersetzt das Array in DiviDesignDetachAbility::target(); Verhalten unverändert

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:13:00 +02:00
co-authored by Claude Sonnet 5.5
parent fef964bb7a
commit 116d866c61
3 changed files with 148 additions and 82 deletions
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
defined('ABSPATH') || exit;
/**
* What lit/divi-design-detach removes (spec §6.8, rulings R10, V9), resolved on the live inventory: the usage query
* and a fresh NodeRewrite per post for the PageRewriter and – when presets nest the target and no post_ids narrow the
* call – the preset step, i.e. how the preset inventory changes (DesignChange, snapshot "presets"). Pure PHP.
*/
final class DetachTarget
{
/**
* @param string $subject The target in messages: 'Variable „Eckenradius“'.
* @param list<int>|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<string> $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<array<string, mixed>> $skipped
*/
public function finished(array $skipped, int $hidden): bool
{
return $this->postIds === null && $skipped === [] && $hidden === 0;
}
}
@@ -151,7 +151,7 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
self::VARIABLES . ' Bis dahin nur Presets angeben (preset).' self::VARIABLES . ' Bis dahin nur Presets angeben (preset).'
); );
} }
$target = $this->target($input); $target = $this->presetTarget($input);
if ($target instanceof WP_Error) { if ($target instanceof WP_Error) {
return $target; return $target;
} }
@@ -163,10 +163,8 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
* The preset to detach, resolved on the live inventory. * The preset to detach, resolved on the live inventory.
* *
* @param array<string, mixed> $input * @param array<string, mixed> $input
* @return array{scope: PresetScope, id: string, name: string, default: bool, nested: bool,
* post_ids: list<int>|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; $preset = $input['preset'] ?? null;
if (! is_array($preset)) { if (! is_array($preset)) {
@@ -204,76 +202,69 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
$id = (string) $found['id']; $id = (string) $found['id'];
$label = is_string($found['name'] ?? null) ? $found['name'] : $id; $label = is_string($found['name'] ?? null) ? $found['name'] : $id;
$nested = $postIds === null && $book->nestedUsers($id) !== [];
return [ return new DetachTarget(
'scope' => $scope, sprintf('%s „%s“', $scope->label(), $label),
'id' => $id, $postIds,
'name' => $label,
'default' => $book->defaultId($scope) === $id,
'nested' => $postIds === null && $book->nestedUsers($id) !== [],
'post_ids' => $postIds,
// Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay. // Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay.
'query' => new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]), new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]),
'rewrite' => static fn (): NodeRewrite => new DetachRewrite($scope, $id), static fn (): NodeRewrite => new DetachRewrite($scope, $id),
'change' => sprintf('Entkoppeln von %s „%s“', $scope->label(), $label), $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. * design_rev; a normal result when nothing references it any more.
* *
* @param array<string, mixed> $target
* @return array<string, mixed>|WP_Error * @return array<string, mixed>|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) { if ($preview instanceof WP_Error) {
return $preview; return $preview;
} }
$presets = $target['nested'] ? $this->presetDryRun($target) : []; $presets = $target->presets !== null ? $this->presetDryRun($target) : [];
if ($presets instanceof WP_Error) { if ($presets instanceof WP_Error) {
return $presets; return $presets;
} }
$notes = self::notes($target);
if ($presets === [] && PageRewriteReport::isEmpty($preview)) { if ($presets === [] && PageRewriteReport::isEmpty($preview)) {
$summary = sprintf( $summary = sprintf('Keine Verweise auf %s gefunden – nichts zu tun.', $target->subject);
'Keine Verweise auf %s „%s“ gefunden – nichts zu tun.', $done = $target->finished($preview['skipped'], $preview['hidden_count']);
$target['scope']->label(),
$target['name']
);
$done = self::finished($target, $preview['skipped'], $preview['hidden_count']);
return PageRewriteReport::nothingToDo( return PageRewriteReport::nothingToDo(
$summary . ($done ? ' ' . self::DELETE_HINT : ''), $summary . ($done ? ' ' . $target->doneHint : ''),
$preview, $preview,
$notes $target->notes
); );
} }
$message = PageRewriteReport::dryRunMessage($preview, $target['change']); $message = PageRewriteReport::dryRunMessage($preview, $target->change());
if ($presets !== []) { if ($presets !== []) {
$message .= sprintf( $message .= sprintf(
' Zuerst entfernt der Aufruf die Verweise in %d anderen Presets (diff, mit Snapshot); dafür die ' ' Zuerst entfernt der Aufruf die Verweise in %d %s (diff, mit Snapshot); dafür die design_rev mit '
. 'design_rev mit confirm: true mitsenden.', . 'confirm: true mitsenden.',
count($presets['diff']) 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 * 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<string, mixed> $target
* @return array{diff: list<array<string, mixed>>, design_rev: string}|array{}|WP_Error * @return array{diff: list<array<string, mixed>>, 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)); $result = $this->change->run($this->name(), new PresetArea($this->writer), [], $this->nestedPlan($target));
if (! $result instanceof WP_Error) { 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'] ?? '')]; return ['diff' => $data['diff'] ?? [], 'design_rev' => (string) ($data['design_rev'] ?? '')];
} }
/** /** @return Closure(array<array-key, mixed>): (DesignPlan|WP_Error) */
* @param array<string, mixed> $target private function nestedPlan(DetachTarget $target): Closure
* @return Closure(array<array-key, mixed>): (DesignPlan|WP_Error)
*/
private function nestedPlan(array $target): Closure
{ {
return function (array $raw) use ($target): DesignPlan|WP_Error { return function (array $raw) use ($target): DesignPlan|WP_Error {
$before = PresetBook::fromDivi($raw); $before = PresetBook::fromDivi($raw);
$after = $before->withoutNestedRef($target['id']); $after = $target->presets === null ? $before : ($target->presets)($before);
$diff = PresetDiff::between($before, $after); $diff = PresetDiff::between($before, $after);
if ($diff === []) { if ($diff === []) {
return new DesignPlan($after->data(), [], PresetDiff::summary([])); return new DesignPlan($after->data(), [], PresetDiff::summary([]));
@@ -311,7 +299,7 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
$after->data(), $after->data(),
$diff, $diff,
PresetDiff::summary($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. * Preset step first (stops on any error – nothing written), then at most PAGE_LIMIT pages.
* *
* @param array<string, mixed> $input * @param array<string, mixed> $input
* @param array<string, mixed> $target
* @return array<string, mixed>|WP_Error * @return array<string, mixed>|WP_Error
* @throws WriteFailed When the preset step fails, or the first page fails without a preset step. * @throws WriteFailed When the preset step fails, or the first page fails without a preset step.
* @throws DiviLocked Likewise. * @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; $presets = null;
if ($target['nested']) { if ($target->presets !== null) {
$presets = $this->change->run( $presets = $this->change->run(
$this->name(), $this->name(),
new PresetArea($this->writer), new PresetArea($this->writer),
@@ -344,10 +331,10 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
try { try {
$pages = $this->pages->apply( $pages = $this->pages->apply(
$this->name(), $this->name(),
$target['query'], $target->query,
$target['rewrite'], $target->rewrite,
$target['post_ids'], $target->postIds,
$target['change'] $target->change()
); );
} catch (WriteFailed | DiviLocked $e) { } catch (WriteFailed | DiviLocked $e) {
// Thrown when the first page fails: the registrar would answer "nothing was saved" – untrue after the // 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); 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) { 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) { if ($wrotePresets) {
$summary = $presets['summary'] . ' ' . $summary; $summary = $presets['summary'] . ' ' . $summary;
} }
$done = self::finished($target, $pages['skipped'], $pages['hidden_count']); if ($pages['remaining'] === 0 && $target->finished($pages['skipped'], $pages['hidden_count'])) {
if ($pages['remaining'] === 0 && $done) { $summary .= ' ' . $target->doneHint;
$summary .= ' ' . self::DELETE_HINT;
} }
return ToolResult::ok($summary) return ToolResult::ok($summary)
->snapshotId($presets['snapshot_id'] ?? null) ->snapshotId($presets['snapshot_id'] ?? null)
->logId($presets['log_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('saved', $pages['saved'])
->with('remaining', $pages['remaining']) ->with('remaining', $pages['remaining'])
->with('skipped', $pages['skipped']) ->with('skipped', $pages['skipped'])
@@ -407,14 +393,15 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
* *
* @param array<string, mixed> $presets The preset step's ToolResult. * @param array<string, mixed> $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(); $data = (array) $error->get_error_data();
return Errors::make( return Errors::make(
Errors::codeOf($error), Errors::codeOf($error),
Errors::clientMessage($error) . sprintf( 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) $presets['snapshot_id']
), ),
(string) ($data['hint'] ?? ''), (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<string, mixed> $target
* @param list<array<string, mixed>> $skipped
*/
private static function finished(array $target, array $skipped, int $hidden): bool
{
return $target['post_ids'] === null && $skipped === [] && $hidden === 0;
}
/**
* @param array<string, mixed> $target
* @return list<string>
*/
private static function notes(array $target): array
{
return $target['default'] ? [sprintf(self::DEFAULT_NOTE, $target['name'], $target['scope']->name)] : [];
}
/** @param array<string, string> $errors */ /** @param array<string, string> $errors */
private static function invalid(array $errors): WP_Error private static function invalid(array $errors): WP_Error
{ {
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
use LIT\MCP\Integrations\Divi\Design\DetachTarget;
use LIT\MCP\Integrations\Divi\Design\NodeRewrite;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use PHPUnit\Framework\TestCase;
final class DetachTargetTest extends TestCase
{
/** @param list<int>|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));
}
}