From 02e7c3e8ec9d839b0c6b5dd1e002248d6bd1eb64 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 21:05:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-dynamic-sources=20mit=20post?= =?UTF-8?q?=5Fid=20+=20node=20=E2=80=93=20Schleife=20des=20Knotens=20und?= =?UTF-8?q?=20passende=20loop=5F*-Quellen=20(X6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Integrations/Divi/DiviIntegration.php | 7 +- .../Divi/Pages/DiviDynamicSourcesAbility.php | 175 +++++++++++++++++- .../Pages/DiviDynamicSourcesAbilityTest.php | 109 ++++++++++- .../Divi/Release/DiviToolFeaturesTest.php | 8 + 4 files changed, 286 insertions(+), 13 deletions(-) diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 2a83d4e..1832c64 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -276,7 +276,12 @@ final class DiviIntegration implements Integration, Bootable new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), - new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), + new DiviDynamicSourcesAbility( + $this->schema, + new DynamicContentMap(), + $this->access->roles(), + loader: $loader + ), new DiviPresetGetAbility($this->designWriter, $this->globalUsages()), new DiviPresetsSetAbility( $this->designWriter, diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php index a959d82..ba0c4a1 100644 --- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -7,11 +7,15 @@ namespace LIT\MCP\Integrations\Divi\Pages; use Closure; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; +use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\JsonMap; +use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; +use LIT\MCP\Integrations\Divi\Compose\LoopContext; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\FeatureSets; use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -22,8 +26,12 @@ defined('ABSPATH') || exit; /** * lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type * (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0; - * no loop_* – they need a node in a Divi loop, ruling X6; via DynamicContentMap::options()) with the - * placeholder that writes each, and the fields of the type usable as {{field:}}. + * no loop_* there; via DynamicContentMap::options()) with the placeholder that writes each, and + * the fields of the type usable as {{field:}}. + * + * Ruling X6: with post_id and node (path ID) the entry itself is asked instead of a sample – loaded with the + * rights of lit-divi-node-get (DiviPageLoader) – and loop_sources lists the loop_* options of the node's loop + * kind (LoopContext, DynamicContentMap::loopFields()); a node outside any loop gets a note instead. * * Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods * table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each @@ -44,13 +52,15 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures * @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none). * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"); * null without a pod. + * @param DiviPageLoader|null $loader Loads the entry of post_id; null: post_id/node unsupported. */ public function __construct( private ContentSchema $schema, private DynamicContentMap $map, private DiviRoles $roles, ?Closure $samplePost = null, - ?Closure $podStorage = null + ?Closure $podStorage = null, + private ?DiviPageLoader $loader = null ) { $this->samplePost = $samplePost ?? self::defaultSample(...); $this->fields = new DynamicFields($schema, $podStorage); @@ -71,14 +81,26 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, ' . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:}}) bzw. dem Namen für die Vollform, ' . 'und die Felder des Typs, die sich als {{field:}} eignen. Nur textartige Felder; Beziehungs-, ' - . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.'; + . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted. Mit post_id und node (Pfad-ID ' + . 'aus lit-divi-outline) zusätzlich die Schleifen-Quellen ({{loop:…}}), die an diesem Knoten passen.'; } public function inputSchema(): array { return [ 'type' => 'object', - 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], + 'properties' => [ + 'post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".'], + 'post_id' => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Eintrag dieses Typs; nur zusammen mit node.', + ], + 'node' => [ + 'type' => 'string', + 'description' => 'Pfad-ID aus lit-divi-outline, z. B. "s0.r0.c1.m0"; nur zusammen mit post_id.', + ], + ], 'required' => ['post_type'], 'additionalProperties' => false, ]; @@ -117,6 +139,28 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures 'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']), 'omitted' => $entry(['key', 'label', 'reason']), 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + 'loop' => [ + 'type' => 'object', + 'description' => 'Nur mit node: die Divi-Schleife des Knotens (query_type null = keine).', + 'properties' => [ + 'node' => ['type' => 'string'], + 'query_type' => ['type' => ['string', 'null']], + 'label' => ['type' => ['string', 'null']], + ], + 'required' => ['node', 'query_type', 'label'], + ], + 'loop_sources' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'placeholder' => ['type' => ['string', 'null']], + 'settings' => ['type' => 'object'], + ], + 'required' => ['name', 'placeholder', 'settings'], + ], + ], ], 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], ]; @@ -135,17 +179,28 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures /** * Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option * read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass. + * post_id loads that entry (PageRead, plus what the entry adds like in lit-divi-node-get). */ public function diviFeatures(array $input): array { - return [DiviFeature::DynamicSources, DiviFeature::Roles]; + $base = [DiviFeature::DynamicSources, DiviFeature::Roles]; + if (! array_key_exists('post_id', $input)) { + return $base; + } + + return FeatureSets::merge($base, [DiviFeature::PageRead], FeatureSets::ofEntry($input['post_id'], false)); } public function permission(array $input): bool|WP_Error { $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + if ($type instanceof WP_Error) { + return $type; + } - return $type instanceof WP_Error ? $type : true; + $page = $this->page($input); + + return $page instanceof WP_Error ? $page : true; } public function execute(array $input): mixed @@ -156,7 +211,17 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures } $postType = (string) $input['post_type']; - $sample = ($this->samplePost)($postType); + $page = $this->page($input); + if ($page instanceof WP_Error) { + return $page; + } + + $node = is_string($input['node'] ?? null) ? $input['node'] : ''; + if ($page !== null && $page->index->find($node) === null) { + return DiviErrors::unknownNode($node, $page->post); + } + + $sample = $page === null ? ($this->samplePost)($postType) : (int) $page->post->ID; $notes = [ 'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"",' . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.', @@ -191,7 +256,7 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; } - return [ + $result = [ 'post_type' => $postType, 'sample_post_id' => $sample, 'sources' => $sources, @@ -199,6 +264,93 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures 'omitted' => $omitted, 'notes' => $notes, ]; + + return $page === null ? $result : $this->withLoop($result, $page, $node); + } + + /** + * The entry of post_id with node checked as a path ID; null when neither is given. Both or none (X6); the entry + * is loaded with read rights (DiviPageLoader::load(…, false), like lit-divi-node-get) and must be of post_type. + * + * @param array $input + */ + private function page(array $input): DiviPage|WP_Error|null + { + $hasPost = array_key_exists('post_id', $input); + if (! $hasPost && ! array_key_exists('node', $input)) { + return null; + } + + if (! $hasPost || ! is_string($input['node'] ?? null)) { + return Errors::invalidInput('post_id und node nur zusammen angeben.', [ + $hasPost ? 'node' : 'post_id' => 'fehlt (post_id und node gehören zusammen)', + ]); + } + + if (preg_match(NodeIndex::ID_PATTERN, $input['node']) !== 1) { + return Errors::invalidInput('node ist keine Pfad-ID.', ['node' => 'z. B. "s0.r1.c0.m2"']); + } + + if ($this->loader === null) { + return Errors::unsupported( + 'Schleifen-Quellen je Knoten sind hier nicht verfügbar.', + 'lit/divi-dynamic-sources ohne post_id und node aufrufen.' + ); + } + + $page = $this->loader->load($input['post_id'], false); + if ($page instanceof WP_Error || $page->post->post_type === $input['post_type']) { + return $page; + } + + $type = (string) $page->post->post_type; + + return Errors::invalidInput( + sprintf('Eintrag %d ist vom Typ "%s", nicht "%s".', $page->post->ID, $type, $input['post_type']), + ['post_type' => sprintf('"%s" (Typ von Eintrag %d)', $type, $page->post->ID)] + ); + } + + /** + * Adds the loop of $node and the loop_* sources of its kind (X6), or the note that there is no loop context. + * + * @param array $result + * @return array + */ + private function withLoop(array $result, DiviPage $page, string $node): array + { + $scope = LoopContext::of($page->index, $node); + $kind = $scope->kind(); + $result['loop'] = [ + 'node' => $node, + 'query_type' => $scope->queryType(), + 'label' => $kind === null ? null : $scope->label(), + ]; + $result['loop_sources'] = []; + if ($kind === null) { + $result['notes'][] = sprintf( + 'Knoten %s liegt in keiner Divi-Schleife (kein Schleifen-Kontext): Schleifen-Inhalte (loop_…, ' + . '{{loop:…}}) gehen dort nicht. Eine Schleife wird im Divi-Builder (Loop Builder) eingerichtet.', + $node + ); + + return $result; + } + + foreach ($this->map->loopFields($kind, (int) $page->post->ID) as $name => $settings) { + $result['loop_sources'][] = [ + 'name' => (string) $name, + 'placeholder' => self::placeholder((string) $name), + 'settings' => JsonMap::of($settings), + ]; + } + $result['notes'][] = sprintf( + 'Schleifen-Quellen gelten nur in dieser Schleife (Typ: %s) – in diesem Knoten und darunter, bis eine ' + . 'innere Schleife beginnt.', + $scope->label() + ); + + return $result; } private static function placeholder(string $name): ?string @@ -208,6 +360,11 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures return '{{post:' . $post . '}}'; } + $loop = array_search($name, Placeholders::LOOP, true); + if ($loop !== false) { + return '{{loop:' . $loop . '}}'; + } + if (! str_starts_with($name, DynamicContentRules::LINK_PREFIX)) { return null; } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php index 8eabe10..66305b0 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition; @@ -16,6 +17,9 @@ use Mockery; final class DiviDynamicSourcesAbilityTest extends TestCase { + /** @var list Post IDs Divi's registry was asked for. */ + private array $registryCalls = []; + protected function setUp(): void { parent::setUp(); @@ -51,19 +55,118 @@ final class DiviDynamicSourcesAbilityTest extends TestCase private function ability( array $deniedRoles = [], ?string $storage = 'meta', - ?array $registry = null + ?array $registry = null, + ?DiviPageLoader $loader = null ): DiviDynamicSourcesAbility { $registry ??= DiviFixtures::dynamicContentRegistry(); return new DiviDynamicSourcesAbility( $this->schema(), - new DynamicContentMap(static fn (int $id): array => $registry), + new DynamicContentMap(function (int $id) use ($registry): array { + $this->registryCalls[] = $id; + + return $registry; + }), LibraryFixtures::roles($deniedRoles), static fn (string $postType): int => 831, - static fn (string $postType): ?string => $storage + static fn (string $postType): ?string => $storage, + $loader ); } + /** + * Ruling X6: the ability with a loader over post 7 – s0.r0.c0 runs a term loop, s0.r0.c1 none – and a + * registry with one post and one term loop option (Divi 5.15.1 groups and fields). + */ + private function withLoopPage(): DiviDynamicSourcesAbility + { + $loop = ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => [ + 'enable' => 'on', 'queryType' => 'post_taxonomies', + ]]]]]]; + $text = ''; + $column = static fn (array $attrs): string => '' . $text . ''; + DiviFixtures::givenPost( + '' . $column($loop) . $column([]) + . '' + ); + $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => '']; + $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')]; + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'loop_post_date' => ['label' => 'Datum', 'group' => 'Loop', 'fields' => $common + [ + 'date_format' => $field('select'), + ]], + 'loop_term_name' => ['label' => 'Name', 'group' => 'Loop Terms', 'fields' => $common], + ]; + + return $this->ability([], 'meta', $registry, new DiviPageLoader(DiviFixtures::schema())); + } + + /** Ruling X6: post_id + node add the loop sources of the node's loop kind, read from that entry. */ + public function testANodeInALoopAddsTheLoopSourcesOfItsKind(): void + { + $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c0.m0']); + + self::assertSame(7, $result['sample_post_id'], 'the entry itself, not a sample'); + self::assertSame([7], array_values(array_unique($this->registryCalls))); + self::assertSame( + ['node' => 's0.r0.c0.m0', 'query_type' => 'post_taxonomies', 'label' => 'Terme'], + $result['loop'] + ); + // The fixture registry also has the meta key option of each kind: only the term one fits, no short form. + self::assertSame( + ['loop_term_name', 'loop_term_meta_key_manual_custom_field'], + array_column($result['loop_sources'], 'name') + ); + self::assertSame('{{loop:term_name}}', $result['loop_sources'][0]['placeholder']); + self::assertNull($result['loop_sources'][1]['placeholder']); + self::assertEquals( + ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'], + (array) $result['loop_sources'][0]['settings'] + ); + self::assertSame([], array_filter( + array_column($result['sources'], 'name'), + static fn (string $name): bool => str_starts_with($name, 'loop_') + )); + } + + public function testANodeOutsideALoopGetsNoLoopSources(): void + { + $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c1.m0']); + + self::assertSame(['node' => 's0.r0.c1.m0', 'query_type' => null, 'label' => null], $result['loop']); + self::assertSame([], $result['loop_sources']); + self::assertStringContainsString('kein Schleifen-Kontext', implode(' ', $result['notes'])); + } + + public function testWithoutANodeTheAnswerHasNoLoopPart(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertArrayNotHasKey('loop', $result); + self::assertArrayNotHasKey('loop_sources', $result); + } + + public function testPostIdAndNodeComeTogetherAndNeedTheEntry(): void + { + $ability = $this->withLoopPage(); + + foreach ([['post_id' => 7], ['node' => 's0']] as $half) { + self::assertToolError($ability->permission(['post_type' => 'page'] + $half), 'validation_failed'); + self::assertToolError($ability->execute(['post_type' => 'page'] + $half), 'validation_failed'); + } + $node = ['post_type' => 'page', 'post_id' => 7, 'node' => 's0']; + self::assertToolError($ability->execute(['node' => 's9'] + $node), 'not_found'); + self::assertToolError($ability->execute(['node' => 'x'] + $node), 'validation_failed'); + self::assertToolError($ability->execute(['post_type' => 'post'] + $node), 'validation_failed'); + + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post'); + self::assertToolError($ability->permission($node), 'forbidden'); + self::assertToolError($ability->execute($node), 'forbidden'); + self::assertSame([], $this->registryCalls, 'nothing is read for a refused entry'); + } + public function testListsDiviSourcesWithTheirPlaceholders(): void { $result = $this->ability()->execute(['post_type' => 'page']); diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php index 55c525c..b778d22 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php @@ -203,6 +203,14 @@ final class DiviToolFeaturesTest extends TestCase 'lit/divi-design-restore', ['snapshot_id' => 'theme_options-20261016T100000Z-aaaaaa'], ['theme-option-read', 'theme-option-write'], ], + 'dynamic-sources of a node' => [ + 'lit/divi-dynamic-sources', ['post_type' => 'page', 'post_id' => $page, 'node' => 's0'], + ['dynamic-sources', 'roles', 'page-read'], + ], + 'dynamic-sources of a node in a layout' => [ + 'lit/divi-dynamic-sources', ['post_type' => 'et_body_layout', 'post_id' => $layout, 'node' => 's0'], + ['dynamic-sources', 'roles', 'page-read'], + ], 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], 'library-list of one entry' => [ 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],