}}. * * 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 * left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor * named. The field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25). */ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures { /** Candidates the default sample lookup checks for edit_post. */ private const SAMPLE_CANDIDATES = 10; /** @var Closure(string): int */ private Closure $samplePost; private DynamicFields $fields; /** * @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, private ?DiviPageLoader $loader = null ) { $this->samplePost = $samplePost ?? self::defaultSample(...); $this->fields = new DynamicFields($schema, $podStorage); } public function name(): string { return 'lit/divi-dynamic-sources'; } public function label(): string { return 'Dynamic-Content-Quellen'; } public function description(): string { 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. 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".'], '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, ]; } public function outputSchema(): ?array { $entry = static fn (array $properties): array => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => array_fill_keys($properties, ['type' => 'string']), 'required' => $properties, ], ]; return [ 'type' => 'object', 'properties' => [ 'post_type' => ['type' => 'string'], 'sample_post_id' => ['type' => 'integer'], 'sources' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string'], 'label' => ['type' => 'string'], 'group' => ['type' => 'string'], 'placeholder' => ['type' => ['string', 'null']], 'settings' => ['type' => 'object'], ], 'required' => ['name', 'label', 'group', 'placeholder', 'settings'], ], ], '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'], ]; } public function annotations(): array { return Annotations::read(); } public function isDirect(): bool { return false; } /** * 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 { $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; } $page = $this->page($input); return $page instanceof WP_Error ? $page : true; } public function execute(array $input): mixed { $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); if ($type instanceof WP_Error) { return $type; } $postType = (string) $input['post_type']; $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.', ]; $options = $this->map->options($sample); if ($options === null) { $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.'; $options = []; foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) { $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings]; } } $sources = []; foreach ($options as $name => $option) { $sources[] = [ 'name' => (string) $name, 'label' => $option['label'], 'group' => $option['group'], 'placeholder' => self::placeholder((string) $name), 'settings' => JsonMap::of($option['settings']), ]; } $fields = []; $omitted = []; if ($this->roles->allows('read_dynamic_content_custom_fields')) { [$fields, $omitted] = $this->fields->offered($postType); } else { $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte ' . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; } $result = [ 'post_type' => $postType, 'sample_post_id' => $sample, 'sources' => $sources, 'fields' => $fields, '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) { if (! $scope->fits((string) $name)) { continue; } $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 { $post = array_search($name, Placeholders::POST, true); if ($post !== false) { 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; } // Review M-9/P10: {{link:}} and the full form refuse attachments; internal types ("_…") are no link targets. $type = substr($name, strlen(DynamicContentRules::LINK_PREFIX)); return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:}}'; } /** The most recently modified entry of the type the current user may edit, else 0. */ private static function defaultSample(string $postType): int { $ids = get_posts([ 'post_type' => $postType, 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], 'posts_per_page' => self::SAMPLE_CANDIDATES, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ]); foreach (is_array($ids) ? $ids : [] as $id) { if (current_user_can('edit_post', (int) $id)) { return (int) $id; } } return 0; } }