feat: Fähigkeiten color-write/variable-write; GlobalColorsAdapter und GlobalVariablesAdapter (Manifest, Signatur-Referenzen) und DesignRights::colors()/variables()

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 13:55:09 +02:00
co-authored by Claude Sonnet 5.5
parent d73144ab36
commit 9b7c3c1d3b
12 changed files with 537 additions and 19 deletions
+39 -7
View File
@@ -11,9 +11,10 @@ use WP_Error;
defined('ABSPATH') || exit;
/**
* Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"). Without unfiltered_html a
* write is refused while presets it saves along hold HTML/CSS that Divi would strip (final review I-2).
* Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right
* "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables
* also the role right "Variables Manager". Without unfiltered_html a write is refused while presets it saves along
* hold HTML/CSS that Divi would strip (final review I-2).
*/
final class DesignRights
{
@@ -48,15 +49,46 @@ final class DesignRights
}
public static function presets(DesignSystemWriter $writer): ?WP_Error
{
return self::designer($writer, 'Presets');
}
/** Colors: the same rights as presets (spec §10 – stricter than Divi, which asks edit_theme_options only). */
public static function colors(DesignSystemWriter $writer): ?WP_Error
{
return self::designer($writer, 'Farben');
}
/** Variables: also Divi's role right "Variables Manager" – without it Divi silently saves nothing (plan 4c V7). */
public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error
{
$refusal = self::designer($writer, 'Variablen');
if ($refusal !== null || $variables->canManage()) {
return $refusal;
}
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine '
. 'Variablen.',
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
);
}
/** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */
private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error
{
if (! current_user_can('edit_theme_options')) {
return Errors::forbidden(
'Presets ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).'
);
return Errors::forbidden(sprintf(
'%s ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).',
$what
));
}
if (! $writer->canUseVisualBuilder()) {
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.',
sprintf(
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.',
$what
),
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
);
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for the global colors (formats §1, plan 4c V3/V4): reads them through
* GlobalData::get_global_colors() and writes them through set_global_colors(). The five customizer colors live in
* Divi's theme options; get_customizer_colors() caches them in a static variable for the whole request, so read()
* lays the stored option values over Divi's answer – a read-back after writing sees the new value. Not final so the
* color area can be tested with a mock.
*/
class GlobalColorsAdapter implements DiviAdapter
{
/** Divi's theme options (one array option) with the customizer colors. */
private const OPTIONS = 'et_divi';
/** Customizer color ID => its option in et_divi (GlobalData::$customizer_colors). */
private const CUSTOMIZER = [
'gcid-primary-color' => 'accent_color',
'gcid-secondary-color' => 'secondary_accent_color',
'gcid-heading-color' => 'header_color',
'gcid-body-color' => 'font_color',
'gcid-link-color' => 'link_color',
];
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call(GlobalData::class . '::get_global_colors', 0),
new Call(GlobalData::class . '::sanitize_global_colors_data', 1),
new Call(GlobalData::class . '::set_global_colors', 2),
],
keys: [self::OPTIONS],
features: [DiviFeature::ColorWrite]
);
}
/** @return array<string, array<string, mixed>> get_global_colors() with the customizer values of et_divi. */
public function read(): array
{
$colors = GlobalData::get_global_colors();
$options = get_option(self::OPTIONS);
foreach (self::CUSTOMIZER as $id => $option) {
$value = is_array($options) ? ($options[$option] ?? null) : null;
if (is_array($colors[$id] ?? null) && is_string($value) && $value !== '') {
$colors[$id]['color'] = $value;
}
}
return $colors;
}
/**
* Saves checked entries: $replace false merges them into the stored colors (set_global_colors with
* "already sanitized"), $replace true replaces all stored colors (restore). Customizer colors among them go to
* the theme options (Divi does that itself).
*
* @param array<string, array<string, mixed>> $entries
* @throws DiviLocked When color-write is not released – before anything is written.
* @throws RuntimeException When Divi's sanitizing drops an entry; nothing is written then.
*/
public function save(array $entries, bool $replace): void
{
ReleaseGate::assertFeature(DiviFeature::ColorWrite);
$clean = GlobalData::sanitize_global_colors_data($entries);
$dropped = array_diff(array_keys($entries), array_keys($clean));
if ($dropped !== []) {
throw new RuntimeException('global colors refused: Divi dropped ' . implode(', ', $dropped));
}
GlobalData::set_global_colors($clean, ! $replace);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for the global variables (formats §2, plan 4c V7): reads them through
* GlobalData::get_global_variables() back into the stored form and writes them through set_global_variables() with
* the inventory the change was planned on (Divi's own stale check). Without edit_theme_options or Divi's role right
* "Variables Manager" Divi silently saves nothing – DesignRights::variables() asks canManage() first. Not final so
* the variable area can be tested with a mock.
*/
class GlobalVariablesAdapter implements DiviAdapter
{
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call(GlobalData::class . '::get_global_variables', 0),
new Call(GlobalData::class . '::set_global_variables', 2),
new Call('et_pb_is_allowed', 1),
],
features: [DiviFeature::VariableWrite]
);
}
/**
* The stored form: type => ID => entry as arrays, without allowedActions, without the customizer fonts and the
* group "colors" (both are not stored there), empty groups left out.
*
* @return array<string, array<string, array<string, mixed>>>
*/
public function read(): array
{
$data = [];
foreach (GlobalData::get_global_variables() as $type => $items) {
if ($type === 'colors' || (! is_array($items) && ! is_object($items))) {
continue;
}
foreach ((array) $items as $id => $item) {
// Divi's sanitizing keeps gvid- IDs only; the customizer fonts (--et_global_*_font) are not stored.
if (! str_starts_with((string) $id, 'gvid-') || (! is_array($item) && ! is_object($item))) {
continue;
}
$entry = (array) $item;
unset($entry['allowedActions']);
$data[(string) $type][(string) $id] = $entry;
}
}
return $data;
}
/**
* @param array<string, array<string, array<string, mixed>>> $data The whole new inventory (stored form).
* @param array<string, array<string, array<string, mixed>>> $expected The inventory the change was planned on.
* @throws DiviLocked When variable-write is not released – before anything is written.
* @throws RuntimeException When Divi refuses the inventory (stale, missing IDs, invalid entries).
*/
public function save(array $data, array $expected): void
{
ReleaseGate::assertFeature(DiviFeature::VariableWrite);
$refusal = GlobalData::set_global_variables($data, $expected);
if ($refusal instanceof WP_Error) {
throw new RuntimeException('global variables refused: ' . $refusal->get_error_code() . ' '
. $refusal->get_error_message());
}
}
/** Divi's role right "Variables Manager"; fail closed without Divi. */
public function canManage(): bool
{
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('variables_manager');
}
}
@@ -6,6 +6,8 @@ namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
@@ -27,6 +29,8 @@ final class DiviAdapters
DiviThemeBuilder::class,
DesignSystemReader::class,
DesignSystemWriter::class,
GlobalColorsAdapter::class,
GlobalVariablesAdapter::class,
DiviModules::class,
DynamicContentMap::class,
];
@@ -21,17 +21,20 @@ enum DiviFeature: string
case DynamicSources = 'dynamic-sources';
case DesignRead = 'design-read';
case PresetWrite = 'preset-write';
case ColorWrite = 'color-write';
case VariableWrite = 'variable-write';
case LibraryRead = 'library-read';
case LibraryWrite = 'library-write';
case Roles = 'roles';
case ThemeBuilderRead = 'tb-read';
case ThemeBuilderWrite = 'tb-write';
/** Whether the feature changes data (the four *Write cases). */
/** Whether the feature changes data (the six *Write cases). */
public function isWrite(): bool
{
return match ($this) {
self::PageWrite, self::PresetWrite, self::LibraryWrite, self::ThemeBuilderWrite => true,
self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite,
self::ThemeBuilderWrite => true,
default => false,
};
}
@@ -46,6 +49,8 @@ enum DiviFeature: string
self::DynamicSources => 'Dynamische Inhalte',
self::DesignRead => 'Designsystem lesen',
self::PresetWrite => 'Presets schreiben',
self::ColorWrite => 'Farben schreiben',
self::VariableWrite => 'Variablen schreiben',
self::LibraryRead => 'Bibliothek lesen',
self::LibraryWrite => 'Bibliothek schreiben',
self::Roles => 'Divi-Rollen',
@@ -6,6 +6,9 @@
"sig:ET\\Builder\\Packages\\Conversion\\Conversion::get_preset_attrs_mapping": "b5c3403666ff92be1dd7bfa6e6589c32f6662b37282f2fd40922f8d50151745d",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::get_global_colors": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::get_global_variables": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::sanitize_global_colors_data": "94f109150a8d6db57c309de00f4f4e369b4cd4a7e2a0682d5612dddeb65c8861",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::set_global_colors": "d269cc06168a1898ea999e6df332449cf9bc04c17abefbb8f729a05136c836e6",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::set_global_variables": "a2f422943530dba1afb5133459b25341dc67aa29e0be0a8626cbd60619c5a267",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_data": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_deleted_preset_ids": "d36bfe7698116a94f7c6bc5ff00e3f66f3cd4267b68b5e61f0a5ec01e051b450",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_group_preset_default_attr": "427ee58858537cf02a9d6dbbf58aafd779f8424c9c840806b5d7af8f25fbbda5",