feat: VariableBook als reiner Variablenbestand mit Lese-/Speicherform, Archivierung statt Löschen und Restore-Vereinigung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
5bb25e1e8f
commit
a7a385d3b3
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The global variables in Divi's stored format (formats §2): type => id => {id, label, value, order, status,
|
||||
* lastUpdated, variableType}. Takes the stored shape and the shape of GlobalData::get_global_variables() alike:
|
||||
* groups and entries may be objects, "allowedActions", the customizer fonts (--et_global_*_font), the group "colors"
|
||||
* and empty groups are dropped. The type "gradients" (Divi 5.15) is kept unchanged (ruling V6). Variables are never
|
||||
* removed, only archived (ruling V1): restoredFrom() keeps every current variable. Immutable. Pure PHP.
|
||||
*/
|
||||
final class VariableBook
|
||||
{
|
||||
/** Types in the order of Divi's stored data; VariableValue::TYPES plus the pass-through "gradients". */
|
||||
private const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts', 'gradients'];
|
||||
|
||||
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** @param array<string, array<string, array<string, mixed>>> $data */
|
||||
private function __construct(private array $data)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<array-key, mixed>|object $raw Stored data or get_global_variables(). */
|
||||
public static function fromDivi(array|object $raw): self
|
||||
{
|
||||
$raw = self::plain($raw);
|
||||
$data = [];
|
||||
foreach (self::TYPES as $type) {
|
||||
foreach (is_array($raw[$type] ?? null) ? $raw[$type] : [] as $id => $entry) {
|
||||
// Customizer fonts have IDs like "--et_global_body_font"; Divi stores only "gvid-" variables.
|
||||
if (is_array($entry) && str_starts_with((string) $id, 'gvid-')) {
|
||||
$data[$type][(string) $id] = self::normalize($type, (string) $id, $entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, array<string, mixed>>> */
|
||||
public function data(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function rev(): string
|
||||
{
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||||
$json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return Rev::of('variables' . "\0" . $json);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function entry(string $type, string $id): ?array
|
||||
{
|
||||
return $this->data[$type][$id] ?? null;
|
||||
}
|
||||
|
||||
public function typeOf(string $id): ?string
|
||||
{
|
||||
foreach ($this->data as $type => $entries) {
|
||||
if (isset($entries[$id])) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The variable $needle names: an ID, else the one variable of that label (case-insensitive, trimmed) –
|
||||
* optionally only within $type.
|
||||
*
|
||||
* @return array{type: string, id: string}|null
|
||||
* @throws InvalidArgumentException When several variables share the label (ruling S13a).
|
||||
*/
|
||||
public function find(string $needle, ?string $type = null): ?array
|
||||
{
|
||||
$key = mb_strtolower(trim($needle));
|
||||
$hits = [];
|
||||
foreach ($this->data as $kind => $entries) {
|
||||
if ($type !== null && $kind !== $type) {
|
||||
continue;
|
||||
}
|
||||
if (isset($entries[$needle])) {
|
||||
return ['type' => $kind, 'id' => $needle];
|
||||
}
|
||||
foreach ($entries as $id => $entry) {
|
||||
if ($key !== '' && mb_strtolower(trim((string) $entry['label'])) === $key) {
|
||||
$hits[] = ['type' => $kind, 'id' => (string) $id];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count($hits) > 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Name der globalen Variable "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||||
trim($needle),
|
||||
implode(', ', array_column($hits, 'id'))
|
||||
));
|
||||
}
|
||||
|
||||
return $hits[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry Added or replaced; missing keys are filled, id and variableType are set.
|
||||
* @throws InvalidArgumentException For a type Divi does not store.
|
||||
*/
|
||||
public function with(string $type, string $id, array $entry): self
|
||||
{
|
||||
if (! in_array($type, self::TYPES, true)) {
|
||||
throw new InvalidArgumentException(sprintf('Unbekannter Variablentyp "%s".', $type));
|
||||
}
|
||||
$data = $this->data;
|
||||
$data[$type][$id] = self::normalize($type, $id, $entry);
|
||||
|
||||
return new self(self::inTypeOrder($data));
|
||||
}
|
||||
|
||||
/** The order for a variable appended to $type: the highest numeric order there plus one, as a string. */
|
||||
public function nextOrder(string $type): string
|
||||
{
|
||||
$highest = 0;
|
||||
foreach ($this->data[$type] ?? [] as $entry) {
|
||||
if (is_numeric($entry['order'])) {
|
||||
$highest = max($highest, (int) $entry['order']);
|
||||
}
|
||||
}
|
||||
|
||||
return (string) ($highest + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The inventory a restore writes (ruling V1b): every variable of $snapshot, and every variable that exists only
|
||||
* here stays – archived, with order "" (Divi refuses a save that drops a stored ID).
|
||||
*/
|
||||
public function restoredFrom(self $snapshot): self
|
||||
{
|
||||
$data = $snapshot->data;
|
||||
foreach ($this->data as $type => $entries) {
|
||||
foreach ($entries as $id => $entry) {
|
||||
if (! isset($snapshot->data[$type][$id])) {
|
||||
$data[$type][$id] = array_merge($entry, ['status' => 'archived', 'order' => '']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new self(self::inTypeOrder($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* The variables restoredFrom() would archive: active ones that are not in $snapshot (already archived ones stay
|
||||
* archived and need no warning).
|
||||
*
|
||||
* @return list<array{type: string, id: string, label: string}>
|
||||
*/
|
||||
public function onlyHere(self $snapshot): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->data as $type => $entries) {
|
||||
foreach ($entries as $id => $entry) {
|
||||
if (! isset($snapshot->data[$type][$id]) && $entry['status'] !== 'archived') {
|
||||
$rows[] = ['type' => $type, 'id' => (string) $id, 'label' => (string) $entry['label']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** A new variable ID like the Visual Builder's: "gvid-" and 10 characters of [0-9a-z] (formats §2). */
|
||||
public static function newId(?Closure $random = null): string
|
||||
{
|
||||
$random ??= static function (int $length): string {
|
||||
$id = '';
|
||||
for ($i = 0; $i < $length; ++$i) {
|
||||
$id .= self::ALPHABET[random_int(0, 35)];
|
||||
}
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
return 'gvid-' . $random(10);
|
||||
}
|
||||
|
||||
/** lastUpdated of a variable written now (same format as a color's). */
|
||||
public static function now(): string
|
||||
{
|
||||
return ColorBook::now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $entry
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalize(string $type, string $id, array $entry): array
|
||||
{
|
||||
$text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null)
|
||||
? (string) $entry[$key]
|
||||
: $default;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'label' => $text('label', ''),
|
||||
'value' => $text('value', ''),
|
||||
'order' => $text('order', ''),
|
||||
'status' => $text('status', 'active'),
|
||||
'lastUpdated' => $text('lastUpdated', ''),
|
||||
'variableType' => $type,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array<string, mixed>>> $data
|
||||
* @return array<string, array<string, array<string, mixed>>>
|
||||
*/
|
||||
private static function inTypeOrder(array $data): array
|
||||
{
|
||||
$ordered = [];
|
||||
foreach (self::TYPES as $type) {
|
||||
if (isset($data[$type])) {
|
||||
$ordered[$type] = $data[$type];
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
private static function sorted(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
$value = array_map([self::class, 'sorted'], $value);
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function plain(mixed $value): mixed
|
||||
{
|
||||
if (is_object($value)) {
|
||||
$value = get_object_vars($value);
|
||||
}
|
||||
|
||||
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user