feat: Fundstellen aller Module eines Typs, Standard-Gruppen-Presets in der Fundstellensuche (ModuleUsageQuery)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:55:54 +02:00
co-authored by Claude Sonnet 5.5
parent 1f9b50f1a6
commit 763d765660
11 changed files with 329 additions and 23 deletions
@@ -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
);
@@ -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
);
@@ -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))
);
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
defined('ABSPATH') || exit;
/**
* The posts with a module of one type (GlobalUsages::reportFor(), Etappe 4b): candidates contain the block name, an
* escaped slash or a JSON escape of a letter (a global instance keeps its type as "blockName" in JSON, fail closed
* like ruling P23); matches() parses the content. Global instances of the type (opaque, never rewritten) count, so
* the page rewriter reports them as skipped; so does markup the parser rejects.
*/
final class ModuleUsageQuery implements UsageQuery
{
private const NAME = '/^([a-z][a-z0-9_-]*)\/([a-z][a-z0-9_-]*)$/D';
private const ESCAPED_LETTER = '\\\\u00[4-7][0-9a-fA-F]';
/** @throws InvalidArgumentException When $module is no full block name such as "divi/button". */
public function __construct(private string $module)
{
if (preg_match(self::NAME, $module) !== 1) {
throw new InvalidArgumentException('ModuleUsageQuery needs a full block name such as divi/button.');
}
}
public function candidateCondition(\wpdb $db): string
{
return (string) $db->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;
}
}
@@ -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.<attr>.presetId whose groupName is the group;
* - group preset $id: a block with $id in groupPreset.<attr>.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<string, string>)|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<string, array<string, string>> $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<string, array<string, string>> $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) !== [];
}
}
@@ -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<array{scope: PresetScope, id: string, default: bool}> $targets
* @param Closure(string $module): array<string, string> $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));