feat: lit-divi-update prüft jede Änderung im Schleifen-Kontext ihres Knotens – ein Composer je Kontext, neue loop-Attribute zählen (X3, S31)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:52:14 +02:00
co-authored by Claude Sonnet 5.5
parent 249bc7b932
commit 8888d246a9
2 changed files with 106 additions and 5 deletions
@@ -7,7 +7,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths; use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error; use WP_Error;
@@ -21,6 +24,9 @@ defined('ABSPATH') || exit;
* come back together; only then are the attributes Composer::update() computed set. A node may * come back together; only then are the attributes Composer::update() computed set. A node may
* appear once per call (ruling S14); set and attrs of one change must not reach the same path * appear once per call (ruling S14); set and attrs of one change must not reach the same path
* (ruling S38, checked by Composer::update()). * (ruling S38, checked by Composer::update()).
*
* Loop content (ruling X3): each change is checked in the loop scope of its node – the stored ancestors plus the
* loop attrs this call gives the node or an ancestor – with one Composer per scope.
*/ */
final class DiviUpdateAbility extends DiviWriteAbility final class DiviUpdateAbility extends DiviWriteAbility
{ {
@@ -123,14 +129,13 @@ final class DiviUpdateAbility extends DiviWriteAbility
]); ]);
} }
$composer = $composition->composer();
$errors = []; $errors = [];
/** @var list<string> $invalidPaths */ /** @var list<string> $invalidPaths */
$invalidPaths = []; $invalidPaths = [];
/** @var array<string, int> $seen id => position of its first change */ /** @var array<string, int> $seen id => position of its first change */
$seen = []; $seen = [];
/** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */ /** @var list<array{0: Block, 1: array<array-key, mixed>, 2: string}> $pending */
$updates = []; $pending = [];
foreach ($changes as $position => $change) { foreach ($changes as $position => $change) {
$field = sprintf('changes[%d]', $position); $field = sprintf('changes[%d]', $position);
if (! is_array($change)) { if (! is_array($change)) {
@@ -159,10 +164,15 @@ final class DiviUpdateAbility extends DiviWriteAbility
continue; continue;
} }
$result = $composer->update($block, $change, $field); $pending[] = [$block, $change, $field];
}
/** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
$updates = [];
foreach (self::composeInLoops($page->index, $composition, $pending) as $position => $result) {
$errors += $result['errors']; $errors += $result['errors'];
array_push($invalidPaths, ...$result['invalid_paths']); array_push($invalidPaths, ...$result['invalid_paths']);
$updates[] = [$block, $result['attrs'], $field]; $updates[] = [$pending[$position][0], $result['attrs'], $pending[$position][2]];
} }
if ($errors === []) { if ($errors === []) {
@@ -189,4 +199,46 @@ final class DiviUpdateAbility extends DiviWriteAbility
return $change; return $change;
} }
/**
* Composer::update() of every pending change in its loop scope (ruling X3). First with the stored ancestors;
* then changes whose scope the new attrs of this call move (a loop switched on, off or to another kind on the
* node or an ancestor) are composed again in that scope – loop attrs are no client content, so the second
* pass sees the same loops.
*
* @param list<array{0: Block, 1: array<array-key, mixed>, 2: string}> $pending
* @return list<array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}>
*/
private static function composeInLoops(NodeIndex $index, DiviComposition $composition, array $pending): array
{
/** @var array<string, Composer> $composers query type ('' = no loop) => Composer of that scope */
$composers = [];
$scopes = [];
$results = [];
foreach ($pending as $position => [$block, $change, $field]) {
$scopes[$position] = self::loopAt($index, $block);
$results[$position] = self::composerFor($composition, $scopes[$position], $composers)
->update($block, $change, $field);
}
$newAttrs = [];
foreach ($pending as $position => [$block]) {
$newAttrs[(string) $index->idOf($block)] = $results[$position]['attrs'];
}
foreach ($pending as $position => [$block, $change, $field]) {
$scope = self::loopAt($index, $block, $newAttrs);
if ($scope->queryType() !== $scopes[$position]->queryType()) {
$results[$position] = self::composerFor($composition, $scope, $composers)
->update($block, $change, $field);
}
}
return $results;
}
/** @param array<string, Composer> $composers One Composer per loop scope (S31 as reworded by X3). */
private static function composerFor(DiviComposition $composition, LoopScope $scope, array &$composers): Composer
{
return $composers[$scope->queryType() ?? ''] ??= $composition->inLoop($scope)->composer();
}
} }
@@ -172,4 +172,53 @@ final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
]]); ]]);
self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent)); self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent));
} }
/** Ruling X3: one Composer per loop scope – changes in a post loop and a term loop in one call. */
public function testUpdateChecksEveryChangeInItsOwnLoop(): void
{
$result = $this->changeAll([
['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:post_date}}']],
['id' => 's0.r0.c1.m0', 'set' => ['content' => '{{loop:term_name}}']],
['id' => 's0.r0.c2.m0', 'set' => ['content' => '<p>Neu</p>']],
]);
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertStringContainsString('loop_term_name', $this->saved[0]);
$refused = $this->changeAll([
['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:term_name}}']],
['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:post_date}}']],
]);
$problems = self::errorFields($refused);
self::assertStringContainsString('Typ: Beiträge', $problems['changes[0].set.content'] ?? '');
self::assertStringContainsString('braucht eine Divi-Schleife', $problems['changes[1].set.content'] ?? '');
}
/**
* Ruling X3: the scope of a change takes the loop attrs this call gives the node or an ancestor – a loop
* switched on admits its content, a loop switched to another kind refuses the old kind's content.
*/
public function testUpdateUsesTheLoopsThisCallSets(): void
{
$switchedOn = $this->changeAll([
['id' => 's0.r0.c2', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:term_name}}']],
]);
self::assertTrue($switchedOn['ok'], (string) json_encode($switchedOn));
$own = $this->changeAll([[
'id' => 's0.r0.c0.m1',
'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]],
'set' => ['content' => '{{loop:term_name}}'],
]]);
self::assertTrue($own['ok'], (string) json_encode($own));
$switchedKind = $this->changeAll([
['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
['id' => 's0.r0.c0.m0', 'set' => ['content' => '{{loop:post_date}}']],
]);
$problems = self::errorFields($switchedKind);
self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? '');
self::assertCount(2, $this->saved);
}
} }