feat: Umschreib-Strategien für Preset-Zuweisung und -Entkopplung (TreeRewrite, AssignRewrite, DetachRewrite)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
763d765660
commit
d36e8286e8
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use JsonException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
use LogicException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Mass assignment of a preset stack and group presets (lit/divi-presets-assign): every block of $module – optionally
|
||||
* only those without an own preset (empty stack or only default IDs) or only those that use preset $onlyWithPresetId
|
||||
* – gets $spec through PresetAssignment, the one choke point for preset assignments (D7). Local values that cover the
|
||||
* assigned presets are noted, or removed with $clearOverrides (rulings R4, R5). A block that would not change stays
|
||||
* untouched, so the call can be repeated. Pure PHP.
|
||||
*/
|
||||
final class AssignRewrite implements NodeRewrite
|
||||
{
|
||||
/**
|
||||
* @param array{preset?: mixed, group_presets?: mixed} $spec Already checked: PresetAssignment::apply() returned
|
||||
* no errors for it.
|
||||
*/
|
||||
public function __construct(
|
||||
private string $module,
|
||||
private PresetAssignment $assignment,
|
||||
private array $spec,
|
||||
private bool $onlyWithoutPreset = false,
|
||||
private ?string $onlyWithPresetId = null,
|
||||
private bool $clearOverrides = false
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws LogicException When $spec does not pass PresetAssignment (it was to be validated beforehand). */
|
||||
public function rewrite(Block $block): ?array
|
||||
{
|
||||
if (! $this->selects($block)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
$result = $this->assignment->apply($this->module, $attrs, $this->spec, 'node');
|
||||
if ($result['errors'] !== []) {
|
||||
throw new LogicException('The preset assignment was not validated: ' . implode('; ', $result['errors']));
|
||||
}
|
||||
|
||||
$new = $this->clearOverrides ? $this->assignment->clearOverrides($result['attrs']) : $result['attrs'];
|
||||
|
||||
return self::same($attrs, $new) ? null : $new;
|
||||
}
|
||||
|
||||
public function notes(Block $block, string $nodeId): array
|
||||
{
|
||||
if ($this->clearOverrides) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->assignment->overrides()->describe($nodeId, $block->attrs(), $this->assignment->names());
|
||||
}
|
||||
|
||||
public function concernsInstance(Block $instance): bool
|
||||
{
|
||||
return GlobalInstance::effectiveName($instance) === $this->module;
|
||||
}
|
||||
|
||||
private function selects(Block $block): bool
|
||||
{
|
||||
if ($block->name() !== $this->module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stack = NodeSummary::presetIds($block);
|
||||
if ($this->onlyWithoutPreset && array_diff($stack, PresetUsageMatcher::DEFAULT_IDS) !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->onlyWithPresetId === null || in_array($this->onlyWithPresetId, $stack, true);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $before @param array<string, mixed> $after */
|
||||
private static function same(array $before, array $after): bool
|
||||
{
|
||||
try {
|
||||
return Json::encode($before) === Json::encode($after);
|
||||
} catch (JsonException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use JsonException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Removes the references to preset $id from blocks (lit/divi-design-detach, ruling R10): a module preset from the
|
||||
* modulePreset stack of the blocks of its module (an empty stack leaves no key, so Divi falls back to the default
|
||||
* preset), a group preset from every groupPreset entry of its group (an empty entry, then an empty map is removed).
|
||||
* Nested references inside other presets are not blocks; the preset pipeline removes those. Pure PHP.
|
||||
*/
|
||||
final class DetachRewrite implements NodeRewrite
|
||||
{
|
||||
public function __construct(private PresetScope $scope, private string $id)
|
||||
{
|
||||
}
|
||||
|
||||
public function rewrite(Block $block): ?array
|
||||
{
|
||||
if (! $block->isDivi()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->scope->kind === PresetScope::MODULE
|
||||
? $this->withoutModulePreset($block)
|
||||
: $this->withoutGroupPreset($block->attrs());
|
||||
}
|
||||
|
||||
public function notes(Block $block, string $nodeId): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function concernsInstance(Block $instance): bool
|
||||
{
|
||||
return self::mentions($instance, $this->id);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private function withoutModulePreset(Block $block): ?array
|
||||
{
|
||||
$attrs = $block->attrs();
|
||||
$stack = self::idList($attrs['modulePreset'] ?? null);
|
||||
if ($block->name() !== $this->scope->name || ! in_array($this->id, $stack, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rest = $this->without($stack);
|
||||
if ($rest === []) {
|
||||
unset($attrs['modulePreset']);
|
||||
} else {
|
||||
$attrs['modulePreset'] = $rest;
|
||||
}
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attrs
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function withoutGroupPreset(array $attrs): ?array
|
||||
{
|
||||
$groups = $attrs['groupPreset'] ?? null;
|
||||
if (! is_array($groups)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
foreach ($groups as $groupId => $entry) {
|
||||
if (! is_array($entry) || ($entry['groupName'] ?? null) !== $this->scope->name) {
|
||||
continue;
|
||||
}
|
||||
$ids = self::idList($entry['presetId'] ?? null);
|
||||
if (! in_array($this->id, $ids, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = true;
|
||||
$rest = $this->without($ids);
|
||||
if ($rest === []) {
|
||||
unset($groups[$groupId]);
|
||||
} else {
|
||||
$groups[$groupId]['presetId'] = $rest;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $changed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($groups === []) {
|
||||
unset($attrs['groupPreset']);
|
||||
} else {
|
||||
$attrs['groupPreset'] = $groups;
|
||||
}
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
/** @return list<mixed> The IDs of a stored stack: a list, the legacy single string, else none. */
|
||||
private static function idList(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
return is_array($value) ? array_values($value) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $ids
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function without(array $ids): array
|
||||
{
|
||||
return array_values(array_filter($ids, fn (mixed $item): bool => $item !== $this->id));
|
||||
}
|
||||
|
||||
/** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */
|
||||
private static function mentions(Block $block, string $id): bool
|
||||
{
|
||||
try {
|
||||
if (str_contains(Json::encode($block->attrs()), $id)) {
|
||||
return true;
|
||||
}
|
||||
} catch (JsonException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
if (self::mentions($child, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* One change to the blocks of a tree (Etappe 4b): assigning a preset (AssignRewrite) or removing a reference to it
|
||||
* (DetachRewrite). TreeRewrite applies it to every block of a page; the strategy only decides per block. Pure PHP.
|
||||
*/
|
||||
interface NodeRewrite
|
||||
{
|
||||
/** The new attributes of $block, or null when it stays as it is (then there is no note either). */
|
||||
public function rewrite(Block $block): ?array;
|
||||
|
||||
/**
|
||||
* Notes on $block after rewrite() changed it (e.g. local values that cover the assigned preset).
|
||||
*
|
||||
* @param string $nodeId Path ID of $block (NodeIndex).
|
||||
* @return list<string>
|
||||
*/
|
||||
public function notes(Block $block, string $nodeId): array;
|
||||
|
||||
/** Whether the opaque global instance $instance would matter for this change (then it is reported as skipped). */
|
||||
public function concernsInstance(Block $instance): bool;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Applies a NodeRewrite to every block of a NodeIndex, in place (ruling R8): readable blocks are offered to
|
||||
* rewrite(), a changed one gets its new attributes and then its notes; opaque blocks – global instances, blocks with
|
||||
* unreadable attributes – are never offered and never changed, but listed as skipped when the strategy says the
|
||||
* change concerns them (a global module is edited in its library entry). Pure PHP.
|
||||
*/
|
||||
final class TreeRewrite
|
||||
{
|
||||
public function __construct(private NodeRewrite $rewrite)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{changed: list<array{id: string, module: string, label: string}>, warnings: list<string>,
|
||||
* skipped: list<array{id: string, reason: string}>}
|
||||
*/
|
||||
public function apply(NodeIndex $index): array
|
||||
{
|
||||
$changed = [];
|
||||
$warnings = [];
|
||||
$skipped = [];
|
||||
foreach ($index->entries() as $entry) {
|
||||
$id = $entry['id'];
|
||||
$block = $entry['block'];
|
||||
|
||||
if ($block->isOpaque()) {
|
||||
if ($this->rewrite->concernsInstance($block)) {
|
||||
$skipped[] = ['id' => $id, 'reason' => self::reason($block)];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$attrs = $this->rewrite->rewrite($block);
|
||||
if ($attrs === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$block->setAttrs($attrs);
|
||||
$changed[] = ['id' => $id, 'module' => $block->name(), 'label' => NodeSummary::label($block)];
|
||||
array_push($warnings, ...$this->rewrite->notes($block, $id));
|
||||
}
|
||||
|
||||
return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
private static function reason(Block $block): string
|
||||
{
|
||||
$ref = NodeSummary::globalRef($block);
|
||||
if ($ref !== null) {
|
||||
return sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id']);
|
||||
}
|
||||
|
||||
return $block->isGlobal()
|
||||
? 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen'
|
||||
: 'Attribute nicht lesbar – im Divi-Builder prüfen';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user