From 6be2e65432c18c0e613e716dbc28123b41899798 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 21:02:53 +0200 Subject: [PATCH] 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_ 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) --- src/Integrations/Divi/Compose/VariableRef.php | 14 ++ src/Integrations/Divi/DiviIntegration.php | 6 +- .../Divi/Pages/DiviDynamicSourcesAbility.php | 80 +------- src/Integrations/Divi/Pages/DiviWriter.php | 10 +- src/Integrations/Divi/Pages/DynamicFields.php | 176 ++++++++++++++++++ .../ThemeBuilder/TbCreateTemplateAbility.php | 11 +- .../Divi/ThemeBuilder/Template.php | 11 +- .../Divi/ThemeBuilder/ThemeBuilderEdits.php | 164 +++++++++++++++- .../ThemeBuilder/ThemeBuilderIntegration.php | 13 +- .../Divi/Compose/VariableRefTest.php | 8 + .../Divi/Pages/DiviWriterThemeBuilderTest.php | 34 +++- .../Divi/Pages/DynamicFieldsTest.php | 110 +++++++++++ .../Divi/Pages/Stage3bBodyEditTest.php | 47 +++++ .../TbCreateTemplateAbilityTest.php | 32 +++- .../ThemeBuilder/ThemeBuilderEditsTest.php | 148 +++++++++++++++ 15 files changed, 778 insertions(+), 86 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DynamicFields.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DynamicFieldsTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/Stage3bBodyEditTest.php diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 9fd7008..43f954e 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -95,6 +95,20 @@ final class VariableRef return is_array($settings) ? $settings : []; } + /** The option name of $value when it is exactly one content reference ("custom_meta_role"); null otherwise. */ + public static function nameOf(string $value): ?string + { + try { + $payload = self::payloadOf($value); + } catch (InvalidArgumentException) { + return null; + } + + $name = $payload['value']['name'] ?? null; + + return ($payload['type'] ?? null) === 'content' && is_string($name) ? $name : null; + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index d2e84fa..21d6317 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -20,6 +20,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility; +use LIT\MCP\Integrations\Divi\Pages\DynamicFields; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; @@ -178,7 +179,8 @@ final class DiviIntegration implements Integration $this->themeBuilderAccess, new DiviComposition($this->modules, $this->design), new DiviSaver(), - $this->log + $this->log, + new DynamicFields($this->schema) ); } @@ -191,7 +193,7 @@ final class DiviIntegration implements Integration $this->design, new DiviComposition($this->modules, $this->design), new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())), - new ThemeBuilderEdits($this->themeBuilderAccess) + new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)) ); } diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php index 29700f9..f428349 100644 --- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -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:}}'; } - /** - * @return array{0: list>, 1: list>} 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; - } } diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 7daab24..470678c 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -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'); diff --git a/src/Integrations/Divi/Pages/DynamicFields.php b/src/Integrations/Divi/Pages/DynamicFields.php new file mode 100644 index 0000000..73991df --- /dev/null +++ b/src/Integrations/Divi/Pages/DynamicFields.php @@ -0,0 +1,176 @@ + + * 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>, 1: list>} 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_ (the plugin's form) + * and the name post_meta_key with select_meta_key "custom_meta_" (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; + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php index bd2a2bf..6b6ae33 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php @@ -48,7 +48,8 @@ final class TbCreateTemplateAbility implements Ability private TemplateConditions $conditions, private DiviComposition $composition, private DiviSaver $saver, - private ChangeLog $log + private ChangeLog $log, + private ?ThemeBuilderEdits $edits = null ) { } @@ -286,6 +287,14 @@ final class TbCreateTemplateAbility implements Ability SaveScope::ThemeBuilderLayout ) ); + if ($area === 'body' && $this->edits !== null) { + // E12: fields of the new body against the post types of the new conditions – warnings only. + $warnings = array_merge($warnings, $this->edits->checkFields( + $layout['blocks'], + Template::postTypesOf($useOn), + 'Das neue Body-Layout' + )); + } } $themeBuilderId = $build->themeBuilder(); diff --git a/src/Integrations/Divi/ThemeBuilder/Template.php b/src/Integrations/Divi/ThemeBuilder/Template.php index 8816f94..82797d2 100644 --- a/src/Integrations/Divi/ThemeBuilder/Template.php +++ b/src/Integrations/Divi/ThemeBuilder/Template.php @@ -92,9 +92,18 @@ final class Template /** @return list Post types of its singular conditions (singular:post_type::…). */ public function postTypes(): array + { + return self::postTypesOf($this->useOn); + } + + /** + * @param list $useOn + * @return list + */ + public static function postTypesOf(array $useOn): array { $types = []; - foreach ($this->useOn as $condition) { + foreach ($useOn as $condition) { $parts = explode(':', $condition); if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') { $types[] = $parts[2]; diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php index b7f02f8..f6d967d 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php @@ -6,7 +6,10 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\Pages\DiviChange; use LIT\MCP\Integrations\Divi\Pages\DiviPage; +use LIT\MCP\Integrations\Divi\Pages\DynamicFields; use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Integrations\Divi\Pages\VisibleEdits; use WP_Error; @@ -22,12 +25,17 @@ defined('ABSPATH') || exit; * change (VisibleEdits, ruling Q2): a backup when revisions are off, refused when it could not be faithful (P9). * Layouts of disabled or detached templates save at once. The save scope sets the Divi flags and clears every * page's CSS and the Theme Builder cache. + * + * Fields in a body (E12, answer F4): the field references of the changed nodes – both forms Divi writes (ruling + * Q11) – are checked against the post types of the templates that use the body; warnings only, nothing is + * refused. Templates the user may not edit are only counted (ruling Q21 pattern). Read-only analysis. */ final class ThemeBuilderEdits { public function __construct( private ThemeBuilderAccess $access, - private VisibleEdits $visible = new VisibleEdits() + private VisibleEdits $visible = new VisibleEdits(), + private ?DynamicFields $fields = null ) { } @@ -99,6 +107,160 @@ final class ThemeBuilderEdits ); } + /** + * E12: in a body layout, the field references of the changed nodes checked against the post types of the + * templates that use the layout (their singular conditions). Only templates the user may edit are named or + * checked; others add a count (ruling Q21 pattern). Warnings only – the layout is saved anyway. + * + * @return list + */ + public function fieldWarnings(DiviPage $page, DiviChange $change): array + { + if ($this->fields === null || $page->themeBuilderArea() !== 'body') { + return []; + } + + $keys = self::keysIn($change->touched()); + if ($keys === []) { + return []; + } + + $id = (int) $page->post->ID; + $uses = static fn (Template $template): bool => $template->layouts['body']['id'] === $id; + $live = $this->access->editableLiveTemplates(); + $types = []; + foreach (array_filter($live['templates'], $uses) as $template) { + array_push($types, ...$template->postTypes()); + } + + $types = array_values(array_unique($types)); + $hidden = count(array_filter($live['hidden'], $uses)); + if ($types === [] && $hidden > 0) { + return [sprintf( + 'Body-Layout %d nutzen nur Vorlagen ohne Berechtigung (%d) oder keine mit Bedingung ' + . '„singular:post_type:…“; ob die Felder %s dort existieren, ist ungeprüft.', + $id, + $hidden, + implode(', ', $keys) + )]; + } + + $warnings = $this->checkKeys($keys, $types, sprintf('Body-Layout %d', $id)); + if ($hidden > 0) { + $warnings[] = sprintf( + 'Weitere Vorlagen ohne Berechtigung nutzen dieses Layout: %d; ob die Felder %s für deren ' + . 'Inhaltstypen passen, ist ungeprüft.', + $hidden, + implode(', ', $keys) + ); + } + + return $warnings; + } + + /** + * Field references ({{field:}} = custom_meta_, and Divi's dialog form post_meta_key with + * select_meta_key – ruling Q11) in $blocks and their descendants against $postTypes. + * + * @param list $blocks + * @param list $postTypes + * @param string $what Names the layout in the warning ("Das neue Body-Layout"). + * @return list + */ + public function checkFields(array $blocks, array $postTypes, string $what): array + { + if ($this->fields === null) { + return []; + } + + $keys = self::keysIn($blocks); + + return $keys === [] ? [] : $this->checkKeys($keys, $postTypes, $what); + } + + /** + * @param list $keys + * @param list $postTypes + * @return list + */ + private function checkKeys(array $keys, array $postTypes, string $what): array + { + if ($this->fields === null) { + return []; + } + + if ($postTypes === []) { + return [sprintf( + '%s gehört zu keiner Vorlage mit Bedingung „singular:post_type:…“; ob die Felder %s dort existieren, ' + . 'ist ungeprüft (Bedingungen mit lit/tb-assign setzen).', + $what, + implode(', ', $keys) + )]; + } + + $warnings = []; + foreach ($keys as $key) { + $problems = []; + $notes = []; + foreach ($postTypes as $type) { + $reason = $this->fields->reason($type, $key); + if ($reason !== null) { + $problems[] = $type . ': ' . $reason; + continue; + } + $note = $this->fields->note($type, $key); + if ($note !== null) { + $notes[] = $type . ': ' . $note; + } + } + if ($problems !== []) { + $warnings[] = sprintf( + 'Feld „%s“ zeigt Divi womöglich nicht als Text (%s); passende Felder mit lit/divi-dynamic-sources.', + $key, + implode('; ', array_merge($problems, $notes)) + ); + } elseif ($notes !== []) { + $warnings[] = sprintf('Feld „%s“: %s.', $key, implode('; ', $notes)); + } + } + + return $warnings; + } + + /** + * @param list $blocks + * @return list + */ + private static function keysIn(array $blocks): array + { + $keys = []; + foreach ($blocks as $block) { + self::collectKeys($block, $keys); + } + + return array_values(array_unique($keys)); + } + + /** @param list $keys */ + private static function collectKeys(Block $block, array &$keys): void + { + if ($block->isOpaque()) { + return; + } + + $attrs = $block->attrs(); + array_walk_recursive($attrs, static function (mixed $value) use (&$keys): void { + $key = is_string($value) ? DynamicFields::metaKeyOf($value) : null; + if ($key !== null) { + $keys[] = $key; + } + }); + + foreach ($block->children() as $child) { + self::collectKeys($child, $keys); + } + } + /** @param list