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>
267 lines
9.9 KiB
PHP
267 lines
9.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Rev;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521;
|
||
* fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's
|
||
* "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10).
|
||
* rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls.
|
||
*/
|
||
final class Template
|
||
{
|
||
public const AREAS = ['header', 'body', 'footer'];
|
||
|
||
/**
|
||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||
* @param list<string> $useOn
|
||
* @param list<string> $excludeFrom
|
||
*/
|
||
public function __construct(
|
||
public readonly int $id,
|
||
public readonly string $title,
|
||
public readonly string $status,
|
||
public readonly bool $default,
|
||
public readonly bool $enabled,
|
||
public readonly array $layouts,
|
||
public readonly array $useOn,
|
||
public readonly array $excludeFrom,
|
||
public readonly bool $attached,
|
||
public readonly ?string $markedUnused
|
||
) {
|
||
}
|
||
|
||
/** @param array<string, mixed> $divi et_theme_builder_get_template() */
|
||
public static function fromDivi(array $divi, string $status, bool $attached, ?string $markedUnused): self
|
||
{
|
||
$layouts = [];
|
||
foreach (self::AREAS as $area) {
|
||
$layout = is_array($divi['layouts'][$area] ?? null) ? $divi['layouts'][$area] : [];
|
||
$layouts[$area] = ['id' => (int) ($layout['id'] ?? 0), 'enabled' => (bool) ($layout['enabled'] ?? true)];
|
||
}
|
||
|
||
return new self(
|
||
(int) ($divi['id'] ?? 0),
|
||
(string) ($divi['title'] ?? ''),
|
||
$status,
|
||
(bool) ($divi['default'] ?? false),
|
||
(bool) ($divi['enabled'] ?? false),
|
||
$layouts,
|
||
self::strings($divi['use_on'] ?? []),
|
||
self::strings($divi['exclude_from'] ?? []),
|
||
$attached,
|
||
$markedUnused
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Whether visitors can see it: listed by the live Theme Builder post and enabled. Divi renders such a
|
||
* template whatever its post status (et_theme_builder_get_template() and ThemeBuilderRequest only read
|
||
* the enabled flag), so the status does not count here – fail closed (ruling Q8/B8).
|
||
*/
|
||
public function isLive(): bool
|
||
{
|
||
return $this->attached && $this->enabled;
|
||
}
|
||
|
||
/** Whether visitors see layout $layoutId as its $area through this template. */
|
||
public function showsLayout(string $area, int $layoutId): bool
|
||
{
|
||
return $this->isLive()
|
||
&& ($this->layouts[$area]['id'] ?? 0) === $layoutId
|
||
&& ($this->layouts[$area]['enabled'] ?? false);
|
||
}
|
||
|
||
/** template_rev: changes with everything the Theme Builder tools read or write. */
|
||
public function rev(): string
|
||
{
|
||
$state = [$this->id, $this->title, $this->enabled, $this->layouts];
|
||
|
||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure value object, no WordPress.
|
||
return Rev::of((string) json_encode(
|
||
array_merge($state, [$this->useOn, $this->excludeFrom, $this->attached]),
|
||
JSON_UNESCAPED_UNICODE
|
||
));
|
||
}
|
||
|
||
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
|
||
public function postTypes(): array
|
||
{
|
||
return self::postTypesOf($this->useOn);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $useOn
|
||
* @return list<string>
|
||
*/
|
||
public static function postTypesOf(array $useOn): array
|
||
{
|
||
$types = [];
|
||
foreach ($useOn as $condition) {
|
||
$parts = explode(':', $condition);
|
||
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
|
||
$types[] = $parts[2];
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($types));
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function toArray(): array
|
||
{
|
||
return [
|
||
'id' => $this->id,
|
||
'title' => $this->title,
|
||
'default' => $this->default,
|
||
'enabled' => $this->enabled,
|
||
'live' => $this->isLive(),
|
||
'attached' => $this->attached,
|
||
'marked_unused' => $this->markedUnused,
|
||
'template_rev' => $this->rev(),
|
||
'layouts' => $this->layouts,
|
||
'use_on' => $this->useOn,
|
||
'exclude_from' => $this->excludeFrom,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Live templates other than $exceptId that share a condition of $useOn (Divi then picks one of them).
|
||
*
|
||
* @param list<Template> $live
|
||
* @param list<string> $useOn
|
||
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||
*/
|
||
public static function overlaps(array $live, int $exceptId, array $useOn): array
|
||
{
|
||
$overlaps = [];
|
||
foreach ($live as $other) {
|
||
if ($other->id === $exceptId || ! $other->isLive()) {
|
||
continue;
|
||
}
|
||
$shared = array_values(array_intersect($other->useOn, $useOn));
|
||
if ($shared !== []) {
|
||
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $shared];
|
||
}
|
||
}
|
||
|
||
return $overlaps;
|
||
}
|
||
|
||
/**
|
||
* Live templates other than $exceptId whose conditions are not identical to one of $useOn but may cover
|
||
* the same pages (ruling Q8/B9 – "kann sich überschneiden"): same family, e.g. all pages vs. one page,
|
||
* all archives vs. one category archive, posts in a category vs. any single post, the front page vs. pages.
|
||
* Deliberately generous – the dry run is the user's only preview. Identical conditions are overlaps().
|
||
*
|
||
* @param list<Template> $live
|
||
* @param list<string> $useOn
|
||
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||
*/
|
||
public static function possibleOverlaps(array $live, int $exceptId, array $useOn): array
|
||
{
|
||
$overlaps = [];
|
||
foreach ($live as $other) {
|
||
if ($other->id === $exceptId || ! $other->isLive()) {
|
||
continue;
|
||
}
|
||
$conditions = [];
|
||
foreach ($other->useOn as $theirs) {
|
||
if (in_array($theirs, $useOn, true)) {
|
||
continue; // identical: overlaps()
|
||
}
|
||
foreach ($useOn as $ours) {
|
||
if (self::mayOverlap($theirs, $ours)) {
|
||
$conditions[] = $theirs;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($conditions !== []) {
|
||
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $conditions];
|
||
}
|
||
}
|
||
|
||
return $overlaps;
|
||
}
|
||
|
||
/**
|
||
* The overlap details of a dry run (tb-assign, tb-activate) for template $exceptId with conditions $useOn:
|
||
* identical and possible overlaps with the live templates the user may edit, and only the number of
|
||
* overlapping live templates the user may not edit (ruling Q21, Q8/B11).
|
||
*
|
||
* @param array{templates: list<Template>, hidden: list<Template>} $live ThemeBuilderAccess::editableLiveTemplates()
|
||
* @param list<string> $useOn
|
||
* @return array{overlaps: list<array{id: int, title: string, conditions: list<string>}>,
|
||
* possible_overlaps: list<array{id: int, title: string, conditions: list<string>}>, hidden_overlaps: int}
|
||
*/
|
||
public static function overlapDetails(array $live, int $exceptId, array $useOn): array
|
||
{
|
||
$hidden = array_merge(
|
||
self::overlaps($live['hidden'], $exceptId, $useOn),
|
||
self::possibleOverlaps($live['hidden'], $exceptId, $useOn)
|
||
);
|
||
|
||
return [
|
||
'overlaps' => self::overlaps($live['templates'], $exceptId, $useOn),
|
||
'possible_overlaps' => self::possibleOverlaps($live['templates'], $exceptId, $useOn),
|
||
'hidden_overlaps' => count(array_unique(array_column($hidden, 'id'))),
|
||
];
|
||
}
|
||
|
||
/** The sentence for hidden_overlaps > 0 (never names them). */
|
||
public static function hiddenOverlapsNote(int $count): string
|
||
{
|
||
return $count > 0
|
||
? sprintf(' Weitere aktive Vorlagen ohne Berechtigung, die sich überschneiden können: %d.', $count)
|
||
: '';
|
||
}
|
||
|
||
private static function mayOverlap(string $a, string $b): bool
|
||
{
|
||
foreach (self::scopesOf($a) as $x) {
|
||
foreach (self::scopesOf($b) as $y) {
|
||
if ($x === $y || str_starts_with($x, $y . ':') || str_starts_with($y, $x . ':')) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>,
|
||
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
|
||
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function scopesOf(string $condition): array
|
||
{
|
||
if ($condition === 'homepage') {
|
||
return ['homepage', 'singular:post_type:page', 'archive:post_type:post'];
|
||
}
|
||
|
||
$parts = explode(':', $condition);
|
||
$second = $parts[1] ?? '';
|
||
if (($parts[0] === 'singular' && $second === 'taxonomy') || $second === 'all') {
|
||
return [$parts[0]];
|
||
}
|
||
|
||
return [implode(':', array_slice($parts, 0, in_array($second, ['post_type', 'taxonomy'], true) ? 3 : 2))];
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private static function strings(mixed $list): array
|
||
{
|
||
return array_values(array_map('strval', array_filter(is_array($list) ? $list : [], 'is_scalar')));
|
||
}
|
||
}
|