}}. * * 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 { /** 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. */ public function __construct( private ContentSchema $schema, private DynamicContentMap $map, private DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = 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.'; } public function inputSchema(): array { return [ 'type' => 'object', 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], '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']], ], 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], ]; } public function annotations(): array { return Annotations::read(); } public function isDirect(): bool { return false; } public function permission(array $input): bool|WP_Error { $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); return $type instanceof WP_Error ? $type : 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']; $sample = ($this->samplePost)($postType); $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).'; } return [ 'post_type' => $postType, 'sample_post_id' => $sample, 'sources' => $sources, 'fields' => $fields, 'omitted' => $omitted, 'notes' => $notes, ]; } private static function placeholder(string $name): ?string { $post = array_search($name, Placeholders::POST, true); if ($post !== false) { return '{{post:' . $post . '}}'; } 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; } }