Files
WP-LIT-MCP/src/Integrations/Divi/ThemeBuilder/Template.php
T
2026-09-30 01:25:39 +02:00

268 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
* 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,
'status' => $this->status,
'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): 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')));
}
}