feat: Divi-Adapter sperren je Fähigkeit – ReleaseGate::feature/assertFeature(s), DiviLocked und Ablehnung nennen die betroffenen Fähigkeiten (D6), DiviSaver prüft Seiten schreiben und die Fähigkeit des Speicherbereichs (D5), DesignArea::feature; Test-Standard alle Fähigkeiten freigegeben

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:10:41 +02:00
co-authored by Claude Opus 5.5
parent c4efeb3ba4
commit e387d8d29e
20 changed files with 525 additions and 65 deletions
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
defined('ABSPATH') || exit;
/**
@@ -20,6 +22,9 @@ interface DesignArea
/** German name for messages, e.g. "Presets". */
public function label(): string;
/** The Divi feature a write of this area needs (DesignChange asks ReleaseGate::assertFeature() for it). */
public function feature(): DiviFeature;
/** @return array<array-key, mixed> */
public function raw(): array;
@@ -159,7 +159,7 @@ final class DesignChange
return $blocked;
}
ReleaseGate::assertWritable();
ReleaseGate::assertFeature($area->feature());
try {
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
} catch (SnapshotTooLarge $e) {
@@ -94,12 +94,12 @@ class DesignSystemWriter implements DiviAdapter
* @param bool $mayRemove Deleting or restoring: fewer presets are allowed and removed ones
* leave Divi's legacy option (like a DELETE_* sync).
* @return array<array-key, mixed> The inventory Divi stored.
* @throws DiviLocked
* @throws DiviLocked When preset-write is not released – before anything is written.
* @throws RuntimeException When Divi refuses the inventory.
*/
public function savePresets(array $rest, bool $mayRemove): array
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::PresetWrite);
$prepared = GlobalPreset::prepare_data($rest);
$current = GlobalPreset::get_data();
$refusal = GlobalPreset::validate_preset_count(
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
defined('ABSPATH') || exit;
/**
@@ -27,6 +29,11 @@ final class PresetArea implements DesignArea
return 'Presets';
}
public function feature(): DiviFeature
{
return DiviFeature::PresetWrite;
}
public function raw(): array
{
return PresetBook::fromDivi($this->writer->presets())->data();
@@ -222,11 +222,11 @@ class DiviLibraryStore implements DiviAdapter
* when that delete fails; a throwing insert hook is a partial write without ID.
*
* @throws WriteFailed
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When library-write is not released (spec §5.5) – before anything is written.
*/
public function create(LibraryItemSpec $spec, NewDraft $draft): int
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::LibraryWrite);
$missing = $this->missingTaxonomies();
if ($missing !== []) {
throw WriteFailed::rolledBack(
+2 -2
View File
@@ -61,11 +61,11 @@ final class DiviPages implements DiviAdapter
/**
* @throws RuntimeException When a flag does not read back as set.
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When page-write is not released (spec §5.5) – before anything is written.
*/
public static function setFlags(int $postId): void
{
ReleaseGate::assertWritable($postId);
ReleaseGate::assertFeature(DiviFeature::PageWrite, $postId);
foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value);
}
+4 -4
View File
@@ -71,12 +71,12 @@ class DiviSaver
/**
* @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written.
*/
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
ReleaseGate::assertWritable($postId);
ReleaseGate::assertFeatures($scope->features(), $postId);
$revisionId = null;
try {
@@ -142,11 +142,11 @@ class DiviSaver
*
* @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written.
*/
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
ReleaseGate::assertWritable($postId);
ReleaseGate::assertFeatures($scope->features(), $postId);
$refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
+12
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
@@ -33,4 +35,14 @@ enum SaveScope
{
return $this === self::GlobalItem || $this === self::ThemeBuilderLayout;
}
/** @return list<DiviFeature> What a write in this scope needs (D5): page-write and the feature of its area. */
public function features(): array
{
return match ($this) {
self::Page => [DiviFeature::PageWrite],
self::LibraryItem, self::GlobalItem => [DiviFeature::PageWrite, DiviFeature::LibraryWrite],
self::ThemeBuilderLayout => [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite],
};
}
}
+12 -5
View File
@@ -11,21 +11,28 @@ use WP_Error;
defined('ABSPATH') || exit;
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
/**
* A Divi call refused because a feature it needs is not released (spec §5.5, D6); AbilityRegistrar returns
* toError(). Without features it is the general write lock of the overall status (texts of spec §5.6).
*/
final class DiviLocked extends RuntimeException implements ToolRefusal
{
public function __construct(public readonly ReleaseStatus $status)
/** @param list<DiviFeature> $features The locked features the refused call needs; [] = writing in general. */
public function __construct(public readonly ReleaseStatus $status, public readonly array $features = [])
{
parent::__construct(sprintf(
'%s is %s for LIT MCP – write refused',
'%s is %s for LIT MCP – %s refused',
$status->versionLabel(),
$status->state->value
$status->state->value,
$features === []
? 'write'
: implode(', ', array_map(static fn (DiviFeature $feature): string => $feature->value, $features))
));
}
public function toError(): WP_Error
{
return $this->status->refusal();
return $this->features === [] ? $this->status->refusal() : $this->status->refusalFor($this->features);
}
/** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
+39 -7
View File
@@ -11,9 +11,9 @@ use Throwable;
defined('ABSPATH') || exit;
/**
* The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the
* admin check write its throwaway page whatever the status – only that post, only during the call, and only from
* code (there is no parameter or option for it).
* The release status of this request and the lock every writing Divi path asks for the feature it needs (spec §5.5,
* E1). whileProbing() lets the admin check write its throwaway page whatever the status – only that post, only
* during the call, and only from code (there is no parameter or option for it).
*/
final class ReleaseGate
{
@@ -57,17 +57,49 @@ final class ReleaseGate
self::$status = null;
}
/** @throws DiviLocked */
public static function assertWritable(?int $postId = null): void
public static function feature(DiviFeature $feature): FeatureStatus
{
return self::status()->matrix->of($feature);
}
/** @throws DiviLocked When $feature is not usable – before anything is written. */
public static function assertFeature(DiviFeature $feature, ?int $postId = null): void
{
self::assertFeatures([$feature], $postId);
}
/**
* Every one of $features usable, else DiviLocked with the locked ones. The probe post passes whatever the status.
*
* @param list<DiviFeature> $features
* @throws DiviLocked
*/
public static function assertFeatures(array $features, ?int $postId = null): void
{
if (self::$probe !== null && $postId === self::$probe) {
return;
}
$status = self::status();
if (! $status->writable()) {
throw new DiviLocked($status);
$locked = [];
foreach ($features as $feature) {
if (! $status->matrix->of($feature)->usable()) {
$locked[$feature->value] = $feature;
}
}
if ($locked !== []) {
throw new DiviLocked($status, array_values($locked));
}
}
/**
* Every writing feature (CompatibilityMatrix::writeFeatures()) – for callers that know no feature.
*
* @throws DiviLocked
*/
public static function assertWritable(?int $postId = null): void
{
self::assertFeatures(CompatibilityMatrix::writeFeatures(), $postId);
}
public static function whileProbing(int $postId, callable $write): mixed
@@ -30,6 +30,15 @@ final class ReleaseStatus
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
. 'Divi-Werkzeuge funktionieren weiter.';
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
private const FEATURE_UNTESTED = 'Achtung: %1$s ist auf dieser Website für %2$s noch nicht für LIT MCP '
. 'freigegeben. Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.';
private const FEATURE_UNTESTED_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre '
. 'Fähigkeiten freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.';
private const FEATURE_INCOMPATIBLE = '%1$s hat Stellen geändert, die LIT MCP für %2$s nutzt. Das ist gesperrt, '
. 'bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
private const FEATURE_INCOMPATIBLE_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre '
. 'Fähigkeiten freigegeben sind. Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.';
public readonly CompatibilityMatrix $matrix;
@@ -99,6 +108,56 @@ final class ReleaseStatus
};
}
/**
* The error of a call refused because some of $features are locked (D6): divi_incompatible when one of them is
* incompatible, else divi_untested; HTTP 423. The message names the features in that state, the hint every
* locked one; details.features lists them (id, label, state, problems), details.problems the problems of an
* incompatible refusal as before.
*
* @param list<DiviFeature> $features What the refused call needs.
* @throws LogicException When all of them are usable.
*/
public function refusalFor(array $features): WP_Error
{
$locked = [];
foreach ($features as $feature) {
$status = $this->matrix->of($feature);
if (! $status->usable()) {
$locked[$feature->value] = $status;
}
}
if ($locked === []) {
throw new LogicException('usable features refuse nothing');
}
$locked = array_values($locked);
$incompatible = array_values(array_filter(
$locked,
static fn (FeatureStatus $status): bool => $status->state === ReleaseState::Incompatible
));
$details = ['features' => array_map(static fn (FeatureStatus $status): array => $status->summary(), $locked)];
if ($incompatible !== []) {
$details['problems'] = array_values(array_unique(array_merge(
...array_map(static fn (FeatureStatus $status): array => $status->problems, $locked)
)));
}
return Errors::make(
$incompatible !== [] ? 'divi_incompatible' : 'divi_untested',
sprintf(
$incompatible !== [] ? self::FEATURE_INCOMPATIBLE : self::FEATURE_UNTESTED,
$this->versionLabel(),
self::named($incompatible !== [] ? $incompatible : $locked)
),
sprintf(
$incompatible !== [] ? self::FEATURE_INCOMPATIBLE_HINT : self::FEATURE_UNTESTED_HINT,
self::named($locked)
),
423,
$details
);
}
/** The warning a reading Divi tool carries while writes are locked. */
public function notice(): string
{
@@ -109,6 +168,22 @@ final class ReleaseStatus
);
}
/**
* "„Seiten schreiben“", "„A“ und „B“", "„A“, „B“ und „C“".
*
* @param non-empty-list<FeatureStatus> $statuses
*/
private static function named(array $statuses): string
{
$labels = array_map(
static fn (FeatureStatus $status): string => '„' . $status->feature->label() . '“',
$statuses
);
$last = array_pop($labels);
return $labels === [] ? $last : implode(', ', $labels) . ' und ' . $last;
}
/**
* @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int,
* problems: list<string>}
@@ -149,12 +149,12 @@ class DiviThemeBuilder implements DiviAdapter
/**
* The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none.
*
* @throws DiviLocked When $create and Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When $create and tb-write is not released (spec §5.5) – before anything is written.
*/
public function liveId(bool $create = false): int
{
if ($create) {
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
}
return function_exists('et_theme_builder_get_theme_builder_post_id')
@@ -261,11 +261,11 @@ class DiviThemeBuilder implements DiviAdapter
/**
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written.
*/
public function insertLayout(string $area, string $title): int
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
$this->assertCanWrite('et_theme_builder_insert_layout');
$id = $this->guarded(
@@ -287,11 +287,11 @@ class DiviThemeBuilder implements DiviAdapter
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
* exclude_from: list<string>} $template
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written.
*/
public function storeTemplate(int $themeBuilderId, array $template): int
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
$this->assertCanWrite('et_theme_builder_store_template', 'et_');
$payload = [
'title' => $template['title'],
@@ -321,11 +321,11 @@ class DiviThemeBuilder implements DiviAdapter
* template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach.
*
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written.
*/
public function attach(int $themeBuilderId, int $templateId): void
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) {
return;
@@ -342,10 +342,10 @@ class DiviThemeBuilder implements DiviAdapter
}
}
/** @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */
/** @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */
public function detach(int $themeBuilderId, int $templateId): void
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
}
@@ -356,11 +356,11 @@ class DiviThemeBuilder implements DiviAdapter
* @param list<string> $useOn
* @param list<string> $excludeFrom
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written.
*/
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
delete_post_meta($templateId, $key);
foreach (array_values(array_unique($conditions)) as $condition) {
@@ -373,11 +373,11 @@ class DiviThemeBuilder implements DiviAdapter
/**
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written.
*/
public function setEnabled(int $templateId, bool $enabled): void
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
$value = $enabled ? '1' : '0';
update_post_meta($templateId, self::ENABLED, $value);
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
@@ -388,11 +388,11 @@ class DiviThemeBuilder implements DiviAdapter
/**
* Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post.
*
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
* @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written.
*/
public function unmarkUnused(int $postId): void
{
ReleaseGate::assertWritable();
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
delete_post_meta($postId, self::UNUSED);
}