feat: jedes Divi-Tool und lit/content-create erklärt seine Divi-Fähigkeiten (RequiresDiviFeatures, DeclaresRequirements, FeatureSets); Tabelle Tool → Fähigkeiten als Test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:14:39 +02:00
co-authored by Claude Sonnet 5.5
parent e387d8d29e
commit fdbda8012c
39 changed files with 1131 additions and 23 deletions
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
defined('ABSPATH') || exit;
/**
* A tool of the generic core that can reach another integration (lit/content-create with divi) and names what that
* call needs from it – as plain IDs, so the core imports nothing from the integration. The guard of the integration
* (DiviFeatureGate) reads the IDs; other guards ignore them.
*/
interface DeclaresRequirements
{
/**
* @param array<string, mixed> $input
* @return list<string> IDs of the capabilities the call needs for $input ([] = it stays in the core).
*/
public function requirements(array $input): array;
}
@@ -12,7 +12,9 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Log; use LIT\MCP\Log;
use WP_Error; use WP_Error;
@@ -28,7 +30,7 @@ defined('ABSPATH') || exit;
* anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly * anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly
* (Divi applies it); only explicit references go. Variables follow with Etappe 4c. * (Divi applies it); only explicit references go. Variables follow with Etappe 4c.
*/ */
final class DiviDesignDetachAbility implements Ability final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
{ {
private const VARIABLES = 'Variablen folgen mit Etappe 4c.'; private const VARIABLES = 'Variablen folgen mit Etappe 4c.';
@@ -113,6 +115,25 @@ final class DiviDesignDetachAbility implements Ability
return false; return false;
} }
/**
* Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references
* nested in other presets (PresetWrite).
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::PresetWrite,
DiviFeature::LibraryRead,
DiviFeature::LibraryWrite,
DiviFeature::ThemeBuilderWrite,
DiviFeature::Roles,
];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return DesignRights::presets($this->writer) ?? true; return DesignRights::presets($this->writer) ?? true;
@@ -8,6 +8,8 @@ use Closure;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -17,7 +19,7 @@ defined('ABSPATH') || exit;
* to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can be * to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can be
* undone as well. Etappe 4a restores presets; the other areas follow with their tools (4c, 4d). * undone as well. Etappe 4a restores presets; the other areas follow with their tools (4c, 4d).
*/ */
final class DiviDesignRestoreAbility implements Ability final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures
{ {
private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D'; private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D';
@@ -76,6 +78,12 @@ final class DiviDesignRestoreAbility implements Ability
return false; return false;
} }
/** Writes a stored snapshot back through the preset area (PresetWrite, reading it first: DesignRead). */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::PresetWrite];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return DesignRights::presets($this->writer) ?? true; return DesignRights::presets($this->writer) ?? true;
@@ -8,12 +8,14 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Cursor; use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** lit/divi-design-snapshots (spec §5): the snapshots of one design area, newest first, without their content. */ /** lit/divi-design-snapshots (spec §5): the snapshots of one design area, newest first, without their content. */
final class DiviDesignSnapshotsAbility implements Ability final class DiviDesignSnapshotsAbility implements Ability, RequiresDiviFeatures
{ {
public function __construct(private SnapshotStore $snapshots) public function __construct(private SnapshotStore $snapshots)
{ {
@@ -70,6 +72,12 @@ final class DiviDesignSnapshotsAbility implements Ability
return false; return false;
} }
/** Reads LIT MCP's own snapshot store (WordPress option); no Divi call. */
public function diviFeatures(array $input): array
{
return [];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return current_user_can('edit_theme_options') return current_user_can('edit_theme_options')
@@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -16,7 +18,7 @@ defined('ABSPATH') || exit;
* placeholder to use for each (spec §5.4, §5.7). Read live from Divi; writing follows in * placeholder to use for each (spec §5.4, §5.7). Read live from Divi; writing follows in
* Etappe 4. * Etappe 4.
*/ */
final class DiviDesignSystemAbility implements Ability final class DiviDesignSystemAbility implements Ability, RequiresDiviFeatures
{ {
/** Presets per answer (ruling S19); narrow with "module". */ /** Presets per answer (ruling S19); narrow with "module". */
public const PRESET_LIMIT = 200; public const PRESET_LIMIT = 200;
@@ -121,6 +123,12 @@ final class DiviDesignSystemAbility implements Ability
return true; return true;
} }
/** Only the design system reader. */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return current_user_can('edit_posts') return current_user_can('edit_posts')
@@ -11,6 +11,8 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap; use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use WP_Error; use WP_Error;
@@ -22,7 +24,7 @@ defined('ABSPATH') || exit;
* group presets with names, the posts that use it (readable ones by title, the rest as a count) and the design_rev * group presets with names, the posts that use it (readable ones by title, the rest as a count) and the design_rev
* for a following lit/divi-presets-set. * for a following lit/divi-presets-set.
*/ */
final class DiviPresetGetAbility implements Ability final class DiviPresetGetAbility implements Ability, RequiresDiviFeatures
{ {
public function __construct(private DesignSystemWriter $writer, private GlobalUsages $usages) public function __construct(private DesignSystemWriter $writer, private GlobalUsages $usages)
{ {
@@ -93,6 +95,12 @@ final class DiviPresetGetAbility implements Ability
return false; return false;
} }
/** Divi's preset inventory (DesignRead) and the option groups of modules (Catalog); usages come from the database. */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::Catalog];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return current_user_can('edit_posts') return current_user_can('edit_posts')
@@ -10,6 +10,8 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -23,7 +25,7 @@ defined('ABSPATH') || exit;
* result ("nichts zu tun"), not confirm_required: there is nothing to confirm, and a repeated call ends cleanly. * result ("nichts zu tun"), not confirm_required: there is nothing to confirm, and a repeated call ends cleanly.
* null removes nothing here – lit/divi-design-detach does. * null removes nothing here – lit/divi-design-detach does.
*/ */
final class DiviPresetsAssignAbility implements Ability final class DiviPresetsAssignAbility implements Ability, RequiresDiviFeatures
{ {
/** Path prefix for PresetAssignment's errors; removed again so fields name the input keys. */ /** Path prefix for PresetAssignment's errors; removed again so fields name the input keys. */
private const ROOT = 'input'; private const ROOT = 'input';
@@ -116,6 +118,28 @@ final class DiviPresetsAssignAbility implements Ability
return false; return false;
} }
/**
* Rewrites references in every post that uses the module, so the upper bound covers every kind of post the
* rewriter may save: pages (PageRead, PageWrite, Catalog, DesignRead), library entries (LibraryRead for
* isGlobal(), LibraryWrite) and Theme Builder layouts (ThemeBuilderWrite), with their roles. Stopping in the
* middle of a run would leave a partial write, so the lock comes before. DesignRights::presets() checks the
* Visual Builder role through the preset writer (PresetWrite).
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::PresetWrite,
DiviFeature::LibraryRead,
DiviFeature::LibraryWrite,
DiviFeature::ThemeBuilderWrite,
DiviFeature::Roles,
];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return DesignRights::presets($this->writer) ?? true; return DesignRights::presets($this->writer) ?? true;
@@ -10,6 +10,8 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -21,7 +23,7 @@ defined('ABSPATH') || exit;
* block: a default preset loses its default, and the posts with modules that use it implicitly are listed in the dry * block: a default preset loses its default, and the posts with modules that use it implicitly are listed in the dry
* run (spec §6.4). Through the change pipeline with Divi's delete semantics (legacy option cleaned). * run (spec §6.4). Through the change pipeline with Divi's delete semantics (legacy option cleaned).
*/ */
final class DiviPresetsDeleteAbility implements Ability final class DiviPresetsDeleteAbility implements Ability, RequiresDiviFeatures
{ {
public const MAX_ITEMS = 20; public const MAX_ITEMS = 20;
@@ -97,6 +99,12 @@ final class DiviPresetsDeleteAbility implements Ability
return false; return false;
} }
/** Reads and writes the preset inventory only; no composition. */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::PresetWrite];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return DesignRights::presets($this->writer) ?? true; return DesignRights::presets($this->writer) ?? true;
@@ -12,6 +12,9 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -23,7 +26,7 @@ defined('ABSPATH') || exit;
* module/group (A5). The dry run lists the posts that use a changed preset or reach a new default, also implicitly * module/group (A5). The dry run lists the posts that use a changed preset or reach a new default, also implicitly
* (ruling R11). * (ruling R11).
*/ */
final class DiviPresetsSetAbility implements Ability final class DiviPresetsSetAbility implements Ability, RequiresDiviFeatures
{ {
public const MAX_ITEMS = 20; public const MAX_ITEMS = 20;
@@ -121,6 +124,27 @@ final class DiviPresetsSetAbility implements Ability
return false; return false;
} }
/**
* Writes the preset inventory (PresetWrite) after reading it (DesignRead); the preset composer cleans the
* attributes (Catalog). from_node reads one node of an entry (PageRead, and the roles for a library entry).
*/
public function diviFeatures(array $input): array
{
$features = [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::Catalog];
foreach (is_array($input['items'] ?? null) ? $input['items'] : [] as $item) {
$source = is_array($item) ? ($item['from_node'] ?? null) : null;
if (is_array($source)) {
$features = FeatureSets::merge(
$features,
[DiviFeature::PageRead],
FeatureSets::ofEntry($source['post_id'] ?? null, false)
);
}
}
return $features;
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return DesignRights::presets($this->writer) ?? true; return DesignRights::presets($this->writer) ?? true;
@@ -18,6 +18,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors; use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -37,7 +39,7 @@ defined('ABSPATH') || exit;
* and next_cursor resumes behind it. Needs the library right (canList); a hidden library answers its entries like * and next_cursor resumes behind it. Needs the library right (canList); a hidden library answers its entries like
* missing IDs (LibraryAccess::item, P9/P12). Only hashes and the matched nodes' labels are returned. * missing IDs (LibraryAccess::item, P9/P12). Only hashes and the matched nodes' labels are returned.
*/ */
final class LibraryFindSimilarAbility implements Ability final class LibraryFindSimilarAbility implements Ability, RequiresDiviFeatures
{ {
public const MAX_POSTS = 200; public const MAX_POSTS = 200;
public const DEFAULT_POSTS = 100; public const DEFAULT_POSTS = 100;
@@ -121,6 +123,15 @@ final class LibraryFindSimilarAbility implements Ability
return false; return false;
} }
/**
* Scans Divi pages (PageRead) of the types that use the builder (Catalog), after the library right (Roles). A
* library entry as reference is read through LibraryAccess (Roles), no Divi call beyond that.
*/
public function diviFeatures(array $input): array
{
return [DiviFeature::PageRead, DiviFeature::Catalog, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return $this->access->canList() ?? true; return $this->access->canList() ?? true;
@@ -19,6 +19,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviSchema; use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error; use WP_Error;
use WP_Post; use WP_Post;
@@ -103,6 +104,23 @@ final class LibraryInsertAbility extends DiviWriteAbility
return null; return null;
} }
/**
* Like lit/divi-insert (StoredNodeGuard cleans the entry's nodes: Catalog, DesignRead, DynamicSources), plus the
* library entry as source: isGlobal() is a Divi call (LibraryRead), its rights are a Divi role (Roles).
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
DiviFeature::LibraryRead,
DiviFeature::Roles,
];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
$mode = $input['mode'] ?? null; $mode = $input['mode'] ?? null;
@@ -13,6 +13,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error; use WP_Error;
use WP_Post; use WP_Post;
@@ -27,7 +29,7 @@ defined('ABSPATH') || exit;
* left out, without title or count). With id, * left out, without title or count). With id,
* everything but a library entry the user may read answers like a missing ID (ruling P12). * everything but a library entry the user may read answers like a missing ID (ruling P12).
*/ */
final class LibraryListAbility implements Ability final class LibraryListAbility implements Ability, RequiresDiviFeatures
{ {
/** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */ /** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */
public const MAX_SCAN = 500; public const MAX_SCAN = 500;
@@ -121,6 +123,19 @@ final class LibraryListAbility implements Ability
return false; return false;
} }
/**
* describe() asks Divi whether an entry is global (LibraryRead); listing needs the library role (Roles). One
* entry (id) is loaded and outlined (PageRead, DesignRead).
*/
public function diviFeatures(array $input): array
{
$features = [DiviFeature::LibraryRead, DiviFeature::Roles];
return isset($input['id'])
? array_merge($features, [DiviFeature::PageRead, DiviFeature::DesignRead])
: $features;
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
if (isset($input['id'])) { if (isset($input['id'])) {
@@ -23,6 +23,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviSchema; use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits; use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\Content\NewDraft; use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
@@ -59,7 +61,7 @@ defined('ABSPATH') || exit;
* point), page N is rolled back by DiviSaver (or reported partial), pages after N are not attempted – the error * point), page N is rolled back by DiviSaver (or reported partial), pages after N are not attempted – the error
* names all three groups (changed_posts with pages, failed_post, unchanged_posts; D10, S10). * names all three groups (changed_posts with pages, failed_post, unchanged_posts; D10, S10).
*/ */
final class LibraryMakeGlobalAbility implements Ability final class LibraryMakeGlobalAbility implements Ability, RequiresDiviFeatures
{ {
public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder ' public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder '
. 'entfernt.'; . 'entfernt.';
@@ -176,6 +178,25 @@ final class LibraryMakeGlobalAbility implements Ability
return false; return false;
} }
/**
* Both modes write pages and posts (PageRead, PageWrite, Catalog, DesignRead, DynamicSources) and ask Divi
* whether an entry is global (LibraryRead); the first mode also creates the global entry (LibraryWrite).
* One upper bound for both modes (the second mode would not reach LibraryWrite): the roles of the library.
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
DiviFeature::LibraryRead,
DiviFeature::LibraryWrite,
DiviFeature::Roles,
];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return $this->precheck($input) ?? true; return $this->precheck($input) ?? true;
@@ -17,6 +17,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\NewDraft; use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
@@ -35,7 +37,7 @@ defined('ABSPATH') || exit;
* to the entry is lit/library-make-global. All or nothing through NewDraft (ruling P6): any failure * to the entry is lit/library-make-global. All or nothing through NewDraft (ruling P6): any failure
* after the insert deletes the new entry, a failed delete is an honest partial_write. * after the insert deletes the new entry, a failed delete is an honest partial_write.
*/ */
final class LibrarySaveAbility implements Ability final class LibrarySaveAbility implements Ability, RequiresDiviFeatures
{ {
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul']; private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
@@ -112,6 +114,24 @@ final class LibrarySaveAbility implements Ability
return false; return false;
} }
/**
* Reads a node of any entry (PageRead), cleans it with StoredNodeGuard (Catalog, DesignRead, DynamicSources) and
* saves a new library entry (PageWrite and LibraryWrite: DiviSaver::saveNew with the library scope, D5), after
* the library right (Roles). The source is only read: a library entry or Theme Builder layout adds Roles only.
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
DiviFeature::LibraryWrite,
DiviFeature::Roles,
];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$refusal = $this->access->canSave(self::isGlobal($input)); $refusal = $this->access->canSave(self::isGlobal($input));
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
@@ -36,6 +37,19 @@ defined('ABSPATH') || exit;
*/ */
final class DiviContentComposer implements ContentComposer final class DiviContentComposer implements ContentComposer
{ {
/**
* What a new Divi entry needs: Catalog (diviEnabled(), composing), DesignRead (presets, variables), DynamicSources
* (the composition checks references against the new entry's map), PageRead (link targets) and PageWrite (saveNew
* with the Divi flags). No library or Theme Builder scope: the draft is an ordinary entry.
*/
private const FEATURES = [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
/** @var Closure(): bool */ /** @var Closure(): bool */
private Closure $writable; private Closure $writable;
@@ -60,6 +74,11 @@ final class DiviContentComposer implements ContentComposer
return ($this->writable)(); return ($this->writable)();
} }
public function requirements(): array
{
return array_map(static fn (DiviFeature $feature): string => $feature->value, self::FEATURES);
}
public function refusal(): ?WP_Error public function refusal(): ?WP_Error
{ {
$status = ReleaseGate::status(); $status = ReleaseGate::status();
@@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -60,6 +61,12 @@ final class DiviDeleteAbility extends DiviWriteAbility
return Annotations::destructive(false); return Annotations::destructive(false);
} }
/** Like lit/divi-move: TreeEditor::remove() only, no composition of client values. */
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
/** @var array<string, Block> $nodes */ /** @var array<string, Block> $nodes */
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -55,6 +56,12 @@ final class DiviDuplicateNodeAbility extends DiviWriteAbility
return false; return false;
} }
/** Like lit/divi-move: TreeEditor::duplicate() copies stored bytes, no composition of client values. */
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
$id = is_string($input['id'] ?? null) ? $input['id'] : ''; $id = is_string($input['id'] ?? null) ? $input['id'] : '';
@@ -11,6 +11,8 @@ use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error; use WP_Error;
@@ -28,7 +30,7 @@ defined('ABSPATH') || exit;
* left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor * left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor
* named. The field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25). * named. The field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
*/ */
final class DiviDynamicSourcesAbility implements Ability final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
{ {
/** Candidates the default sample lookup checks for edit_post. */ /** Candidates the default sample lookup checks for edit_post. */
private const SAMPLE_CANDIDATES = 10; private const SAMPLE_CANDIDATES = 10;
@@ -130,6 +132,15 @@ final class DiviDynamicSourcesAbility implements Ability
return false; return false;
} }
/**
* Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option
* read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass.
*/
public function diviFeatures(array $input): array
{
return [DiviFeature::DynamicSources, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -64,6 +65,21 @@ final class DiviInsertAbility extends DiviWriteAbility
return Annotations::write(false); return Annotations::write(false);
} }
/**
* Loads and saves the entry (PageRead, PageWrite) and composes new nodes: Catalog, DesignRead (presets,
* variables) and DynamicSources (references are checked against the dynamic content map of the entry).
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
$editor = $composition->editor(); $editor = $composition->editor();
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -52,6 +53,15 @@ final class DiviMoveAbility extends DiviWriteAbility
return Annotations::write(false); return Annotations::write(false);
} }
/**
* No client value enters the tree (no DynamicSources): load and save (PageRead, PageWrite), StructureRules
* need the catalog, the quality warnings of the result read the design system (DesignRead).
*/
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
$id = is_string($input['id'] ?? null) ? $input['id'] : ''; $id = is_string($input['id'] ?? null) ? $input['id'] : '';
@@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure; use Closure;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -117,6 +119,29 @@ final class DiviNodeCopyAbility extends DiviWriteAbility
return $this->loader->library()?->canInsert(); return $this->loader->library()?->canInsert();
} }
/**
* Like lit/divi-insert (StoredNodeGuard cleans the copied node: Catalog, DesignRead, DynamicSources); the source
* entry is only read, so a library entry or Theme Builder layout as source adds the Divi roles.
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
parent::diviFeatures($input),
FeatureSets::ofEntry($input['source_post_id'] ?? null, false)
);
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
$source = $this->loader->load($input['source_post_id'] ?? null, false); $source = $this->loader->load($input['source_post_id'] ?? null, false);
@@ -14,6 +14,9 @@ use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap; use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use WP_Error; use WP_Error;
@@ -23,7 +26,7 @@ defined('ABSPATH') || exit;
* lit/divi-node-get: all attributes of one node in the full form (spec §5.4, §5.7) – * lit/divi-node-get: all attributes of one node in the full form (spec §5.4, §5.7) –
* attribute path => breakpoint map – plus the current short-form values. * attribute path => breakpoint map – plus the current short-form values.
*/ */
final class DiviNodeGetAbility implements Ability final class DiviNodeGetAbility implements Ability, RequiresDiviFeatures
{ {
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design) public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{ {
@@ -123,6 +126,18 @@ final class DiviNodeGetAbility implements Ability
return true; return true;
} }
/**
* Loads the entry (PageRead) and names preset labels (DesignRead); ShortformMap is pure. A library entry or
* Theme Builder layout as post_id adds the Divi roles.
*/
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
[DiviFeature::PageRead, DiviFeature::DesignRead],
FeatureSets::ofEntry($input['post_id'] ?? null, false)
);
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$page = $this->loader->load($input['post_id'] ?? null, false); $page = $this->loader->load($input['post_id'] ?? null, false);
@@ -11,6 +11,9 @@ use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -19,7 +22,7 @@ defined('ABSPATH') || exit;
* lit/divi-outline: the page tree with path IDs, types, admin labels, text excerpts and * lit/divi-outline: the page tree with path IDs, types, admin labels, text excerpts and
* preset names plus tree_rev (spec §5.3, §5.7). Every Divi write needs this tree_rev. * preset names plus tree_rev (spec §5.3, §5.7). Every Divi write needs this tree_rev.
*/ */
final class DiviOutlineAbility implements Ability final class DiviOutlineAbility implements Ability, RequiresDiviFeatures
{ {
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design) public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{ {
@@ -137,6 +140,18 @@ final class DiviOutlineAbility implements Ability
return true; return true;
} }
/**
* Loads the entry (PageRead) and names preset labels (DesignRead). Global modules are shown by ID only, never
* resolved. A library entry or Theme Builder layout as post_id adds the Divi roles.
*/
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
[DiviFeature::PageRead, DiviFeature::DesignRead],
FeatureSets::ofEntry($input['post_id'] ?? null, false)
);
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$page = $this->loader->load($input['post_id'] ?? null, false); $page = $this->loader->load($input['post_id'] ?? null, false);
@@ -13,6 +13,9 @@ use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer; use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Log; use LIT\MCP\Log;
@@ -36,7 +39,7 @@ defined('ABSPATH') || exit;
* others go on. Inserted text is cleaned by the field kind through * others go on. Inserted text is cleaned by the field kind through
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched. * ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/ */
final class DiviReplaceTextAbility implements Ability final class DiviReplaceTextAbility implements Ability, RequiresDiviFeatures
{ {
private const MAX_TARGETS = 20; private const MAX_TARGETS = 20;
@@ -128,6 +131,34 @@ final class DiviReplaceTextAbility implements Ability
return false; return false;
} }
/**
* The dry run (default) reads: PageRead, Catalog and DesignRead; the cleaner builds its reference check from the
* entry's dynamic content map (DynamicSources). A real run (dry_run: false) also saves each target, which may be a
* library entry or Theme Builder layout (PageWrite and what FeatureSets::ofEntry() adds).
*/
public function diviFeatures(array $input): array
{
$features = [
DiviFeature::PageRead,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
if (($input['dry_run'] ?? true) !== false) {
return $features;
}
$features[] = DiviFeature::PageWrite;
foreach (is_array($input['targets'] ?? null) ? $input['targets'] : [] as $target) {
$features = FeatureSets::merge(
$features,
FeatureSets::ofEntry(is_array($target) ? ($target['post_id'] ?? null) : null, true)
);
}
return $features;
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$type = PostGuard::postType($input['post_type'] ?? 'page', $this->schema, 'edit_posts'); $type = PostGuard::postType($input['post_type'] ?? 'page', $this->schema, 'edit_posts');
@@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths; use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -101,6 +102,18 @@ final class DiviUpdateAbility extends DiviWriteAbility
return Annotations::write(true); return Annotations::write(true);
} }
/** Like lit/divi-insert: the Composer checks every changed value (Catalog, DesignRead, DynamicSources). */
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{ {
$changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : []; $changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : [];
@@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -17,7 +20,7 @@ defined('ABSPATH') || exit;
* validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries: * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries:
* GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22). * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
*/ */
abstract class DiviWriteAbility implements Ability abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures
{ {
public function __construct(protected DiviWriter $writer) public function __construct(protected DiviWriter $writer)
{ {
@@ -31,6 +34,19 @@ abstract class DiviWriteAbility implements Ability
*/ */
abstract protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error; abstract protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error;
/**
* What the tool needs whatever entry it changes; diviFeatures() adds what the entry post_id names (a library
* entry or Theme Builder layout is saved with its own capability, D5).
*
* @return list<DiviFeature>
*/
abstract protected function baseFeatures(): array;
public function diviFeatures(array $input): array
{
return FeatureSets::merge($this->baseFeatures(), FeatureSets::ofEntry($input['post_id'] ?? null, true));
}
public function outputSchema(): ?array public function outputSchema(): ?array
{ {
return ToolResult::schema([ return ToolResult::schema([
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard;
defined('ABSPATH') || exit;
/** Helpers of RequiresDiviFeatures::diviFeatures(): joining lists and the capabilities an entry adds. */
final class FeatureSets
{
/**
* The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool
* names through DeclaresRequirements (lit/content-create with divi). Anything else declares none; an ID that is no
* DiviFeature is ignored (the core may name capabilities of other integrations).
*
* @param array<string, mixed> $input
* @return list<DiviFeature>
*/
public static function declaredBy(Ability $ability, array $input): array
{
if ($ability instanceof RequiresDiviFeatures) {
return self::merge($ability->diviFeatures($input));
}
if (! $ability instanceof DeclaresRequirements) {
return [];
}
$features = [];
foreach ($ability->requirements($input) as $id) {
$feature = DiviFeature::tryFrom($id);
if ($feature !== null) {
$features[] = $feature;
}
}
return self::merge($features);
}
/**
* @param list<DiviFeature> ...$lists
* @return list<DiviFeature> Every capability once, in the order of first appearance.
*/
public static function merge(array ...$lists): array
{
$merged = [];
foreach ($lists as $list) {
foreach ($list as $feature) {
$merged[$feature->value] ??= $feature;
}
}
return array_values($merged);
}
/**
* What the entry $postId adds to a tool that loads it. A page or post adds nothing; a library entry needs the
* Divi roles (LibraryAccess), written also the library check of global entries and the library save scope
* (DiviSaver, D5); a Theme Builder layout needs the roles, written also the template lookup of
* ThemeBuilderEdits and the Theme Builder save scope. An ID that names no entry, or one the caller may not edit,
* adds nothing – the tool answers "not found" or "forbidden" itself, and a lock never tells which kind of entry
* an ID is (ruling P12).
*
* @return list<DiviFeature>
*/
public static function ofEntry(mixed $postId, bool $write): array
{
$id = PostGuard::id($postId);
$type = $id === null ? false : get_post_type($id);
if ($id === null || ! is_string($type)) {
return [];
}
$isLibrary = $type === DiviLibraryStore::POST_TYPE;
if (! $isLibrary && DiviThemeBuilder::areaOf($type) === null) {
return [];
}
if (! current_user_can('edit_post', $id)) {
return [];
}
if ($isLibrary) {
return $write
? [DiviFeature::Roles, DiviFeature::LibraryRead, DiviFeature::LibraryWrite]
: [DiviFeature::Roles];
}
return $write
? [DiviFeature::Roles, DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite]
: [DiviFeature::Roles];
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* A tool that says which Divi capabilities a call needs. DiviFeatureGate refuses the call before it runs while one of
* them is not usable; the adapters still check each write themselves (ReleaseGate::assertFeature()).
*/
interface RequiresDiviFeatures
{
/**
* The capabilities this call needs for $input, the upper bound of what the call may reach: a capability an
* adapter checks later must be in it, or the tool would stop in the middle of its work instead of before.
* [] = none (a tool that never touches Divi).
*
* @param array<string, mixed> $input
* @return list<DiviFeature>
*/
public function diviFeatures(array $input): array;
}
@@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -16,7 +18,7 @@ defined('ABSPATH') || exit;
* lit/divi-catalog: the modules of this Divi installation with their short-form fields; * lit/divi-catalog: the modules of this Divi installation with their short-form fields;
* with "module" the details of one module incl. all known attribute paths (spec §5.7). * with "module" the details of one module incl. all known attribute paths (spec §5.7).
*/ */
final class DiviCatalogAbility implements Ability final class DiviCatalogAbility implements Ability, RequiresDiviFeatures
{ {
public const CODE_NOTE = 'Code-Modul (code) nur verwenden, wenn kein natives Modul passt – es ist im ' public const CODE_NOTE = 'Code-Modul (code) nur verwenden, wenn kein natives Modul passt – es ist im '
. 'Divi-Builder nicht visuell bearbeitbar.'; . 'Divi-Builder nicht visuell bearbeitbar.';
@@ -116,6 +118,12 @@ final class DiviCatalogAbility implements Ability
return true; return true;
} }
/** Only the module catalog (DiviModules::catalog()). */
public function diviFeatures(array $input): array
{
return [DiviFeature::Catalog];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return current_user_can('edit_posts') return current_user_can('edit_posts')
@@ -12,6 +12,8 @@ use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use Throwable; use Throwable;
use WP_Error; use WP_Error;
@@ -27,7 +29,7 @@ defined('ABSPATH') || exit;
* published, e.g. in the trash (ruling Q16), never needs the layouts and works despite a Theme Builder draft (with * published, e.g. in the trash (ruling Q16), never needs the layouts and works despite a Theme Builder draft (with
* DRAFT_WARNING, ruling Q20). Overlaps name only templates the user may edit, the others as a count (ruling Q21). * DRAFT_WARNING, ruling Q20). Overlaps name only templates the user may edit, the others as a count (ruling Q21).
*/ */
final class TbActivateAbility implements Ability final class TbActivateAbility implements Ability, RequiresDiviFeatures
{ {
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.'; public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
@@ -97,6 +99,12 @@ final class TbActivateAbility implements Ability
return false; return false;
} }
/** Switches _et_enabled of a listed template (Theme Builder functions); the right is a Divi role. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$template = $this->access->changeableTemplate($input, ($input['enabled'] ?? null) === false); $template = $this->access->changeableTemplate($input, ($input['enabled'] ?? null) === false);
@@ -12,6 +12,8 @@ use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use Throwable; use Throwable;
use WP_Error; use WP_Error;
@@ -30,7 +32,7 @@ defined('ABSPATH') || exit;
* Divi's "unused" marks a reattach removed (review M-12 c): the template is detached again, but Divi no longer * Divi's "unused" marks a reattach removed (review M-12 c): the template is detached again, but Divi no longer
* trashes it after seven days – harmless, it stays under detached in lit/tb-list. * trashes it after seven days – harmless, it stays under detached in lit/tb-list.
*/ */
final class TbAssignAbility implements Ability final class TbAssignAbility implements Ability, RequiresDiviFeatures
{ {
public const REASON = 'Die Bedingungen der Vorlage konnten nicht gespeichert werden.'; public const REASON = 'Die Bedingungen der Vorlage konnten nicht gespeichert werden.';
@@ -103,6 +105,12 @@ final class TbAssignAbility implements Ability
return false; return false;
} }
/** Reads and writes templates and their conditions (Theme Builder functions); the right is a Divi role. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
$template = $this->access->changeableTemplate($input); $template = $this->access->changeableTemplate($input);
@@ -17,6 +17,9 @@ use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder; use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder;
use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
use WP_Error; use WP_Error;
@@ -33,7 +36,7 @@ defined('ABSPATH') || exit;
* (TemplateBuild). Refused while the Theme Builder has an unsaved draft (E7). Never enables anything (F1, Q5): * (TemplateBuild). Refused while the Theme Builder has an unsaved draft (E7). Never enables anything (F1, Q5):
* Divi stores it with enabled '0' and a template that reads back enabled or as default is rolled back. * Divi stores it with enabled '0' and a template that reads back enabled or as default is rolled back.
*/ */
final class TbCreateTemplateAbility implements Ability final class TbCreateTemplateAbility implements Ability, RequiresDiviFeatures
{ {
public const SOURCES = ['new', 'global', 'default', 'hidden']; public const SOURCES = ['new', 'global', 'default', 'hidden'];
@@ -127,6 +130,29 @@ final class TbCreateTemplateAbility implements Ability
return false; return false;
} }
/**
* Creates layouts and the template (Theme Builder functions); layouts with nodes are composed and saved like a
* page (PageRead, PageWrite, Catalog, DesignRead, DynamicSources) – only when an area names nodes.
*/
public function diviFeatures(array $input): array
{
$features = [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles];
foreach (Template::AREAS as $area) {
$spec = $input[$area] ?? null;
if (is_array($spec) && is_array($spec['nodes'] ?? null) && $spec['nodes'] !== []) {
return FeatureSets::merge($features, [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
]);
}
}
return $features;
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true; return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true;
@@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error; use WP_Error;
@@ -22,7 +24,7 @@ defined('ABSPATH') || exit;
* reads like a missing one – P12) – active ones only as a count (hidden_live, ruling Q21); layout titles only for * reads like a missing one – P12) – active ones only as a count (hidden_live, ruling Q21); layout titles only for
* layouts the user may edit; condition labels name objects by the stored ID only, never by title. * layouts the user may edit; condition labels name objects by the stored ID only, never by title.
*/ */
final class TbListAbility implements Ability final class TbListAbility implements Ability, RequiresDiviFeatures
{ {
public const DETACHED_LIMIT = 50; public const DETACHED_LIMIT = 50;
@@ -97,6 +99,12 @@ final class TbListAbility implements Ability
return false; return false;
} }
/** Templates, layouts and conditions come from Divi's Theme Builder functions; the right is a Divi role. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeBuilderRead, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error public function permission(array $input): bool|WP_Error
{ {
return $this->access->canUse() ?? true; return $this->access->canUse() ?? true;
@@ -35,6 +35,15 @@ interface ContentComposer
*/ */
public function refusal(): ?WP_Error; public function refusal(): ?WP_Error;
/**
* IDs of the capabilities of the composing integration that composing a new entry needs (the Divi integration:
* the DiviFeature values). lit/content-create hands them on as DeclaresRequirements; the core never interprets
* them.
*
* @return list<string>
*/
public function requirements(): array;
/** /**
* Validates the description for a new entry of $postType. Nothing is written. * Validates the description for a new entry of $postType. Nothing is written.
* *
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
@@ -32,7 +33,7 @@ defined('ABSPATH') || exit;
* (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec * (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec
* D7) – a refusal or failure there deletes the draft again. * D7) – a refusal or failure there deletes the draft again.
*/ */
final class ContentCreateAbility implements Ability final class ContentCreateAbility implements Ability, DeclaresRequirements
{ {
/** The extras (written after the layout) re-saved the entry and changed the stored layout (review m-6). */ /** The extras (written after the layout) re-saved the entry and changed the stored layout (review m-6). */
public const LAYOUT_CHANGED = 'Der Divi-Inhalt wurde beim Speichern der Felder verändert (z. B. durch ein Plugin); ' public const LAYOUT_CHANGED = 'Der Divi-Inhalt wurde beim Speichern der Felder verändert (z. B. durch ein Plugin); '
@@ -109,6 +110,15 @@ final class ContentCreateAbility implements Ability
return $type instanceof WP_Error ? $type : true; return $type instanceof WP_Error ? $type : true;
} }
/**
* Without divi the call stays in WordPress and needs nothing; with divi it needs what the ContentComposer says it
* needs (the Divi integration names its capabilities), so the guard refuses before a draft is inserted.
*/
public function requirements(array $input): array
{
return array_key_exists('divi', $input) && $this->divi !== null ? $this->divi->requirements() : [];
}
public function execute(array $input): mixed public function execute(array $input): mixed
{ {
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts'); $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
@@ -68,6 +69,17 @@ final class DiviContentComposerTest extends TestCase
]; ];
} }
public function testRequirementsNameTheCapabilitiesANewEntryNeeds(): void
{
$requirements = $this->composer()->requirements();
sort($requirements);
self::assertSame(['catalog', 'design-read', 'dynamic-sources', 'page-read', 'page-write'], $requirements);
foreach ($requirements as $id) {
self::assertNotNull(DiviFeature::tryFrom($id), $id);
}
}
public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void
{ {
self::assertNull($this->composer()->check('page', self::sections())); self::assertNull($this->composer()->check('page', self::sections()));
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
/**
* The table of the Divi capabilities every tool of the groups divi, divi-library and divi-theme-builder and
* lit/content-create declare (plan "Divi-Freigabe je Fähigkeit", Task 8). The table is the contract: a tool that
* needs more or less than listed here changes it here, on purpose.
*/
final class DiviToolFeaturesTest extends TestCase
{
private const PAGE = 5;
private const LIBRARY_ENTRY = 7;
private const LAYOUT = 9;
/** Post ID => post type for get_post_type(); every other ID names no entry. */
private const TYPES = [
self::PAGE => 'page',
self::LIBRARY_ENTRY => 'et_pb_layout',
self::LAYOUT => 'et_body_layout',
];
/** What a tool needs whatever its input names: tool => capabilities (DiviFeature values). */
private const EXPECTED = [
// Pages (divi)
'lit/divi-outline' => ['page-read', 'design-read'],
'lit/divi-node-get' => ['page-read', 'design-read'],
'lit/divi-catalog' => ['catalog'],
'lit/divi-design-system' => ['design-read'],
'lit/divi-insert' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'],
'lit/divi-update' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'],
'lit/divi-move' => ['page-read', 'page-write', 'catalog', 'design-read'],
'lit/divi-delete' => ['page-read', 'page-write', 'catalog', 'design-read'],
'lit/divi-duplicate-node' => ['page-read', 'page-write', 'catalog', 'design-read'],
'lit/divi-replace-text' => ['page-read', 'catalog', 'design-read', 'dynamic-sources'],
'lit/divi-node-copy' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'],
'lit/divi-dynamic-sources' => ['dynamic-sources', 'roles'],
// Design system (divi)
'lit/divi-preset-get' => ['design-read', 'catalog'],
'lit/divi-presets-set' => ['design-read', 'preset-write', 'catalog'],
'lit/divi-presets-delete' => ['design-read', 'preset-write'],
'lit/divi-design-snapshots' => [],
'lit/divi-design-restore' => ['design-read', 'preset-write'],
'lit/divi-presets-assign' => [
'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write',
'tb-write', 'roles',
],
'lit/divi-design-detach' => [
'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write',
'tb-write', 'roles',
],
// Library (divi-library)
'lit/library-list' => ['library-read', 'roles'],
'lit/library-find-similar' => ['page-read', 'catalog', 'roles'],
'lit/library-insert' => [
'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'roles',
],
'lit/library-make-global' => [
'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'library-write',
'roles',
],
'lit/library-save' => [
'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-write', 'roles',
],
// Theme Builder (divi-theme-builder)
'lit/tb-list' => ['tb-read', 'roles'],
'lit/tb-create-template' => ['tb-read', 'tb-write', 'roles'],
'lit/tb-assign' => ['tb-read', 'tb-write', 'roles'],
'lit/tb-activate' => ['tb-read', 'tb-write', 'roles'],
// WordPress: without the divi key it stays in the core
'lit/content-create' => [],
];
/** The compose set of a new or changed page. */
private const COMPOSE = ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'];
/**
* Input-dependent additions: label => [tool, input, capabilities for that input].
*
* @return array<string, array{0: string, 1: array<string, mixed>, 2: list<string>}>
*/
public static function inputCases(): array
{
$page = self::PAGE;
$library = self::LIBRARY_ENTRY;
$layout = self::LAYOUT;
$libraryWrite = ['roles', 'library-read', 'library-write'];
$layoutWrite = ['roles', 'tb-read', 'tb-write'];
return [
'outline of a page' => ['lit/divi-outline', ['post_id' => $page], ['page-read', 'design-read']],
'outline of a library entry' => [
'lit/divi-outline', ['post_id' => $library], ['page-read', 'design-read', 'roles'],
],
'outline of a layout' => [
'lit/divi-outline', ['post_id' => $layout], ['page-read', 'design-read', 'roles'],
],
'node-get of a library entry' => [
'lit/divi-node-get', ['post_id' => $library, 'id' => 's0'], ['page-read', 'design-read', 'roles'],
],
'outline of an unknown entry' => [
'lit/divi-outline', ['post_id' => 999], ['page-read', 'design-read'],
],
'outline without post_id' => ['lit/divi-outline', [], ['page-read', 'design-read']],
'insert into a page' => ['lit/divi-insert', ['post_id' => $page], self::COMPOSE],
'insert into a library entry' => [
'lit/divi-insert', ['post_id' => $library], array_merge(self::COMPOSE, $libraryWrite),
],
'update of a layout' => [
'lit/divi-update', ['post_id' => $layout], array_merge(self::COMPOSE, $layoutWrite),
],
'move in a layout' => [
'lit/divi-move', ['post_id' => $layout],
array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $layoutWrite),
],
'delete with a numeric string ID' => [
'lit/divi-delete', ['post_id' => (string) $library],
array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $libraryWrite),
],
'node-copy of a page into a page' => [
'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $page], self::COMPOSE,
],
'node-copy out of a library entry' => [
'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $library],
array_merge(self::COMPOSE, ['roles']),
],
'node-copy into a library entry' => [
'lit/divi-node-copy', ['post_id' => $library, 'source_post_id' => $page],
array_merge(self::COMPOSE, $libraryWrite),
],
'replace-text dry run by default' => [
'lit/divi-replace-text', ['search' => 'a', 'replace' => 'b'],
['page-read', 'catalog', 'design-read', 'dynamic-sources'],
],
'replace-text dry run explicit' => [
'lit/divi-replace-text', ['dry_run' => true, 'targets' => [['post_id' => $library]]],
['page-read', 'catalog', 'design-read', 'dynamic-sources'],
],
'replace-text on pages' => [
'lit/divi-replace-text', ['dry_run' => false, 'targets' => [['post_id' => $page, 'tree_rev' => 'x']]],
array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources'], ['page-write']),
],
'replace-text on a library entry' => [
'lit/divi-replace-text',
['dry_run' => false, 'targets' => [['post_id' => $page], ['post_id' => $library], 'bad']],
array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources', 'page-write'], $libraryWrite),
],
'presets-set without from_node' => [
'lit/divi-presets-set', ['items' => [['module' => 'button', 'name' => 'x']]],
['design-read', 'preset-write', 'catalog'],
],
'presets-set from a page node' => [
'lit/divi-presets-set', ['items' => [['from_node' => ['post_id' => $page, 'id' => 's0']]]],
['design-read', 'preset-write', 'catalog', 'page-read'],
],
'presets-set from a library node' => [
'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]],
['design-read', 'preset-write', 'catalog', 'page-read', 'roles'],
],
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
'library-list of one entry' => [
'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],
],
'library-insert into a layout' => [
'lit/library-insert', ['post_id' => $layout, 'library_id' => $library],
array_merge(
['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read'],
$layoutWrite
),
],
'tb-create-template without nodes' => [
'lit/tb-create-template', ['title' => 'T', 'header' => ['source' => 'new']],
['tb-read', 'tb-write', 'roles'],
],
'tb-create-template with nodes' => [
'lit/tb-create-template', ['title' => 'T', 'body' => ['source' => 'new', 'nodes' => [[]]]],
array_merge(['tb-read', 'tb-write', 'roles'], self::COMPOSE),
],
'tb-create-template with empty nodes' => [
'lit/tb-create-template', ['title' => 'T', 'footer' => ['source' => 'new', 'nodes' => []]],
['tb-read', 'tb-write', 'roles'],
],
'content-create without divi' => ['lit/content-create', ['post_type' => 'page'], []],
'content-create with divi' => [
'lit/content-create', ['post_type' => 'page', 'divi' => [[]]], self::COMPOSE,
],
];
}
protected function setUp(): void
{
parent::setUp();
Functions\when('__')->returnArg();
Functions\when('get_post_type')->alias(static fn (int $id): string|false => self::TYPES[$id] ?? false);
Functions\when('current_user_can')->justReturn(true);
}
/** @return array<string, Ability> name => ability of the three Divi groups and lit/content-create */
private function abilities(): array
{
$divi = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('requirements')->andReturn(self::COMPOSE);
$wordPress = new WordPressIntegration(
Mockery::mock(ContentSchema::class),
FakeChangeLog::forbidden(),
$composer
);
$found = [];
foreach ([
...$divi->abilities(),
...$divi->library()->abilities(),
...$divi->themeBuilder()->abilities(),
] as $ability) {
$found[$ability->name()] = $ability;
}
foreach ($wordPress->abilities() as $ability) {
if ($ability->name() === 'lit/content-create') {
$found[$ability->name()] = $ability;
}
}
return $found;
}
/**
* @param list<DiviFeature> $features
* @return list<string> The values, sorted.
*/
private static function values(array $features): array
{
$values = array_map(static fn (DiviFeature $feature): string => $feature->value, $features);
sort($values);
return $values;
}
/** @param list<string> $values */
private static function sorted(array $values): array
{
sort($values);
return array_values(array_unique($values));
}
public function testEveryToolOfTheDiviGroupsDeclaresItsFeatures(): void
{
$abilities = $this->abilities();
foreach ($abilities as $name => $ability) {
if ($name === 'lit/content-create') {
self::assertInstanceOf(DeclaresRequirements::class, $ability, $name);
self::assertNotInstanceOf(RequiresDiviFeatures::class, $ability, 'the core imports no Divi class');
continue;
}
self::assertInstanceOf(RequiresDiviFeatures::class, $ability, $name);
}
}
public function testEveryRowOfTheTableHasAnAbilityAndTheOtherWayRound(): void
{
$abilities = array_keys($this->abilities());
self::assertSame([], array_values(array_diff(array_keys(self::EXPECTED), $abilities)), 'row without ability');
self::assertSame([], array_values(array_diff($abilities, array_keys(self::EXPECTED))), 'ability without row');
}
public function testEveryToolDeclaresExactlyTheTableForAnInputThatNamesNothing(): void
{
foreach ($this->abilities() as $name => $ability) {
self::assertSame(
self::sorted(self::EXPECTED[$name] ?? ['<no row>']),
self::values(FeatureSets::declaredBy($ability, [])),
$name
);
}
}
/**
* @param array<string, mixed> $input
* @param list<string> $expected
* @dataProvider inputCases
*/
public function testInputDependentFeatures(string $tool, array $input, array $expected): void
{
$ability = $this->abilities()[$tool];
self::assertSame(self::sorted($expected), self::values(FeatureSets::declaredBy($ability, $input)));
}
public function testNoToolDeclaresAFeatureTwiceAndEveryIdIsAFeature(): void
{
foreach ($this->abilities() as $name => $ability) {
$declared = $ability instanceof RequiresDiviFeatures
? array_map(static fn (DiviFeature $f): string => $f->value, $ability->diviFeatures([]))
: [];
self::assertSame($declared, array_values(array_unique($declared)), $name);
}
foreach (self::EXPECTED as $name => $ids) {
foreach ($ids as $id) {
self::assertNotNull(DiviFeature::tryFrom($id), $name . ': ' . $id);
}
}
}
public function testEveryWritingToolNeedsTheWriteFeatureOfEverythingItSaves(): void
{
$writes = [
'lit/divi-insert' => 'page-write', 'lit/divi-update' => 'page-write', 'lit/divi-move' => 'page-write',
'lit/divi-delete' => 'page-write', 'lit/divi-duplicate-node' => 'page-write',
'lit/divi-node-copy' => 'page-write', 'lit/library-insert' => 'page-write',
'lit/library-make-global' => 'library-write', 'lit/library-save' => 'library-write',
'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write',
'lit/divi-design-restore' => 'preset-write', 'lit/divi-design-detach' => 'preset-write',
'lit/divi-presets-assign' => 'page-write', 'lit/tb-assign' => 'tb-write', 'lit/tb-activate' => 'tb-write',
'lit/tb-create-template' => 'tb-write',
];
foreach ($writes as $name => $feature) {
self::assertContains($feature, self::EXPECTED[$name], $name);
}
// Read-only tools never need a write capability.
foreach (['lit/divi-outline', 'lit/divi-node-get', 'lit/divi-catalog', 'lit/divi-design-system',
'lit/divi-dynamic-sources', 'lit/divi-preset-get', 'lit/library-list', 'lit/library-find-similar',
'lit/tb-list', 'lit/divi-design-snapshots'] as $name) {
foreach (self::EXPECTED[$name] as $id) {
self::assertFalse(DiviFeature::from($id)->isWrite(), $name . ': ' . $id);
}
}
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class FeatureSetsTest extends TestCase
{
/** @var array<int, string> */
private array $types = [];
private bool $mayEdit = true;
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post_type')->alias(fn (int $id): string|false => $this->types[$id] ?? false);
Functions\when('current_user_can')->alias(fn (string $capability): bool => $this->mayEdit);
}
public function testMergeKeepsEveryFeatureOnceInTheOrderOfItsFirstAppearance(): void
{
$merged = FeatureSets::merge(
[DiviFeature::Catalog, DiviFeature::Roles],
[],
[DiviFeature::Roles, DiviFeature::PageRead, DiviFeature::Catalog]
);
self::assertSame([DiviFeature::Catalog, DiviFeature::Roles, DiviFeature::PageRead], $merged);
self::assertSame([], FeatureSets::merge());
}
public function testAPageOrPostAddsNothing(): void
{
$this->types = [5 => 'page', 6 => 'post', 7 => 'project'];
foreach ([5, 6, 7, '5'] as $id) {
self::assertSame([], FeatureSets::ofEntry($id, true), (string) $id);
self::assertSame([], FeatureSets::ofEntry($id, false), (string) $id);
}
}
public function testAnIdThatNamesNoEntryAddsNothing(): void
{
foreach ([null, 0, -3, 'abc', '1.5', [], 404] as $id) {
self::assertSame([], FeatureSets::ofEntry($id, true), var_export($id, true));
}
}
public function testALibraryEntryNeedsTheRolesAndWritingAlsoTheLibrary(): void
{
$this->types = [7 => 'et_pb_layout'];
self::assertSame([DiviFeature::Roles], FeatureSets::ofEntry(7, false));
self::assertSame(
[DiviFeature::Roles, DiviFeature::LibraryRead, DiviFeature::LibraryWrite],
FeatureSets::ofEntry('7', true)
);
}
public function testEveryThemeBuilderLayoutNeedsTheRolesAndWritingAlsoTheThemeBuilder(): void
{
$this->types = [1 => 'et_header_layout', 2 => 'et_body_layout', 3 => 'et_footer_layout'];
foreach ([1, 2, 3] as $id) {
self::assertSame([DiviFeature::Roles], FeatureSets::ofEntry($id, false), (string) $id);
self::assertSame(
[DiviFeature::Roles, DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite],
FeatureSets::ofEntry($id, true),
(string) $id
);
}
}
public function testAnEntryTheCallerMayNotEditAddsNothingSoALockNeverNamesItsKind(): void
{
$this->types = [7 => 'et_pb_layout', 9 => 'et_body_layout'];
$this->mayEdit = false;
foreach ([7, 9] as $id) {
self::assertSame([], FeatureSets::ofEntry($id, true), (string) $id);
self::assertSame([], FeatureSets::ofEntry($id, false), (string) $id);
}
}
public function testTemplatesAndThemeBuilderPostsAreNoLayouts(): void
{
$this->types = [1 => 'et_template', 2 => 'et_theme_builder'];
self::assertSame([], FeatureSets::ofEntry(1, true));
self::assertSame([], FeatureSets::ofEntry(2, true));
}
public function testDeclaredByReadsTheDiviToolsOwnList(): void
{
$tool = Mockery::mock(Ability::class, RequiresDiviFeatures::class);
$tool->expects('diviFeatures')->with(['a' => 1])->andReturn([DiviFeature::Roles, DiviFeature::Roles]);
self::assertSame([DiviFeature::Roles], FeatureSets::declaredBy($tool, ['a' => 1]));
}
public function testDeclaredByTranslatesTheIdsOfACoreToolAndIgnoresUnknownOnes(): void
{
$tool = Mockery::mock(Ability::class, DeclaresRequirements::class);
$tool->expects('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']);
self::assertSame(
[DiviFeature::PageWrite, DiviFeature::Catalog],
FeatureSets::declaredBy($tool, ['divi' => []])
);
}
public function testDeclaredByIsEmptyForAnyOtherTool(): void
{
self::assertSame([], FeatureSets::declaredBy(Mockery::mock(Ability::class), ['x' => 1]));
}
}
@@ -94,6 +94,23 @@ final class ContentCreateDiviTest extends TestCase
); );
} }
public function testRequirementsAreEmptyWithoutDiviWhateverTheComposerNeeds(): void
{
$composer = $this->composer();
$composer->shouldNotReceive('requirements');
self::assertSame([], $this->ability($composer)->requirements(['post_type' => 'page', 'title' => 'Start']));
self::assertSame([], $this->ability(null)->requirements(self::INPUT), 'no composer: the call is refused anyway');
}
public function testRequirementsWithDiviAreThoseOfTheComposer(): void
{
$composer = $this->composer();
$composer->expects('requirements')->andReturn(['page-write', 'catalog']);
self::assertSame(['page-write', 'catalog'], $this->ability($composer)->requirements(self::INPUT));
}
public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void
{ {
$composer = $this->composer(); $composer = $this->composer();
@@ -342,6 +359,11 @@ final class ContentCreateDiviTest extends TestCase
return null; return null;
} }
public function requirements(): array
{
return $this->real->requirements();
}
public function check(string $postType, array $sections): ?WP_Error public function check(string $postType, array $sections): ?WP_Error
{ {
return $this->real->check($postType, $sections); return $this->real->check($postType, $sections);