diff --git a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php index cde4ce7..61e6e82 100644 --- a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php @@ -25,12 +25,11 @@ defined('ABSPATH') || exit; */ final class DiviPresetsAssignAbility implements Ability { - public const MAX_STACK = 10; - /** Path prefix for PresetAssignment's errors; removed again so fields name the input keys. */ private const ROOT = 'input'; - private const USE_DETACH = 'null entfernt site-weit nichts; Preset-Verweise entfernt lit/divi-design-detach'; + private const USE_DETACH = 'null und leerer Text entfernen site-weit nichts; ' + . 'Preset-Verweise entfernt lit/divi-design-detach'; /** @param Closure(): PresetAssignment $assignment A fresh assignment (own preset book) per call. */ public function __construct( @@ -70,18 +69,16 @@ final class DiviPresetsAssignAbility implements Ability 'type' => 'object', 'properties' => [ 'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'], - 'preset' => [ - 'type' => ['string', 'array'], - 'items' => ['type' => 'string'], - 'minItems' => 1, - 'maxItems' => self::MAX_STACK, - 'description' => 'Modul-Preset (Name oder ID) oder Stapel; bei gleicher Priorität gewinnt das ' - . 'letzte Preset im Stapel (Divi-Verhalten).', - ], + 'preset' => PresetAssignment::valueSchema( + false, + 'Modul-Preset (Name oder ID) oder Stapel; bei gleicher Priorität gewinnt das ' + . 'letzte Preset im Stapel (Divi-Verhalten).' + ), 'group_presets' => [ 'type' => 'object', 'description' => '{"": "" | ["", …]}; Gruppe als Kurzname (font-body), voller ' . 'Name (divi/font-body) oder Attributpfad.', + 'additionalProperties' => PresetAssignment::valueSchema(false), ], 'post_ids' => PostIdsInput::schema(), 'only_without_preset' => [ @@ -224,7 +221,7 @@ final class DiviPresetsAssignAbility implements Ability } /** - * preset and group_presets as given; null removes nothing site-wide (lit/divi-design-detach does). + * preset and group_presets as given; null and "" remove nothing site-wide (lit/divi-design-detach does). * * @param array $input * @param array $errors @@ -236,7 +233,7 @@ final class DiviPresetsAssignAbility implements Ability if ($spec === []) { $errors['preset'] = 'preset oder group_presets angeben'; } - if (array_key_exists('preset', $spec) && $spec['preset'] === null) { + if (array_key_exists('preset', $spec) && in_array($spec['preset'], [null, ''], true)) { $errors['preset'] = self::USE_DETACH; } $groups = $spec['group_presets'] ?? null; @@ -244,7 +241,7 @@ final class DiviPresetsAssignAbility implements Ability $errors['group_presets'] = 'mindestens eine Gruppe angeben'; } foreach (is_array($groups) ? $groups : [] as $key => $value) { - if ($value === null) { + if (in_array($value, [null, ''], true)) { $errors['group_presets.' . $key] = self::USE_DETACH; } } diff --git a/src/Integrations/Divi/Design/PresetAssignment.php b/src/Integrations/Divi/Design/PresetAssignment.php index 552d808..ae32fc2 100644 --- a/src/Integrations/Divi/Design/PresetAssignment.php +++ b/src/Integrations/Divi/Design/PresetAssignment.php @@ -19,7 +19,8 @@ defined('ABSPATH') || exit; */ final class PresetAssignment { - private const MAX_STACK = 10; + /** The most presets one module (or group entry) can carry as a stack. */ + public const MAX_STACK = 10; private ?PresetBook $loaded = null; @@ -60,6 +61,29 @@ final class PresetAssignment return ['attrs' => $attrs, 'errors' => $errors]; } + /** + * JSON schema of a preset value: a name or ID, a stack of 1..MAX_STACK of them and, when $nullable, null. + * Without null (site-wide tools) empty strings are refused too: they would mean "remove", which only null or + * lit/divi-design-detach may do there. The single source for every tool that takes a preset value. + * + * @return array + */ + public static function valueSchema(bool $nullable, string $description = ''): array + { + $schema = [ + 'type' => $nullable ? ['string', 'array', 'null'] : ['string', 'array'], + 'items' => ['type' => 'string'], + 'minItems' => 1, + 'maxItems' => self::MAX_STACK, + ]; + if (! $nullable) { + $schema['minLength'] = 1; + $schema['items']['minLength'] = 1; + } + + return $description === '' ? $schema : $schema + ['description' => $description]; + } + /** * The attributes without the local values that lie on values of the presets the block names (ruling R4). * diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 059040d..0bfd77f 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\Compose\ColumnWidths; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use WP_Error; defined('ABSPATH') || exit; @@ -36,8 +37,8 @@ final class DiviUpdateAbility extends DiviWriteAbility { return 'Ändert vorhandene Divi-Knoten: changes = Liste von {id, set: {Kurzform-Felder}, attrs: {Attributpfad: ' . '{Breakpoint: Wert}}, preset, group_presets, clear_overrides, label}. null entfernt einen Wert. ' - . 'preset = Name/ID oder Liste (Stapel, bis 10; bei gleicher Priorität gewinnt das letzte Preset im ' - . 'Stapel); group_presets = {Gruppe: Preset}, z. B. {"font-body": "Fließtext"}. Überdecken lokale Werte ' + . 'preset = Name/ID oder Liste (Stapel, bis ' . PresetAssignment::MAX_STACK . '; bei gleicher Priorität ' + . 'gewinnt das letzte Preset im Stapel); group_presets = {Gruppe: Preset}, z. B. {"font-body": "Fließtext"}. Überdecken lokale Werte ' . 'ein Preset, meldet die Antwort das; clear_overrides: true entfernt sie (nach set/attrs). Nicht ' . 'genannte Einstellungen bleiben unverändert. Jeder Knoten nur einmal pro Aufruf; denselben Pfad nicht ' . 'zugleich über set und attrs. tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.'; @@ -61,16 +62,18 @@ final class DiviUpdateAbility extends DiviWriteAbility 'type' => 'object', 'description' => 'Vollform: Attributpfad → Breakpoint-Werte.', ], - 'preset' => self::presetValue( - 'Preset-Name oder -ID, Liste (Stapel, 1–10) oder null zum Entfernen. Bei gleicher ' - . 'Priorität gewinnt das letzte Preset im Stapel.' + 'preset' => PresetAssignment::valueSchema( + true, + 'Preset-Name oder -ID, Liste (Stapel, 1–' . PresetAssignment::MAX_STACK + . ') oder null zum Entfernen. Bei gleicher Priorität gewinnt das letzte ' + . 'Preset im Stapel.' ), 'group_presets' => [ 'type' => 'object', 'description' => 'Gruppen-Presets: {Gruppe: Preset-Name | Liste | null}. Gruppe = ' . 'Kurzname (font-body), voller Name (divi/font-body) oder Attributpfad; null ' . 'entfernt den Eintrag der Gruppe.', - 'additionalProperties' => self::presetValue(), + 'additionalProperties' => PresetAssignment::valueSchema(true), ], 'clear_overrides' => [ 'type' => 'boolean', @@ -172,21 +175,4 @@ final class DiviUpdateAbility extends DiviWriteAbility return $change; } - - /** - * Schema of one preset value: a name or id, a stack of 1–10 of them, or null. - * - * @return array - */ - private static function presetValue(string $description = ''): array - { - $schema = [ - 'type' => ['string', 'array', 'null'], - 'items' => ['type' => 'string'], - 'minItems' => 1, - 'maxItems' => 10, - ]; - - return $description === '' ? $schema : $schema + ['description' => $description]; - } } diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php index a85d187..e2862d1 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php @@ -117,6 +117,29 @@ final class DiviPresetsAssignAbilityTest extends TestCase self::assertSame('preset oder group_presets angeben', self::errorFields($missing)['preset']); } + public function testEmptyStringAssignmentPointsToDetachToo(): void + { + $ability = $this->ability(self::untouched()); + + $empty = $ability->execute(['module' => 'button', 'preset' => '', 'confirm' => true]); + self::assertToolError($empty, 'validation_failed'); + self::assertStringContainsString('lit/divi-design-detach', self::errorFields($empty)['preset']); + + $group = $ability->execute(['module' => 'text', 'group_presets' => ['font-body' => ''], 'confirm' => true]); + self::assertToolError($group, 'validation_failed'); + self::assertStringContainsString( + 'lit/divi-design-detach', + self::errorFields($group)['group_presets.font-body'] + ); + } + + public function testInputSchemaForbidsEmptyPresetStrings(): void + { + $schema = $this->ability(self::untouched())->inputSchema()['properties']['preset']; + + self::assertSame(1, $schema['minLength']); + } + public function testBadModuleFiltersAndPostIdsAreCollected(): void { $ability = $this->ability(self::untouched()); @@ -212,7 +235,7 @@ final class DiviPresetsAssignAbilityTest extends TestCase self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations()); self::assertSame(['module'], $schema['required']); self::assertFalse($schema['additionalProperties']); - self::assertSame(DiviPresetsAssignAbility::MAX_STACK, $schema['properties']['preset']['maxItems']); + self::assertSame(PresetAssignment::MAX_STACK, $schema['properties']['preset']['maxItems']); self::assertArrayNotHasKey('design_rev', $schema['properties']); self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3)); self::assertStringContainsString('gewinnt das letzte Preset im Stapel', $ability->description());