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:
co-authored by
Claude Sonnet 5.5
parent
1f9b50f1a6
commit
763d765660
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user