Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
403 lines
16 KiB
PHP
403 lines
16 KiB
PHP
<?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\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;
|
||
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_* there; via DynamicContentMap::options()) with the placeholder that writes each, and
|
||
* the fields of the type usable as {{field:<key>}}.
|
||
*
|
||
* 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:<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. 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":"<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<string, mixed> $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<string, mixed> $result
|
||
* @return array<string, mixed>
|
||
*/
|
||
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:<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;
|
||
}
|
||
}
|