Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
T
janniclas.loosenandClaude Opus 5.5 6be2e65432 feat: Pods fields in Theme Builder bodies checked against the template's post types (E12)
Both forms Divi writes are recognized (ruling Q11): custom_meta_<key> and the
dialog form post_meta_key + select_meta_key. Choice fields get a note about the
stored value, relation fields name IDs (F4). Only templates the user may edit
are checked or named; others are counted (Q21 pattern). Warnings only, also in
the dry run of a live body. DynamicFields extracted from lit/divi-dynamic-sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:02:53 +02:00

232 lines
8.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
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_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
* the fields of the type usable as {{field:<key>}}.
*
* 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:<ID>}}) bzw. dem Namen für die Vollform, '
. 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} 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":"<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:<ID>}}';
}
/** 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;
}
}