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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
use WP_Term;
|
||||
|
||||
final class TemplateConditionsTest extends TestCase
|
||||
{
|
||||
/** @var list<string> Capabilities the user lacks (read_post of 2202 is always missing). */
|
||||
private array $denied = ['list_users'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) {
|
||||
1053 => new WP_Post(['ID' => 1053, 'post_type' => 'page', 'post_status' => 'draft']),
|
||||
2201 => new WP_Post(['ID' => 2201, 'post_type' => 'lit_person', 'post_status' => 'publish']),
|
||||
2202 => new WP_Post(['ID' => 2202, 'post_type' => 'page', 'post_status' => 'private']),
|
||||
2203 => new WP_Post(['ID' => 2203, 'post_type' => 'page', 'post_status' => 'trash']),
|
||||
7 => new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'publish']),
|
||||
default => null,
|
||||
});
|
||||
Functions\when('current_user_can')->alias(
|
||||
fn (string $cap, mixed ...$args): bool => ! in_array($cap, $this->denied, true)
|
||||
&& ! ($cap === 'read_post' && ($args[0] ?? 0) === 2202)
|
||||
);
|
||||
Functions\when('get_term')->alias(
|
||||
static fn (int $id, string $taxonomy): ?WP_Term => $id === 5 && $taxonomy === 'category'
|
||||
? new WP_Term(['term_id' => 5, 'slug' => 'news', 'name' => 'News'])
|
||||
: null
|
||||
);
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\when('wp_roles')->justReturn(new class {
|
||||
public function is_role(string $role): bool
|
||||
{
|
||||
return $role === 'editor';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static function conditions(): TemplateConditions
|
||||
{
|
||||
return new TemplateConditions(ThemeBuilderFixtures::store());
|
||||
}
|
||||
|
||||
public function testAcceptsDivisConditionsAndReadableObjects(): void
|
||||
{
|
||||
$checked = self::conditions()->check([
|
||||
'singular:post_type:lit_person:all',
|
||||
'homepage',
|
||||
'404',
|
||||
'singular:post_type:page:id:1053',
|
||||
'singular:post_type:lit_person:id:2201',
|
||||
'archive:taxonomy:category:term:id:5',
|
||||
'archive:user:role:editor',
|
||||
'homepage',
|
||||
], 'use_on');
|
||||
|
||||
self::assertSame([], $checked['errors']);
|
||||
self::assertSame([
|
||||
'singular:post_type:lit_person:all',
|
||||
'homepage',
|
||||
'404',
|
||||
'singular:post_type:page:id:1053',
|
||||
'singular:post_type:lit_person:id:2201',
|
||||
'archive:taxonomy:category:term:id:5',
|
||||
'archive:user:role:editor',
|
||||
], $checked['ids']);
|
||||
}
|
||||
|
||||
public function testRefusesUnknownUnreadableAndMalformedConditions(): void
|
||||
{
|
||||
$checked = self::conditions()->check([
|
||||
'singular:post_type:page:all:x',
|
||||
'singular:post_type:page:id:2202',
|
||||
'singular:post_type:page:id:2203',
|
||||
'singular:post_type:page:id:2201',
|
||||
'singular:post_type:page:id:0123',
|
||||
"homepage\n",
|
||||
'archive:user:id:1',
|
||||
'archive:taxonomy:category:term:id:6',
|
||||
'archive:user:role:hacker',
|
||||
'singular:post_type:page:id:',
|
||||
42,
|
||||
], 'use_on');
|
||||
|
||||
self::assertSame([], $checked['ids']);
|
||||
self::assertSame(
|
||||
array_map(static fn (int $i): string => 'use_on.' . $i, range(0, 10)),
|
||||
array_keys($checked['errors'])
|
||||
);
|
||||
self::assertStringContainsString('unbekannte Bedingung', $checked['errors']['use_on.0']);
|
||||
// Private and trashed look the same – no oracle over other users' entries.
|
||||
self::assertSame(
|
||||
$checked['errors']['use_on.1'],
|
||||
str_replace('2203', '2202', $checked['errors']['use_on.2'])
|
||||
);
|
||||
self::assertStringContainsString('list_users', $checked['errors']['use_on.6']);
|
||||
}
|
||||
|
||||
public function testListShapeAndSize(): void
|
||||
{
|
||||
self::assertSame(['ids' => [], 'errors' => []], self::conditions()->check(null, 'use_on'));
|
||||
self::assertArrayHasKey('use_on', self::conditions()->check('homepage', 'use_on')['errors']);
|
||||
self::assertArrayHasKey('use_on', self::conditions()->check(['a' => 'homepage'], 'use_on')['errors']);
|
||||
self::assertArrayHasKey(
|
||||
'exclude_from',
|
||||
self::conditions()->check(array_fill(0, TemplateConditions::MAX + 1, 'homepage'), 'exclude_from')['errors']
|
||||
);
|
||||
self::assertSame(
|
||||
[],
|
||||
self::conditions()->check(array_fill(0, TemplateConditions::MAX, 'homepage'), 'exclude_from')['errors']
|
||||
);
|
||||
}
|
||||
|
||||
public function testLabelsAndTheCatalog(): void
|
||||
{
|
||||
self::assertSame('Alle Personen', self::conditions()->label('singular:post_type:lit_person:all'));
|
||||
self::assertSame('Bestimmte Seiten: 1053', self::conditions()->label('singular:post_type:page:id:1053'));
|
||||
self::assertSame('Unterseiten bestimmter Seiten: 7', self::conditions()->label('singular:post_type:page:children:id:7'));
|
||||
self::assertSame('404-Seite', self::conditions()->label('404'));
|
||||
self::assertSame('unbekannt:x', self::conditions()->label('unbekannt:x'));
|
||||
self::assertSame('singular:post_type:page:id:', self::conditions()->label('singular:post_type:page:id:'));
|
||||
|
||||
$available = array_column(self::conditions()->available(), null, 'id');
|
||||
self::assertNull($available['homepage']['needs']);
|
||||
self::assertSame('post_type:page', $available['singular:post_type:page:id:']['needs']);
|
||||
self::assertSame('taxonomy:category', $available['archive:taxonomy:category:term:id:']['needs']);
|
||||
self::assertSame('user', $available['archive:user:id:']['needs']);
|
||||
self::assertSame('user_role', $available['archive:user:role:']['needs']);
|
||||
self::assertSame('404-Seite', $available['404']['label']);
|
||||
self::assertSame(array_keys(ThemeBuilderFixtures::conditions()), array_keys($available));
|
||||
}
|
||||
|
||||
/** Every ID Divi offers round-trips: exactly, or with an object the user may read. */
|
||||
public function testEveryConditionDiviOffersRoundTrips(): void
|
||||
{
|
||||
$this->denied = [];
|
||||
Functions\when('get_userdata')->alias(static fn (int $id): object|false => $id === 1 ? (object) ['ID' => 1] : false);
|
||||
$objects = [
|
||||
'post_type:page' => '1053',
|
||||
'post_type:lit_person' => '2201',
|
||||
'taxonomy:category' => '5',
|
||||
'user' => '1',
|
||||
'user_role' => 'editor',
|
||||
];
|
||||
|
||||
$ids = [];
|
||||
foreach (self::conditions()->available() as $condition) {
|
||||
$ids[] = $condition['id'] . ($condition['needs'] === null ? '' : $objects[$condition['needs']]);
|
||||
}
|
||||
$checked = self::conditions()->check($ids, 'use_on');
|
||||
|
||||
self::assertSame([], $checked['errors']);
|
||||
self::assertSame($ids, $checked['ids']);
|
||||
}
|
||||
|
||||
/** Ruling Q10: authors only with list_users, and a missing user reads like a forbidden one. */
|
||||
public function testAuthorConditionsNeedListUsers(): void
|
||||
{
|
||||
Functions\when('get_userdata')->alias(static fn (int $id): object|false => $id === 1 ? (object) ['ID' => 1] : false);
|
||||
|
||||
$forbidden = self::conditions()->check(['archive:user:id:1'], 'use_on')['errors'];
|
||||
$this->denied = [];
|
||||
$missing = self::conditions()->check(['archive:user:id:2'], 'use_on')['errors'];
|
||||
$allowed = self::conditions()->check(['archive:user:id:1'], 'use_on');
|
||||
|
||||
self::assertSame($forbidden['use_on.0'], $missing['use_on.0']);
|
||||
self::assertSame(['archive:user:id:1'], $allowed['ids']);
|
||||
}
|
||||
|
||||
/** A term the user may not assign reads like a missing one; no term name or slug in any message. */
|
||||
public function testTermsMustBeAssignable(): void
|
||||
{
|
||||
$this->denied = ['list_users', 'assign_term'];
|
||||
|
||||
$errors = self::conditions()->check(
|
||||
['archive:taxonomy:category:term:id:5', 'archive:taxonomy:category:term:id:6'],
|
||||
'use_on'
|
||||
)['errors'];
|
||||
|
||||
self::assertSame($errors['use_on.0'], str_replace('6', '5', $errors['use_on.1']));
|
||||
self::assertStringNotContainsString('News', implode(' ', $errors));
|
||||
self::assertStringNotContainsString('news', implode(' ', $errors));
|
||||
}
|
||||
|
||||
/** The object is the last segment as Divi reads it back; types a filter adds stay builder-only. */
|
||||
public function testObjectIsTheLastSegmentAndForeignTypesStayInTheBuilder(): void
|
||||
{
|
||||
$store = Mockery::mock(DiviThemeBuilder::class);
|
||||
$store->allows('flatConditions')->andReturn(ThemeBuilderFixtures::conditions() + [
|
||||
'woo:shop:id:' => ['label' => 'Shop', 'type' => 'product_set', 'value' => 'x'],
|
||||
]);
|
||||
$conditions = new TemplateConditions($store);
|
||||
|
||||
$errors = $conditions->check([
|
||||
'singular:post_type:page:id:7:1053',
|
||||
'woo:shop:id:3',
|
||||
'singular:post_type:page:id:1053 ',
|
||||
'Homepage',
|
||||
], 'exclude_from')['errors'];
|
||||
|
||||
self::assertSame(
|
||||
['exclude_from.0', 'exclude_from.1', 'exclude_from.2', 'exclude_from.3'],
|
||||
array_keys($errors)
|
||||
);
|
||||
self::assertStringContainsString('unbekannte Bedingung', $errors['exclude_from.0']);
|
||||
self::assertStringContainsString('Divi Theme Builder', $errors['exclude_from.1']);
|
||||
self::assertStringContainsString('unbekannte Bedingung', $errors['exclude_from.3']);
|
||||
}
|
||||
|
||||
/** No existence oracle: a readable post of another type reads like a missing one. */
|
||||
public function testWrongTypeReadsLikeMissing(): void
|
||||
{
|
||||
$errors = self::conditions()->check(
|
||||
['singular:post_type:lit_person:id:1053', 'singular:post_type:lit_person:id:9999'],
|
||||
'use_on'
|
||||
)['errors'];
|
||||
|
||||
self::assertSame($errors['use_on.0'], str_replace('9999', '1053', $errors['use_on.1']));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user