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>
This commit is contained in:
@@ -13,8 +13,6 @@ 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 LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -27,24 +25,18 @@ defined('ABSPATH') || exit;
|
||||
*
|
||||
* 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. 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).
|
||||
* 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
|
||||
{
|
||||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||||
private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||||
|
||||
private const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
/** Candidates the default sample lookup checks for edit_post. */
|
||||
private const SAMPLE_CANDIDATES = 10;
|
||||
|
||||
/** @var Closure(string): int */
|
||||
private Closure $samplePost;
|
||||
|
||||
/** @var Closure(string): ?string */
|
||||
private Closure $podStorage;
|
||||
private DynamicFields $fields;
|
||||
|
||||
/**
|
||||
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
|
||||
@@ -59,7 +51,7 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
?Closure $podStorage = null
|
||||
) {
|
||||
$this->samplePost = $samplePost ?? self::defaultSample(...);
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
$this->fields = new DynamicFields($schema, $podStorage);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -182,7 +174,7 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
|
||||
[$fields, $omitted] = $this->fields($postType);
|
||||
[$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).';
|
||||
@@ -215,53 +207,6 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:<ID>}}';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
|
||||
*/
|
||||
private function fields(string $postType): array
|
||||
{
|
||||
$storage = ($this->podStorage)($postType);
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
foreach ($this->schema->fields($postType) as $field) {
|
||||
if ($field->status === FieldDefinition::HIDDEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||||
$reason = self::omission($field, $storage);
|
||||
if ($reason !== null) {
|
||||
$omitted[] = $entry + ['reason' => $reason];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = $entry + [
|
||||
'type' => $field->type,
|
||||
'source' => $field->source,
|
||||
'placeholder' => '{{field:' . $field->key . '}}',
|
||||
];
|
||||
}
|
||||
|
||||
return [$fields, $omitted];
|
||||
}
|
||||
|
||||
private static function omission(FieldDefinition $field, ?string $storage): ?string
|
||||
{
|
||||
return match (true) {
|
||||
! DynamicContentRules::isMetaKey($field->key)
|
||||
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||||
$field->source === 'pods' && $storage === 'table'
|
||||
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||||
in_array($field->type, self::ID_TYPES, true)
|
||||
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||||
$field->type === 'unknown'
|
||||
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
|
||||
! in_array($field->type, self::TEXT_TYPES, true)
|
||||
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The most recently modified entry of the type the current user may edit, else 0. */
|
||||
private static function defaultSample(string $postType): int
|
||||
{
|
||||
@@ -283,19 +228,4 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static function defaultStorage(string $postType): ?string
|
||||
{
|
||||
if (! function_exists('pods_api')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,9 +211,17 @@ final class DiviWriter
|
||||
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
|
||||
}
|
||||
|
||||
// E12: fields in a body checked against the templates' post types – warnings only, also in the dry run.
|
||||
$fieldWarnings = $this->themeBuilder->fieldWarnings($page, $change);
|
||||
foreach ($fieldWarnings as $warning) {
|
||||
$change->warning($warning);
|
||||
}
|
||||
|
||||
$prepared = $this->themeBuilder->prepare($page, $input);
|
||||
if ($prepared instanceof WP_Error) {
|
||||
return $prepared;
|
||||
return $fieldWarnings !== [] && Errors::codeOf($prepared) === 'confirm_required'
|
||||
? VisibleEdits::merged($prepared, 'Hinweise zu Feldern siehe warnings.', ['warnings' => $fieldWarnings])
|
||||
: $prepared;
|
||||
}
|
||||
|
||||
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The fields of a post type Divi can show as text through dynamic content (answer F4, E12): custom_meta_<key>
|
||||
* reads post meta (F25/F26). Relation/file fields show IDs, Pods table storage is no post meta, protected keys
|
||||
* never (S12), unknown types not (ruling P9) – each omitted with its reason. Choice fields are offered, but
|
||||
* Divi shows their stored value, not the label (stage3b render: "senior") – note(). Hidden fields (security
|
||||
* audit M4) are neither offered nor named. Used by lit/divi-dynamic-sources and the Theme Builder body check.
|
||||
* Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class DynamicFields
|
||||
{
|
||||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||||
public const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||||
|
||||
public const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
public const UNKNOWN = 'kein bekanntes Feld dieses Inhaltstyps';
|
||||
|
||||
private const PREFIX = 'custom_meta_';
|
||||
|
||||
/** Divi's option for a meta key typed in by hand (the key is in settings.meta_key). */
|
||||
private const MANUAL = 'custom_meta_manual_custom_field_value';
|
||||
|
||||
/** @var Closure(string): ?string */
|
||||
private Closure $podStorage;
|
||||
|
||||
/** @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null: none. */
|
||||
public function __construct(private ContentSchema $schema, ?Closure $podStorage = null)
|
||||
{
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
}
|
||||
|
||||
/** @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields. */
|
||||
public function offered(string $postType): array
|
||||
{
|
||||
$storage = ($this->podStorage)($postType);
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
foreach ($this->schema->fields($postType) as $field) {
|
||||
if ($field->status === FieldDefinition::HIDDEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||||
$reason = self::omission($field, $storage);
|
||||
if ($reason !== null) {
|
||||
$omitted[] = $entry + ['reason' => $reason];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = $entry + [
|
||||
'type' => $field->type,
|
||||
'source' => $field->source,
|
||||
'placeholder' => '{{field:' . $field->key . '}}',
|
||||
];
|
||||
}
|
||||
|
||||
return [$fields, $omitted];
|
||||
}
|
||||
|
||||
/** Why {{field:$key}} would not show as text for $postType; null when it does. */
|
||||
public function reason(string $postType, string $key): ?string
|
||||
{
|
||||
[$fields, $omitted] = $this->offered($postType);
|
||||
foreach ($fields as $field) {
|
||||
if ($field['key'] === $key) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
foreach ($omitted as $field) {
|
||||
if ($field['key'] === $key) {
|
||||
return $field['reason'];
|
||||
}
|
||||
}
|
||||
|
||||
return self::UNKNOWN;
|
||||
}
|
||||
|
||||
/** What to know about an offered field of $postType ($key) that shows other text than expected; else null. */
|
||||
public function note(string $postType, string $key): ?string
|
||||
{
|
||||
[$fields] = $this->offered($postType);
|
||||
foreach ($fields as $field) {
|
||||
if ($field['key'] === $key && $field['type'] === 'choice') {
|
||||
return 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta key the content reference $value reads, in both forms Divi resolves (ruling Q11, stage3b README
|
||||
* finding 12; DynamicContentOptionPostMetaKey.php:222-262): the name custom_meta_<key> (the plugin's form)
|
||||
* and the name post_meta_key with select_meta_key "custom_meta_<key>" (Divi's dialog), each also with a key
|
||||
* typed in by hand (custom_meta_manual_custom_field_value or post_meta_key with only meta_key). Null for
|
||||
* anything else. Reads, never validates – writing goes through VariableRef::canonical() (S29/S30).
|
||||
*/
|
||||
public static function metaKeyOf(string $value): ?string
|
||||
{
|
||||
if (VariableRef::typeOf($value) !== 'content') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = VariableRef::nameOf($value);
|
||||
$settings = VariableRef::settingsOf($value);
|
||||
$manual = is_string($settings['meta_key'] ?? null) ? $settings['meta_key'] : '';
|
||||
if ($name === 'post_meta_key') {
|
||||
$selected = $settings['select_meta_key'] ?? '';
|
||||
if (! is_string($selected)) {
|
||||
return null;
|
||||
}
|
||||
if ($selected === '') {
|
||||
return $manual !== '' ? $manual : null;
|
||||
}
|
||||
$name = $selected;
|
||||
}
|
||||
|
||||
if ($name === null || ! str_starts_with($name, self::PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
if ($name === self::MANUAL) {
|
||||
return $manual !== '' ? $manual : null;
|
||||
}
|
||||
|
||||
$key = str_replace(self::PREFIX, '', $name);
|
||||
|
||||
return $key !== '' ? $key : null;
|
||||
}
|
||||
|
||||
private static function omission(FieldDefinition $field, ?string $storage): ?string
|
||||
{
|
||||
return match (true) {
|
||||
! DynamicContentRules::isMetaKey($field->key)
|
||||
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||||
$field->source === 'pods' && $storage === 'table'
|
||||
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||||
in_array($field->type, self::ID_TYPES, true)
|
||||
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||||
$field->type === 'unknown'
|
||||
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
|
||||
! in_array($field->type, self::TEXT_TYPES, true)
|
||||
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function defaultStorage(string $postType): ?string
|
||||
{
|
||||
if (! function_exists('pods_api')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user