diff --git a/src/Integrations/Divi/Compose/LoopContext.php b/src/Integrations/Divi/Compose/LoopContext.php index eed59c7..c31f8ff 100644 --- a/src/Integrations/Divi/Compose/LoopContext.php +++ b/src/Integrations/Divi/Compose/LoopContext.php @@ -45,8 +45,10 @@ final class LoopContext $queryType = self::settings($attrs)['queryType'] ?? 'post_types'; - // Divi's sanitize_key() makes a non-string "" – a loop without a kind the tools support. - return LoopScope::ofQueryType(is_string($queryType) ? $queryType : ''); + // Divi's sanitize_key() (LoopUtils.php:729-731) makes a non-string "" and lowercases and strips the rest. + return LoopScope::ofQueryType( + is_string($queryType) ? preg_replace('/[^a-z0-9_\-]/', '', strtolower($queryType)) ?? '' : '' + ); } /** @@ -54,14 +56,20 @@ final class LoopContext */ public static function fromChain(array $attrChain): LoopScope { + $scopes = []; foreach (array_reverse($attrChain) as $attrs) { $scope = self::ownScope($attrs); if ($scope !== null) { - return $scope; + $scopes[] = $scope; } } + if ($scopes === []) { + return LoopScope::none(); + } - return LoopScope::none(); + $outerKinds = array_map(static fn (LoopScope $outer): ?string => $outer->kind(), array_slice($scopes, 1)); + + return $scopes[0]->inside($outerKinds); } /** @@ -103,14 +111,12 @@ final class LoopContext /** The loop of the node $nodeId (path ID) or its nearest ancestor; none() for an unknown ID. */ public static function of(NodeIndex $index, string $nodeId): LoopScope { + $chain = []; for ($node = $index->find($nodeId); $node !== null; $node = $index->parentBlock($node)) { - $scope = $node->isOpaque() ? null : self::ownScope($node->attrs()); - if ($scope !== null) { - return $scope; - } + array_unshift($chain, $node->isOpaque() ? [] : $node->attrs()); } - return LoopScope::none(); + return self::fromChain($chain); } /** diff --git a/src/Integrations/Divi/Compose/LoopScope.php b/src/Integrations/Divi/Compose/LoopScope.php index 2784638..e36516a 100644 --- a/src/Integrations/Divi/Compose/LoopScope.php +++ b/src/Integrations/Divi/Compose/LoopScope.php @@ -11,10 +11,15 @@ defined('ABSPATH') || exit; * queryType: post_types and current_page → post, post_taxonomies → term, user_roles → user, menus → menu * (the five values the Visual Builder offers; LoopUtils.php:730-739, QueryTypeController.php:89-96). * - * Any other queryType – repeater_, the aliases "terms"/"users", "" – is a loop the tools do - * not support: queryType() names it, but it has no kind and is not active(), so every loop_* option is - * refused inside it (fail closed). ACF repeater rows would need the setting acf_type, which the content - * rules refuse (ruling X7e); Divi's content renderer knows no aliases (LoopUtils.php:3130-3148). + * "terms" and "users" are Divi's aliases for term and user loops (LoopUtils.php:881-898; LoopContext + * normalises the queryType like sanitize_key first). Any other queryType – repeater_, "" – is a + * loop the tools do not support: queryType() names it, but it has no kind and is not active(), so every + * loop_* option is refused inside it (fail closed). ACF repeater rows would need the setting acf_type, + * which the content rules refuse (ruling X7e). + * + * Nested loops (audit M1): an empty inner loop hands its block unchanged to the outer loop, which then + * resolves the references with ITS object. So kinds() lists the kinds of all enabled loops of the chain, + * and a meta key option has to fit every one of them (fits()). * * kindOfOption() is the kind an option NAME belongs to: Divi takes the meta type of * loop__meta_key_* from the name and reads the meta of the loop object's ID, whatever the @@ -35,6 +40,8 @@ final class LoopScope 'post_taxonomies' => self::TERM, 'user_roles' => self::USER, 'menus' => self::MENU, + 'terms' => self::TERM, + 'users' => self::USER, ]; private const LABELS = [ @@ -52,7 +59,8 @@ final class LoopScope 'loop_acf_' => null, ]; - private function __construct(private ?string $queryType) + /** @param list $outerKinds Kinds of the enclosing enabled loops, innermost first */ + private function __construct(private ?string $queryType, private array $outerKinds = []) { } @@ -66,6 +74,26 @@ final class LoopScope return new self($queryType); } + /** + * The same loop inside the loops of $outerKinds (innermost first; null = an unsupported loop). + * + * @param list $outerKinds + */ + public function inside(array $outerKinds): self + { + return new self($this->queryType, $outerKinds); + } + + /** + * Kinds of all enabled loops of the chain, innermost (own) first; null for an unsupported loop; [] without. + * + * @return list + */ + public function kinds(): array + { + return $this->queryType === null ? [] : [$this->kind(), ...$this->outerKinds]; + } + /** Whether there is a loop whose loop_* options the tools allow. */ public function active(): bool { @@ -92,10 +120,19 @@ final class LoopScope return $kind === null ? (string) $this->queryType : self::LABELS[$kind]; } - /** Whether the loop_* option $option belongs to this loop's kind. */ + /** + * Whether the loop_* option $option belongs to this loop's kind – a meta key option (loop__meta_key_*) + * to the kind of every loop of the chain (audit M1), since Divi reads meta by the ID of whatever loop + * object resolves it. + */ public function fits(string $option): bool { - return $this->active() && self::kindOfOption($option) === $this->kind(); + $kind = self::kindOfOption($option); + if (! $this->active() || $kind !== $this->kind()) { + return false; + } + + return ! str_contains($option, '_meta_key_') || array_unique($this->kinds()) === [$kind]; } /** Kind of a loop_* option by its name; null for other names and ACF repeater options. */ diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php index ba0c4a1..7450136 100644 --- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -338,6 +338,9 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures } foreach ($this->map->loopFields($kind, (int) $page->post->ID) as $name => $settings) { + if (! $scope->fits((string) $name)) { + continue; + } $result['loop_sources'][] = [ 'name' => (string) $name, 'placeholder' => self::placeholder((string) $name), diff --git a/src/Integrations/Divi/Pages/DiviMoveAbility.php b/src/Integrations/Divi/Pages/DiviMoveAbility.php index 8fcb754..67f1d6b 100644 --- a/src/Integrations/Divi/Pages/DiviMoveAbility.php +++ b/src/Integrations/Divi/Pages/DiviMoveAbility.php @@ -79,8 +79,17 @@ final class DiviMoveAbility extends DiviWriteAbility } // Both scopes from the tree before the move: the new parent never lies inside $node (TreeEditor::move()). - $from = self::loopAt($page->index, $page->index->parentBlock($node)); - $to = self::loopAt($page->index, $position['parent']); + // Audit M1: for $node and its loop-enabled descendants – the new ancestors join their chains. + $target = self::ancestorAttrs($page->index, $position['parent']); + $warnings = []; + foreach (self::loopRoots($node) as [$root, $path]) { + $names = LoopContext::optionsIn($root, true); + $from = self::loopAt($page->index, $root); + $to = LoopContext::fromChain([...$target, ...$path]); + if (self::loopMisfits($names, $from, $to)) { + $warnings[] = self::loopWarning($names, $from, $to); + } + } $problem = $editor->move($page->index, $node, $position); if ($problem !== null) { return Errors::invalidInput( @@ -90,9 +99,8 @@ final class DiviMoveAbility extends DiviWriteAbility } $change = (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved'); - $names = LoopContext::enabled($node->attrs()) ? [] : LoopContext::optionsIn($node, true); - if ($names !== [] && $from->kind() !== $to->kind()) { - $change->warning(self::loopWarning($names, $from, $to)); + foreach ($warnings as $warning) { + $change->warning($warning); } return $change; diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 00909f5..0790622 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -192,21 +192,28 @@ final class DiviUpdateAbility extends DiviWriteAbility static fn (array $update): bool => $update[1] !== $update[0]->attrs() )); $change = new DiviChange(sprintf('%d Divi-Knoten geändert', count($updates))); - $before = []; + // Audit M1: besides the changed blocks, their loop-enabled descendants (an outer loop changes their chain). + $roots = []; foreach ($updates as [$block]) { - $before[spl_object_id($block)] = self::loopAt($page->index, $block); + foreach (self::loopRoots($block) as [$root]) { + $roots[spl_object_id($root)] = $root; + } } + $before = array_map(static fn (Block $root): LoopScope => self::loopAt($page->index, $root), $roots); foreach ($updates as [$block, $attrs]) { // Composer::update() output: every client value already passed the choke point. $block->setAttrs($attrs); $change->record($block, 'updated'); } - foreach ($updates as [$block]) { - $from = $before[spl_object_id($block)]; - $to = self::loopAt($page->index, $block); - $names = $from->kind() === $to->kind() ? [] : LoopContext::optionsIn($block, true); - if ($names !== []) { - $change->warning(sprintf('%s: %s', $page->index->idOf($block), self::loopWarning($names, $from, $to))); + foreach ($roots as $key => $root) { + $to = self::loopAt($page->index, $root); + $names = LoopContext::optionsIn($root, true); + if (self::loopMisfits($names, $before[$key], $to)) { + $change->warning(sprintf( + '%s: %s', + $page->index->idOf($root), + self::loopWarning($names, $before[$key], $to) + )); } } diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index a007511..37b6e29 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -128,26 +128,77 @@ abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures * @param array> $newAttrs */ protected static function loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope + { + return LoopContext::fromChain(self::ancestorAttrs($index, $block, $newAttrs)); + } + + /** + * Attributes from the root down to $block (inclusive); $newAttrs replaces those of nodes this call changes. + * + * @param array> $newAttrs + * @return list> + */ + protected static function ancestorAttrs(NodeIndex $index, ?Block $block, array $newAttrs = []): array { $chain = []; for ($node = $block; $node !== null; $node = $index->parentBlock($node)) { array_unshift($chain, $newAttrs[(string) $index->idOf($node)] ?? $node->attrs()); } - return LoopContext::fromChain($chain); + return $chain; + } + + /** + * $block and every loop-enabled descendant (audit M1: their chains change with an outer loop, and meta key + * options care), each with the attrs from $block down to it. Opaque blocks are not entered. + * + * @param list> $path + * @return list>}> + */ + protected static function loopRoots(Block $block, array $path = [], bool $top = true): array + { + $path[] = $block->attrs(); + $roots = $top || LoopContext::enabled($block->attrs()) ? [[$block, $path]] : []; + if (! $block->isOpaque()) { + foreach ($block->children() as $child) { + array_push($roots, ...self::loopRoots($child, $path, false)); + } + } + + return $roots; + } + + /** + * Whether moving or changing loop content from $from to $to needs a warning: the kind changes, or (audit M1) + * a loop option no longer fits the whole chain – meta key options are checked against every enclosing loop. + * + * @param list $names + */ + protected static function loopMisfits(array $names, LoopScope $from, LoopScope $to): bool + { + if ($names === []) { + return false; + } + + return $from->kind() !== $to->kind() + || array_filter($names, static fn (string $name): bool => ! $to->fits($name)) !== []; } /** @param list $names */ protected static function loopWarning(array $names, LoopScope $from, LoopScope $to): string { - $where = $to->active() - ? sprintf( + $where = $to->queryType() === null + ? 'jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer' + : ($to->active() ? sprintf( 'jetzt in einer Divi-Schleife vom Typ: %s (vorher: %s); passen sie nicht zu dieser Art, zeigt Divi ' . 'sie leer oder mit Daten eines anderen Objekts mit derselben ID', $to->label(), $from->active() ? $from->label() : 'keine Schleife' - ) - : 'jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer'; + ) : sprintf( + 'jetzt in einer Divi-Schleife, die dieses Werkzeug nicht unterstützt (Typ: %s); Divi kann Daten ' + . 'eines anderen Objekts zeigen', + $to->label() + )); return sprintf( 'Schleifen-Inhalte (%s) liegen %s. Zurückverschieben oder die Inhalte anpassen.', diff --git a/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php b/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php index 3199882..444f1ef 100644 --- a/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php +++ b/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php @@ -119,6 +119,37 @@ final class LoopContextTest extends TestCase self::assertSame('repeater_team', $inner->queryType()); } + /** Audit M1: a meta key option must fit EVERY enabled loop of the chain (an empty inner loop hands over to the outer). */ + public function testMetaKeyOptionsMustFitEveryLoopOfTheChain(): void + { + $posts = self::loop(['enable' => 'on', 'queryType' => 'post_types']); + $terms = self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']); + $repeater = self::loop(['enable' => 'on', 'queryType' => 'repeater_team']); + $meta = 'loop_post_meta_key_manual_custom_field'; + + $postInTerm = LoopContext::fromChain([$terms, $posts]); + self::assertSame([LoopScope::POST, LoopScope::TERM], $postInTerm->kinds()); + self::assertFalse($postInTerm->fits($meta)); + self::assertTrue($postInTerm->fits('loop_post_title'), 'other options keep the innermost rule'); + self::assertTrue(LoopContext::fromChain([$posts, $posts])->fits($meta)); + self::assertFalse(LoopContext::fromChain([$repeater, $posts])->fits($meta), 'unsupported outer loop'); + self::assertTrue(LoopContext::fromChain([self::loop([]), $posts])->fits($meta), 'a disabled loop is no loop'); + } + + /** Audit L2: Divi sanitize_key()s queryType and knows the aliases terms/users. */ + public function testQueryTypeIsNormalisedLikeSanitizeKey(): void + { + $kind = static fn (string $q): ?string => LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => $q])) + ?->kind(); + + self::assertSame(LoopScope::TERM, $kind('Post_Taxonomies')); + self::assertSame(LoopScope::TERM, $kind(' post_taxonomies ')); + self::assertSame(LoopScope::TERM, $kind('terms')); + self::assertSame(LoopScope::USER, $kind('users')); + self::assertSame(LoopScope::POST, $kind("POST_TYPES\n")); + self::assertNull($kind('repeater_team')); + } + private static function page(): NodeIndex { $loop = static fn (string $queryType): string => '{"module":{"advanced":{"loop":{"desktop":{"value":' diff --git a/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php b/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php index 645a1d2..6e50868 100644 --- a/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php +++ b/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php @@ -19,6 +19,8 @@ final class LoopScopeTest extends TestCase 'taxonomies' => ['post_taxonomies', LoopScope::TERM, 'Terme'], 'user roles' => ['user_roles', LoopScope::USER, 'Benutzer'], 'menus' => ['menus', LoopScope::MENU, 'Menü'], + 'terms alias (audit L2)' => ['terms', LoopScope::TERM, 'Terme'], + 'users alias (audit L2)' => ['users', LoopScope::USER, 'Benutzer'], ]; } @@ -50,7 +52,7 @@ final class LoopScopeTest extends TestCase */ public function testOtherQueryTypesAreALoopWithoutKind(): void { - foreach (['repeater_team', 'terms', 'users', '', 'Post_Types', 'post_types '] as $queryType) { + foreach (['repeater_team', '', 'Post_Types', 'post_types '] as $queryType) { $scope = LoopScope::ofQueryType($queryType); self::assertFalse($scope->active(), $queryType); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php b/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php index e47186b..72fab8b 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php @@ -373,4 +373,70 @@ final class DiviLoopWriteTest extends DiviWriteAbilityTestCase ]); self::assertStringContainsString('außerhalb jeder Divi-Schleife', implode(' | ', $off['warnings'])); } + + private function useChainPage(string $outerType): void + { + $meta = VariableRef::serialize('content', 'loop_post_meta_key_manual_custom_field', ['loop_meta_key' => 'telefon']); + $loop = static fn (string $type): array => self::loop(['enable' => 'on', 'queryType' => $type]); + $this->original = '' . self::block('section', $loop($outerType), self::block( + 'row', + $loop('post_types'), + self::block('column', [], self::text('

