From 763d765660d676ee98a4b3b107fcebcd2dbfc29f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 16:55:54 +0200 Subject: [PATCH] feat: Fundstellen aller Module eines Typs, Standard-Gruppen-Presets in der Fundstellensuche (ModuleUsageQuery) Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/DiviPresetGetAbility.php | 5 +- .../Divi/Design/DiviPresetsDeleteAbility.php | 9 +- .../Divi/Design/DiviPresetsSetAbility.php | 14 +--- .../Divi/Design/ModuleUsageQuery.php | 70 ++++++++++++++++ .../Divi/Design/PresetUsageMatcher.php | 54 ++++++++++-- .../Divi/Design/PresetUsageQuery.php | 19 ++++- .../Design/DiviPresetsDeleteAbilityTest.php | 1 + .../Divi/Design/DiviPresetsSetAbilityTest.php | 10 +++ .../Divi/Design/ModuleUsageQueryTest.php | 82 +++++++++++++++++++ .../Divi/Design/PresetUsageMatcherTest.php | 55 +++++++++++++ .../Divi/Design/PresetUsageQueryTest.php | 33 ++++++++ 11 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 src/Integrations/Divi/Design/ModuleUsageQuery.php create mode 100644 tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php diff --git a/src/Integrations/Divi/Design/DiviPresetGetAbility.php b/src/Integrations/Divi/Design/DiviPresetGetAbility.php index 4cd65cf..cd38f56 100644 --- a/src/Integrations/Divi/Design/DiviPresetGetAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetGetAbility.php @@ -127,7 +127,10 @@ final class DiviPresetGetAbility implements Ability $attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : []; $module = is_string($item['moduleName'] ?? null) ? $item['moduleName'] : $scope->name; $usage = $this->usages->reportFor( - new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => $isDefault]]), + PresetUsageQuery::withGroups( + [['scope' => $scope, 'id' => $id, 'default' => $isDefault]], + $this->writer->groupsOf(...) + ), false ); diff --git a/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php b/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php index 64dde7a..d2ba25c 100644 --- a/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php @@ -17,7 +17,7 @@ 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. Only explicit references + * with its usages (in_use); lit/divi-design-detach removes the references first. Only explicit references * block: a default preset loses its default, and the posts with modules that use it implicitly are listed in the dry * run (spec §6.4). Through the change pipeline with Divi's delete semantics (legacy option cleaned). */ @@ -25,8 +25,8 @@ 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.'; + private const DETACH_HINT = 'Erst die Verweise entfernen: lit/divi-design-detach oder im Divi-Builder; ' + . 'danach erneut löschen.'; /** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */ private const DEFAULT_WARNING = 'Module ohne eigenes Preset verwenden danach das Divi-Standardaussehen.'; @@ -179,12 +179,13 @@ final class DiviPresetsDeleteAbility implements Ability $diff = PresetDiff::between($before, $book); $defaults = array_values(array_filter($targets, static fn (array $target): bool => $target['default'])); + $groupsOf = $this->writer->groupsOf(...); return new DesignPlan( $book->data(), $diff, PresetDiff::summary($diff), - $defaults === [] ? null : $this->usages->reportFor(new PresetUsageQuery($defaults)), + $defaults === [] ? null : $this->usages->reportFor(PresetUsageQuery::withGroups($defaults, $groupsOf)), $defaults === [] ? [] : [self::DEFAULT_WARNING], true ); diff --git a/src/Integrations/Divi/Design/DiviPresetsSetAbility.php b/src/Integrations/Divi/Design/DiviPresetsSetAbility.php index 548a4eb..a5eaf62 100644 --- a/src/Integrations/Divi/Design/DiviPresetsSetAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsSetAbility.php @@ -20,7 +20,8 @@ defined('ABSPATH') || exit; * lit/divi-presets-set (spec §6.3, G7): creates and changes module and option group presets – from the short and full * form, from a module of a page (from_node), with nested group presets and as the default preset – through the * change pipeline (dry run, design_rev, snapshot, Divi's sync, change log). New names must be free within the - * module/group (A5). The dry run lists the posts that use a changed preset or reach a new default. + * module/group (A5). The dry run lists the posts that use a changed preset or reach a new default, also implicitly + * (ruling R11). */ final class DiviPresetsSetAbility implements Ability { @@ -33,9 +34,6 @@ final class DiviPresetsSetAbility implements Ability private const MARKUP = 'Vorhandene Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html ' . 'verloren ginge; nichts wurde gespeichert.'; - private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser ' - . 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.'; - /** * @param Closure(): PresetComposer $composer * @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). @@ -159,7 +157,7 @@ final class DiviPresetsSetAbility implements Ability $invalid = []; $targets = []; $created = []; - $warnings = []; + $groupsOf = $this->writer->groupsOf(...); foreach ($items as $index => $item) { $field = sprintf('items[%d]', $index); if (! is_array($item)) { @@ -184,9 +182,6 @@ final class DiviPresetsSetAbility implements Ability $book = $book->with($scope, $result['item']); if (($item['make_default'] ?? null) === true) { $book = $book->withDefault($scope, $id); - if ($scope->kind === PresetScope::GROUP) { - $warnings[] = self::GROUP_DEFAULT_WARNING; - } } elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) { $book = $book->withDefault($scope, ''); } @@ -214,8 +209,7 @@ final class DiviPresetsSetAbility implements Ability $book->data(), $diff, PresetDiff::summary($diff), - $targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)), - array_values(array_unique($warnings)) + $targets === [] ? null : $this->usages->reportFor(PresetUsageQuery::withGroups($targets, $groupsOf)) ); } diff --git a/src/Integrations/Divi/Design/ModuleUsageQuery.php b/src/Integrations/Divi/Design/ModuleUsageQuery.php new file mode 100644 index 0000000..fb22dde --- /dev/null +++ b/src/Integrations/Divi/Design/ModuleUsageQuery.php @@ -0,0 +1,70 @@ +prepare( + '(post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', + '%' . $db->esc_like($this->module) . '%', + $this->escapedSlash(), + self::ESCAPED_LETTER + ); + } + + public function matches(string $content): bool + { + try { + $index = NodeIndex::build((new Parser())->parse($content)); + } catch (MalformedMarkup) { + return true; + } + + foreach ($index->entries() as $entry) { + if (GlobalInstance::effectiveName($entry['block']) === $this->module) { + return true; + } + } + + return false; + } + + /** Regular expression for "divi\/button" and "divi/button" (two literal backslashes in the pattern). */ + private function escapedSlash(): string + { + [$namespace, $name] = explode('/', $this->module, 2); + + return $namespace . '(\\\\/|\\\\u002[fF])' . $name; + } +} diff --git a/src/Integrations/Divi/Design/PresetUsageMatcher.php b/src/Integrations/Divi/Design/PresetUsageMatcher.php index 1f892cd..8f4ebf7 100644 --- a/src/Integrations/Divi/Design/PresetUsageMatcher.php +++ b/src/Integrations/Divi/Design/PresetUsageMatcher.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use Closure; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; @@ -17,15 +18,20 @@ defined('ABSPATH') || exit; * - module preset $id: a block of the module with $id in its modulePreset stack (array or legacy string); * the default preset additionally reaches every block of the module without an own preset ("" / "default" / * "_initial" mean the default, GlobalPreset::is_preset_id_as_default); - * - group preset $id: a block with $id in groupPreset..presetId whose groupName is the group; + * - group preset $id: a block with $id in groupPreset..presetId whose groupName is the group; the default + * group preset additionally reaches every block whose module has the group (groupsOf) and that has no own group + * preset for it (ruling R11) – without $groupsOf only the explicit references are found; * - markup the parser rejects counts as a use. + * Global instances (opaque) get their content from the library entry, so they never reach a default preset. * Pure PHP. */ final class PresetUsageMatcher { - private const DEFAULT_IDS = ['', 'default', '_initial']; + /** Preset IDs that mean "no own preset" (GlobalPreset::is_preset_id_as_default). */ + public const DEFAULT_IDS = ['', 'default', '_initial']; - public function __construct(private Parser $parser = new Parser()) + /** @param (Closure(string $module): array)|null $groupsOf groupId => groupName of a module */ + public function __construct(private Parser $parser = new Parser(), private ?Closure $groupsOf = null) { } @@ -37,8 +43,9 @@ final class PresetUsageMatcher return true; } + $groups = []; foreach ($index->entries() as $entry) { - if ($this->usesIn($entry['block'], $scope, $id, $isDefault)) { + if ($this->usesIn($entry['block'], $scope, $id, $isDefault, $groups)) { return true; } } @@ -46,13 +53,15 @@ final class PresetUsageMatcher return false; } - private function usesIn(Block $block, PresetScope $scope, string $id, bool $isDefault): bool + /** @param array> $groups groupsOf() per module name, filled on demand */ + private function usesIn(Block $block, PresetScope $scope, string $id, bool $isDefault, array &$groups): bool { if ($scope->kind === PresetScope::MODULE) { return $this->usesModulePreset($block, $scope, $id, $isDefault); } - return $this->usesGroupPreset($block->attrs(), $scope, $id); + return $this->usesGroupPreset($block->attrs(), $scope, $id) + || ($isDefault && $this->reachesByDefault($block, $scope, $groups)); } private function usesModulePreset(Block $block, PresetScope $scope, string $id, bool $isDefault): bool @@ -84,4 +93,37 @@ final class PresetUsageMatcher return false; } + + /** + * Ruling R11: the module of $block has the group $scope and $block has no own group preset for its groupId. + * + * @param array> $groups + */ + private function reachesByDefault(Block $block, PresetScope $scope, array &$groups): bool + { + if ($this->groupsOf === null || ! $block->isDivi() || $block->isOpaque()) { + return false; + } + + $groups[$block->name()] ??= ($this->groupsOf)($block->name()); + $own = is_array($block->attrs()['groupPreset'] ?? null) ? $block->attrs()['groupPreset'] : []; + foreach ($groups[$block->name()] as $groupId => $groupName) { + if ($groupName === $scope->name && ! self::hasOwnPreset($own[$groupId] ?? null)) { + return true; + } + } + + return false; + } + + private static function hasOwnPreset(mixed $entry): bool + { + if (! is_array($entry)) { + return false; + } + + $ids = array_filter((array) ($entry['presetId'] ?? []), 'is_string'); + + return array_diff($ids, self::DEFAULT_IDS) !== []; + } } diff --git a/src/Integrations/Divi/Design/PresetUsageQuery.php b/src/Integrations/Divi/Design/PresetUsageQuery.php index ddac7c5..6619bc1 100644 --- a/src/Integrations/Divi/Design/PresetUsageQuery.php +++ b/src/Integrations/Divi/Design/PresetUsageQuery.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use Closure; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Library\UsageQuery; @@ -11,8 +12,9 @@ defined('ABSPATH') || exit; /** * The posts that use any of the presets $targets (GlobalUsages::reportFor()): candidates contain one of the IDs, a - * JSON escape of a letter (an escaped ID or key, fail closed like ruling P23) or – for a default module preset – a - * block of the module; PresetUsageMatcher decides per target. + * JSON escape of a letter (an escaped ID or key, fail closed like ruling P23), for a default module preset a block + * of the module and for a default group preset any Divi block (ruling R11: which modules have the group is only + * known to PresetUsageMatcher::$groupsOf); PresetUsageMatcher decides per target. */ final class PresetUsageQuery implements UsageQuery { @@ -33,6 +35,17 @@ final class PresetUsageQuery implements UsageQuery $this->targets = array_values($targets); } + /** + * Targets whose default group presets are also found where a module reaches them implicitly. + * + * @param list $targets + * @param Closure(string $module): array $groupsOf groupId => groupName of a module + */ + public static function withGroups(array $targets, Closure $groupsOf): self + { + return new self($targets, new PresetUsageMatcher(groupsOf: $groupsOf)); + } + public function candidateCondition(\wpdb $db): string { $likes = []; @@ -40,6 +53,8 @@ final class PresetUsageQuery implements UsageQuery $likes[] = '%' . $db->esc_like($target['id']) . '%'; if ($target['default'] && $target['scope']->kind === PresetScope::MODULE) { $likes[] = '%' . $db->esc_like('wp:' . $target['scope']->name . ' ') . '%'; + } elseif ($target['default']) { + $likes[] = '%' . $db->esc_like('wp:divi/') . '%'; } } $likes = array_values(array_unique($likes)); diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php index e1b846a..611e4f8 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php @@ -98,6 +98,7 @@ final class DiviPresetsDeleteAbilityTest extends TestCase self::assertSame(3, $error['usages'][0]['post_id']); self::assertStringContainsString('lit/divi-design-detach', $error['hint']); + self::assertStringNotContainsString('4b', $error['hint'], 'the tool exists now'); } public function testNestedUseInAnotherPresetCounts(): void diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php index 23290fc..1ca110c 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php @@ -138,6 +138,16 @@ final class DiviPresetsSetAbilityTest extends TestCase self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action')); } + /** R11: a default group preset no longer warns that pages are "not listed" – the dry run lists them. */ + public function testDefaultGroupPresetHasNoNotListedWarning(): void + { + $dry = self::assertToolError($this->ability()->execute(['items' => [[ + 'group' => 'font-body', 'preset' => 'Lesetext', 'rename' => 'Lesetext neu', 'make_default' => true, + ]]]), 'confirm_required'); + + self::assertSame([], $dry['warnings']); + } + public function testUnknownPresetAndBadItemsAreCollected(): void { $result = self::assertToolError($this->ability()->execute(['items' => [ diff --git a/tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php b/tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php new file mode 100644 index 0000000..5b621af --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php @@ -0,0 +1,82 @@ +' . $inner + . ''; + } + + public function testCandidatesContainTheNameAnEscapedSlashOrAnEscapedLetter(): void + { + $db = Mockery::mock(\wpdb::class); + $db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', + '%divi/button%', + 'divi(\\\\/|\\\\u002[fF])button', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + self::assertSame('(prepared)', (new ModuleUsageQuery('divi/button'))->candidateCondition($db)); + } + + public function testNeedsAFullBlockName(): void + { + $this->expectException(InvalidArgumentException::class); + + new ModuleUsageQuery('button'); + } + + public function testMatchesBlocksOfTheModule(): void + { + $query = new ModuleUsageQuery('divi/button'); + + self::assertTrue($query->matches(self::page(''))); + self::assertTrue($query->matches(self::page(''))); + self::assertTrue($query->matches(self::page( + '' + ))); + self::assertFalse($query->matches(self::page(''))); + self::assertFalse($query->matches(self::page('')), 'another module name'); + self::assertFalse($query->matches('

Kein Divi-Inhalt

')); + } + + public function testGlobalInstancesOfTheModuleCountSoThatTheyAreReportedAsSkipped(): void + { + $query = new ModuleUsageQuery('divi/button'); + + self::assertTrue($query->matches(self::page( + '' + ))); + self::assertTrue($query->matches(self::page( + '' + )), 'an escaped slash decodes to the same name'); + self::assertFalse($query->matches(self::page( + '' + ))); + self::assertTrue($query->matches(self::page( + '' + )), 'Divi 4 conversion: the block itself carries globalModule'); + self::assertTrue($query->matches(self::page('')), 'unreadable attributes'); + } + + public function testBrokenMarkupCountsAsUse(): void + { + self::assertTrue((new ModuleUsageQuery('divi/button'))->matches( + '' + )); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php b/tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php index a2dddd9..f6fa85b 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; final class PresetUsageMatcherTest extends TestCase { + private const TEXT = ''; private const BUTTON = '' . ''; @@ -62,4 +63,58 @@ final class PresetUsageMatcherTest extends TestCase false )); } + + private static function groupsOf(string $module): array + { + return $module === 'divi/text' ? ['content.decoration.bodyFont' => 'divi/font-body'] : []; + } + + public function testDefaultGroupPresetReachesModulesWithTheGroup(): void + { + $matcher = new PresetUsageMatcher(groupsOf: self::groupsOf(...)); + $group = new PresetScope('group', 'divi/font-body'); + $own = '{"groupPreset":{"content.decoration.bodyFont":{"presetId":["%s"],"groupName":"divi/font-body"}}}'; + + self::assertTrue($matcher->uses(sprintf(self::TEXT, '{}'), $group, 'gggggggggg', true)); + self::assertTrue( + $matcher->uses(sprintf(self::TEXT, sprintf($own, 'default')), $group, 'gggggggggg', true), + 'an entry with the default ID is no own preset' + ); + self::assertFalse( + $matcher->uses(sprintf(self::TEXT, sprintf($own, 'hhhhhhhhhh')), $group, 'gggggggggg', true), + 'an own group preset replaces the default' + ); + self::assertFalse( + $matcher->uses(self::page('{}'), $group, 'gggggggggg', true), + 'a button has no font-body group' + ); + self::assertFalse( + $matcher->uses(sprintf(self::TEXT, '{}'), new PresetScope('group', 'divi/spacing'), 'gggggggggg', true), + 'the text module has no spacing group in this fixture' + ); + self::assertFalse( + $matcher->uses(sprintf(self::TEXT, '{}'), $group, 'gggggggggg', false), + 'a preset that is not the default needs an explicit entry' + ); + } + + public function testDefaultGroupPresetIsNotDetectedWithoutGroupsOf(): void + { + self::assertFalse((new PresetUsageMatcher())->uses( + sprintf(self::TEXT, '{}'), + new PresetScope('group', 'divi/font-body'), + 'gggggggggg', + true + )); + } + + public function testGlobalInstancesDoNotReachADefaultGroupPreset(): void + { + $matcher = new PresetUsageMatcher(groupsOf: static fn (string $module): array => [ + 'content.decoration.bodyFont' => 'divi/font-body', + ]); + $instance = ''; + + self::assertFalse($matcher->uses($instance, new PresetScope('group', 'divi/font-body'), 'gggggggggg', true)); + } } diff --git a/tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php b/tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php index 8217a97..8c10d5b 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php @@ -62,4 +62,37 @@ final class PresetUsageQueryTest extends TestCase new PresetUsageQuery([]); } + + public function testADefaultGroupPresetLooksForEveryDiviBlock(): void + { + $db = Mockery::mock(\wpdb::class); + $db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s)', + '%gggggggggg%', + '%wp:divi/%', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + $query = new PresetUsageQuery([ + ['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => true], + ]); + + self::assertSame('(prepared)', $query->candidateCondition($db)); + } + + public function testWithGroupsDetectsTheImplicitUseOfADefaultGroupPreset(): void + { + $query = PresetUsageQuery::withGroups( + [['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => true]], + static fn (string $module): array => $module === 'divi/text' + ? ['content.decoration.bodyFont' => 'divi/font-body'] + : [] + ); + + self::assertTrue($query->matches('')); + self::assertFalse($query->matches('')); + } }