feat: Theme Builder conditions only from Divi's list, objects readable

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:55:44 +02:00
co-authored by Claude Opus 5.5
parent b1dd35abd8
commit ac69fdf113
2 changed files with 424 additions and 0 deletions
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
use WP_Post;
use WP_Term;
defined('ABSPATH') || exit;
/**
* Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list
* (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry
* plus one object segment, split the way Divi reads it back (et_theme_builder_load_template_setting_options():
* the last segment is the object). The object must be readable by the user (ruling P10: PostIdParser): a post
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here.
*/
final class TemplateConditions
{
public const MAX = 50;
private const SEPARATOR = ':';
private const DEAD = ['trash', 'auto-draft', 'inherit'];
/** @var array<string, array{label: string, type: ?string, value: ?string}>|null */
private ?array $flat = null;
public function __construct(private DiviThemeBuilder $store)
{
}
/**
* Checks a client's condition list for field $field: null = no conditions; errors are keyed "<field>"
* (the list itself) or "<field>.<index>". Duplicates are dropped.
*
* @return array{ids: list<string>, errors: array<string, string>}
*/
public function check(mixed $conditions, string $field): array
{
if ($conditions === null) {
return ['ids' => [], 'errors' => []];
}
if (! is_array($conditions) || ! array_is_list($conditions)) {
return ['ids' => [], 'errors' => [$field => 'Liste von Bedingungs-IDs erwartet']];
}
if (count($conditions) > self::MAX) {
return ['ids' => [], 'errors' => [$field => sprintf('höchstens %d Bedingungen', self::MAX)]];
}
$ids = [];
$errors = [];
foreach ($conditions as $index => $condition) {
$problem = is_string($condition) ? $this->problem($condition) : 'Bedingungs-ID als Text erwartet';
if ($problem === null) {
$ids[] = (string) $condition;
} else {
$errors[$field . '.' . $index] = $problem;
}
}
return ['ids' => array_values(array_unique($ids)), 'errors' => $errors];
}
/** Divi's label, for an ID with object "<label>: <object>" (the object as stored, never its title). */
public function label(string $id): string
{
$flat = $this->flat();
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
return $flat[$id]['label'];
}
$split = $this->split($id);
return $split === null ? $id : sprintf('%s: %s', $flat[$split[0]]['label'], $split[1]);
}
/**
* Divi's conditions for lit/tb-list: "needs" names what an ID ending in ":" takes, e.g. "post_type:page".
*
* @return list<array{id: string, label: string, needs: ?string}>
*/
public function available(): array
{
$list = [];
foreach ($this->flat() as $id => $setting) {
$id = (string) $id;
$list[] = [
'id' => $id,
'label' => $setting['label'],
'needs' => str_ends_with($id, self::SEPARATOR)
? trim(($setting['type'] ?? '') . self::SEPARATOR . ($setting['value'] ?? ''), self::SEPARATOR)
: null,
];
}
return $list;
}
private function problem(string $id): ?string
{
if (preg_match('/^[A-Za-z0-9_:\-]{1,200}$/D', $id) !== 1) {
return 'ungültige Bedingungs-ID (erlaubt: Buchstaben, Ziffern, „_“, „-“ und „:“)';
}
$flat = $this->flat();
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
return null;
}
if (isset($flat[$id])) {
return sprintf('Bedingung %s braucht ein Objekt am Ende (siehe „needs“ in lit/tb-list)', $id);
}
$split = $this->split($id);
if ($split === null) {
return 'unbekannte Bedingung (verfügbare: lit/tb-list mit conditions: true)';
}
return $this->objectProblem($flat[$split[0]], $split[1]);
}
/**
* [Divi's parent ID ending in ":", object] as Divi reads the stored ID back (last segment = object),
* or null when that parent is not in Divi's list or the object is empty.
*
* @return array{string, string}|null
*/
private function split(string $id): ?array
{
$cut = strrpos($id, self::SEPARATOR);
if ($cut === false || $cut === strlen($id) - 1) {
return null;
}
$parent = substr($id, 0, $cut + 1);
return isset($this->flat()[$parent]) ? [$parent, substr($id, $cut + 1)] : null;
}
/** @param array{label: string, type: ?string, value: ?string} $setting */
private function objectProblem(array $setting, string $object): ?string
{
$value = (string) $setting['value'];
$id = PostIdParser::parse($object);
if ($setting['type'] === 'post_type') {
return $this->postProblem($value, $id, $object);
}
if ($setting['type'] === 'taxonomy') {
$valid = $id !== null && get_term($id, $value) instanceof WP_Term && current_user_can('assign_term', $id);
return $valid
? null
: sprintf('Begriff %s gibt es in „%s“ nicht oder er ist nicht zuweisbar', $object, $value);
}
if ($setting['type'] === 'user') {
$valid = $id !== null && current_user_can('list_users') && get_userdata($id) !== false;
return $valid ? null : 'Autoren-Bedingungen nur mit dem Recht list_users und einer vorhandenen Benutzer-ID';
}
if ($setting['type'] === 'user_role') {
return wp_roles()->is_role($object) ? null : sprintf('Rolle „%s“ gibt es nicht', $object);
}
return 'diese Bedingung nur im Divi Theme Builder setzen';
}
private function postProblem(string $postType, ?int $id, string $object): ?string
{
$post = $id === null ? null : get_post($id);
$valid = $post instanceof WP_Post
&& (string) $post->post_type === $postType
&& ! in_array((string) $post->post_status, self::DEAD, true)
&& current_user_can('read_post', $id);
return $valid
? null
: sprintf('Eintrag %s vom Typ „%s“ gibt es nicht oder er ist nicht lesbar', $object, $postType);
}
/** @return array<string, array{label: string, type: ?string, value: ?string}> */
private function flat(): array
{
return $this->flat ??= $this->store->flatConditions();
}
}