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:
co-authored by
Claude Sonnet 5.5
parent
fef964bb7a
commit
116d866c61
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user