feat: lit/divi-presets-delete – unbenutzte Presets löschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
25720ad086
commit
2e8f723b01
@@ -80,13 +80,14 @@ final class Errors
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $usages
|
||||
* @param string|null $hint Replaces the default hint (force: true, for lit/media-delete).
|
||||
*/
|
||||
public static function inUse(string $message, array $usages): WP_Error
|
||||
public static function inUse(string $message, array $usages, ?string $hint = null): WP_Error
|
||||
{
|
||||
return self::make(
|
||||
'in_use',
|
||||
$message,
|
||||
'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
|
||||
$hint ?? 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
|
||||
409,
|
||||
['usages' => $usages]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/divi-presets-delete (spec §6.4, G5): deletes presets nothing uses any more – no page, library entry or Theme
|
||||
* Builder layout (GlobalUsages with PresetUsageQuery), and no other preset that nests it. A used preset is refused
|
||||
* with its usages (in_use); lit/divi-design-detach (Etappe 4b) removes the references first. A default preset loses
|
||||
* its default. Through the change pipeline with Divi's delete semantics (legacy option cleaned).
|
||||
*/
|
||||
final class DiviPresetsDeleteAbility implements Ability
|
||||
{
|
||||
public const MAX_ITEMS = 20;
|
||||
|
||||
private const DETACH_HINT = 'Erst die Verweise entfernen: lit/divi-design-detach (ab Etappe 4b) oder im '
|
||||
. 'Divi-Builder; danach erneut löschen.';
|
||||
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private DesignChange $change,
|
||||
private GlobalUsages $usages
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-presets-delete';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Divi-Presets löschen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Löscht Modul- oder Gruppen-Presets (items: module bzw. group + preset als Name oder ID), die nirgends '
|
||||
. 'mehr verwendet werden; sonst Fehler in_use mit den Fundstellen. Zuerst Probelauf, nach Rückfrage beim '
|
||||
. 'Nutzer confirm: true mit design_rev; vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'maxItems' => self::MAX_ITEMS,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'module' => ['type' => 'string'],
|
||||
'group' => ['type' => 'string'],
|
||||
'preset' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['preset'],
|
||||
],
|
||||
],
|
||||
] + DesignChange::inputProperties(),
|
||||
'required' => ['items'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return DesignChange::outputSchema();
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::destructive(true);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return DesignRights::presets($this->writer) ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = DesignRights::presets($this->writer);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
$items = $input['items'] ?? null;
|
||||
if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) {
|
||||
return Errors::invalidInput(
|
||||
'items fehlt oder ist zu lang; nichts wurde gelöscht.',
|
||||
['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->change->run(
|
||||
$this->name(),
|
||||
new PresetArea($this->writer),
|
||||
$input,
|
||||
fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<mixed> $items */
|
||||
private function plan(PresetBook $before, array $items): DesignPlan|WP_Error
|
||||
{
|
||||
$book = $before;
|
||||
$errors = [];
|
||||
$targets = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$field = sprintf('items[%d]', $index);
|
||||
try {
|
||||
$scope = PresetScope::fromInput(is_array($item) ? $item : [], $field);
|
||||
$found = $before->find($scope, is_string($item['preset'] ?? null) ? $item['preset'] : '');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$field] = $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
if ($found === null) {
|
||||
$errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), (string) $item['preset']);
|
||||
continue;
|
||||
}
|
||||
$id = (string) $found['id'];
|
||||
$targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id];
|
||||
$book = $book->without($scope, $id);
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Ungültige Angaben; nichts wurde gelöscht.', $errors);
|
||||
}
|
||||
|
||||
$deleted = array_column($targets, 'id');
|
||||
$nested = [];
|
||||
foreach ($targets as $target) {
|
||||
foreach ($before->nestedUsers($target['id']) as $user) {
|
||||
if (! in_array($user['id'], $deleted, true)) {
|
||||
$nested[] = ['type' => 'preset', 'preset_id' => $user['id'], 'name' => $user['name'],
|
||||
'scope' => $user['scope']->name];
|
||||
}
|
||||
}
|
||||
}
|
||||
$report = $this->usages->reportFor(new PresetUsageQuery($targets), false);
|
||||
if ($nested !== [] || $report->exists()) {
|
||||
return Errors::inUse(
|
||||
'Die Presets werden noch verwendet; nichts wurde gelöscht.',
|
||||
array_merge($nested, $report->listing()['affected']),
|
||||
self::DETACH_HINT
|
||||
);
|
||||
}
|
||||
|
||||
$diff = PresetDiff::between($before, $book);
|
||||
|
||||
return new DesignPlan($book->data(), $diff, PresetDiff::summary($diff), null, [], true);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
@@ -166,6 +167,7 @@ final class DiviIntegration implements Integration
|
||||
$loader,
|
||||
$this->presetComposer(...)
|
||||
),
|
||||
new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -134,4 +134,13 @@ final class ErrorsTest extends TestCase
|
||||
self::assertSame([], Errors::fieldsOf(Errors::invalidInput('x')));
|
||||
self::assertSame([], Errors::fieldsOf(new \WP_Error('db', 'x', 'kein Array')));
|
||||
}
|
||||
|
||||
public function testInUseTakesItsOwnHint(): void
|
||||
{
|
||||
$error = Errors::inUse('Wird verwendet.', [['post_id' => 3]], 'Erst die Verweise entfernen.');
|
||||
|
||||
$payload = self::assertToolError($error, 'in_use');
|
||||
self::assertSame('Erst die Verweise entfernen.', $payload['hint']);
|
||||
self::assertStringContainsString('force: true', Errors::inUse('x', [])->get_error_data()['hint']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviPresetsDeleteAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(UsageReport $report, ?DesignSystemWriter $writer = null, ?SnapshotStore $snapshots = null): DiviPresetsDeleteAbility
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
if ($writer === null) {
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||||
}
|
||||
$usages = Mockery::mock(GlobalUsages::class);
|
||||
$usages->allows('reportFor')->andReturn($report);
|
||||
|
||||
return new DiviPresetsDeleteAbility(
|
||||
$writer,
|
||||
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)),
|
||||
$usages
|
||||
);
|
||||
}
|
||||
|
||||
private static function unused(): UsageReport
|
||||
{
|
||||
return new UsageReport(0, [], 0, 0, [], 0, false);
|
||||
}
|
||||
|
||||
public function testUnusedPresetIsDeletedAfterConfirm(): void
|
||||
{
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||||
$writer->expects('savePresets')
|
||||
->with(Mockery::on(static function (array $rest): bool {
|
||||
$ids = array_column($rest['module'][0]['items'], 'id');
|
||||
|
||||
return $ids === ['aaaaaaaaaa', 'cccccccccc'];
|
||||
}), true)
|
||||
->andReturnUsing(static function (): array {
|
||||
$stored = PresetBookTest::stored();
|
||||
unset($stored['module']['divi/button']['items']['bbbbbbbbbb']);
|
||||
|
||||
return $stored;
|
||||
});
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->andReturn('presets-9');
|
||||
$snapshots->expects('attachLog');
|
||||
$ability = $this->ability(self::unused(), $writer, $snapshots);
|
||||
$input = ['items' => [['module' => 'button', 'preset' => 'bbbbbbbbbb']]];
|
||||
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
self::assertSame('removed', $dry['diff'][0]['action']);
|
||||
|
||||
$result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
self::assertSame('presets-9', $result['snapshot_id']);
|
||||
}
|
||||
|
||||
public function testPresetInUseIsRefused(): void
|
||||
{
|
||||
$used = new UsageReport(2, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
|
||||
1, 1, [], 0, false);
|
||||
|
||||
$error = self::assertToolError(
|
||||
$this->ability($used)->execute(['items' => [['module' => 'button', 'preset' => 'CTA primär']]]),
|
||||
'in_use'
|
||||
);
|
||||
|
||||
self::assertSame(3, $error['usages'][0]['post_id']);
|
||||
self::assertStringContainsString('lit/divi-design-detach', $error['hint']);
|
||||
}
|
||||
|
||||
public function testNestedUseInAnotherPresetCounts(): void
|
||||
{
|
||||
$error = self::assertToolError(
|
||||
$this->ability(self::unused())->execute(['items' => [['group' => 'font-body', 'preset' => 'Lesetext']]]),
|
||||
'in_use'
|
||||
);
|
||||
|
||||
self::assertSame('preset', $error['usages'][0]['type']);
|
||||
self::assertSame('dddddddddd', $error['usages'][0]['preset_id']);
|
||||
}
|
||||
|
||||
public function testDeletingTheDefaultClearsIt(): void
|
||||
{
|
||||
$dry = self::assertToolError(
|
||||
$this->ability(self::unused())->execute(['items' => [['module' => 'text', 'preset' => 'Fließtext']]]),
|
||||
'confirm_required'
|
||||
);
|
||||
|
||||
self::assertSame(['removed', 'default_cleared'], array_column($dry['diff'], 'action'));
|
||||
}
|
||||
|
||||
public function testUnknownPreset(): void
|
||||
{
|
||||
self::assertToolError(
|
||||
$this->ability(self::unused())->execute(['items' => [['module' => 'button', 'preset' => 'Fehlt']]]),
|
||||
'validation_failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ final class DiviIntegrationTest extends TestCase
|
||||
'lit/divi-dynamic-sources',
|
||||
'lit/divi-preset-get',
|
||||
'lit/divi-presets-set',
|
||||
'lit/divi-presets-delete',
|
||||
];
|
||||
|
||||
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
|
||||
|
||||
Reference in New Issue
Block a user