Fest

') . self::text($meta)) + )) . ''; + $this->post->post_content = $this->original; + } + + /** Audit M1: a post loop inside a term loop may be empty; the term loop would then read post meta by term ID. */ + public function testPostMetaInAPostLoopInsideATermLoopIsRefusedOnInsertAndUpdate(): void + { + $this->useChainPage('post_taxonomies'); + $meta = VariableRef::serialize('content', 'loop_post_meta_key_manual_custom_field', ['loop_meta_key' => 'telefon']); + + $insert = $this->insertInto('s0.r0.c0', ['nodes' => [['type' => 'text', 'content' => $meta]]]); + self::assertStringContainsString('passt nicht zur Schleife', self::problems($insert)); + $update = $this->changeAll([['id' => 's0.r0.c0.m0', 'set' => ['content' => $meta]]]); + self::assertStringContainsString('passt nicht zur Schleife', self::problems($update)); + self::assertSame([], $this->saved); + } + + public function testPostMetaInAPostLoopInsideAPostLoopIsAccepted(): void + { + $this->useChainPage('post_types'); + $meta = VariableRef::serialize('content', 'loop_post_meta_key_manual_custom_field', ['loop_meta_key' => 'telefon']); + + $insert = $this->insertInto('s0.r0.c0', ['nodes' => [['type' => 'text', 'content' => $meta]]]); + self::assertTrue($insert['ok'], (string) json_encode($insert)); + $update = $this->changeAll([['id' => 's0.r0.c0.m0', 'set' => ['content' => $meta]]]); + self::assertTrue($update['ok'], (string) json_encode($update)); + } + + /** Audit M1: the update warning follows the chain, not only the innermost loop. */ + public function testUpdateWarnsWhenAnOuterLoopTurnsIntoAnotherKindUnderMetaContent(): void + { + $this->useChainPage('post_types'); + + $result = $this->changeAll([ + ['id' => 's0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertStringContainsString('loop_post_meta_key_manual_custom_field', implode(' | ', $result['warnings'])); + } + + /** Audit L2: "terms" is a term loop for Divi, an unsupported target "can show data of another object". */ + public function testUpdateWarningNamesTheRealKindOfAnAliasAndNeverCallsAnUnsupportedLoopEmpty(): void + { + $alias = $this->changeAll([ + ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => ['enable' => 'on', 'queryType' => 'Terms']]]], + ]); + self::assertStringContainsString('Typ: Terme (vorher: Beiträge)', implode(' | ', $alias['warnings'])); + + $this->post->post_content = $this->original; + $other = $this->changeAll([ + ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => ['enable' => 'on', 'queryType' => 'repeater_x']]]], + ]); + $text = implode(' | ', $other['warnings']); + self::assertStringContainsString('kann Daten eines anderen Objekts zeigen', $text); + self::assertStringNotContainsString('zeigt sie dort leer', $text); + } }