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).'
);
}
$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<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;
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<string, mixed> $target
* @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) {
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<string, mixed> $target
* @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));
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<string, mixed> $target
* @return Closure(array<array-key, mixed>): (DesignPlan|WP_Error)
*/
private function nestedPlan(array $target): Closure
/** @return Closure(array<array-key, mixed>): (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<string, mixed> $input
* @param array<string, mixed> $target
* @return array<string, mixed>|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<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();
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<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 */
private static function invalid(array $errors): WP_Error
{