Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
T

403 lines
16 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\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;
}
}