From fdbda8012cceb79c759e8a10b49d406424353e7b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 22:14:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20jedes=20Divi-Tool=20und=20lit/content-c?= =?UTF-8?q?reate=20erkl=C3=A4rt=20seine=20Divi-F=C3=A4higkeiten=20(Require?= =?UTF-8?q?sDiviFeatures,=20DeclaresRequirements,=20FeatureSets);=20Tabell?= =?UTF-8?q?e=20Tool=20=E2=86=92=20F=C3=A4higkeiten=20als=20Test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Abilities/DeclaresRequirements.php | 21 ++ .../Divi/Design/DiviDesignDetachAbility.php | 23 +- .../Divi/Design/DiviDesignRestoreAbility.php | 10 +- .../Design/DiviDesignSnapshotsAbility.php | 10 +- .../Divi/Design/DiviDesignSystemAbility.php | 10 +- .../Divi/Design/DiviPresetGetAbility.php | 10 +- .../Divi/Design/DiviPresetsAssignAbility.php | 26 +- .../Divi/Design/DiviPresetsDeleteAbility.php | 10 +- .../Divi/Design/DiviPresetsSetAbility.php | 26 +- .../Library/LibraryFindSimilarAbility.php | 13 +- .../Divi/Library/LibraryInsertAbility.php | 18 + .../Divi/Library/LibraryListAbility.php | 17 +- .../Divi/Library/LibraryMakeGlobalAbility.php | 23 +- .../Divi/Library/LibrarySaveAbility.php | 22 +- .../Divi/Pages/DiviContentComposer.php | 19 + .../Divi/Pages/DiviDeleteAbility.php | 7 + .../Divi/Pages/DiviDuplicateNodeAbility.php | 7 + .../Divi/Pages/DiviDynamicSourcesAbility.php | 13 +- .../Divi/Pages/DiviInsertAbility.php | 16 + .../Divi/Pages/DiviMoveAbility.php | 10 + .../Divi/Pages/DiviNodeCopyAbility.php | 25 ++ .../Divi/Pages/DiviNodeGetAbility.php | 17 +- .../Divi/Pages/DiviOutlineAbility.php | 17 +- .../Divi/Pages/DiviReplaceTextAbility.php | 33 +- .../Divi/Pages/DiviUpdateAbility.php | 13 + .../Divi/Pages/DiviWriteAbility.php | 18 +- src/Integrations/Divi/Release/FeatureSets.php | 100 +++++ .../Divi/Release/RequiresDiviFeatures.php | 24 ++ .../Divi/Schema/DiviCatalogAbility.php | 10 +- .../Divi/ThemeBuilder/TbActivateAbility.php | 10 +- .../Divi/ThemeBuilder/TbAssignAbility.php | 10 +- .../ThemeBuilder/TbCreateTemplateAbility.php | 28 +- .../Divi/ThemeBuilder/TbListAbility.php | 10 +- .../WordPress/Content/ContentComposer.php | 9 + .../Content/ContentCreateAbility.php | 12 +- .../Divi/Pages/DiviContentComposerTest.php | 12 + .../Divi/Release/DiviToolFeaturesTest.php | 347 ++++++++++++++++++ .../Divi/Release/FeatureSetsTest.php | 126 +++++++ .../Content/ContentCreateDiviTest.php | 22 ++ 39 files changed, 1131 insertions(+), 23 deletions(-) create mode 100644 src/Abilities/DeclaresRequirements.php create mode 100644 src/Integrations/Divi/Release/FeatureSets.php create mode 100644 src/Integrations/Divi/Release/RequiresDiviFeatures.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php diff --git a/src/Abilities/DeclaresRequirements.php b/src/Abilities/DeclaresRequirements.php new file mode 100644 index 0000000..85d7751 --- /dev/null +++ b/src/Abilities/DeclaresRequirements.php @@ -0,0 +1,21 @@ + $input + * @return list IDs of the capabilities the call needs for $input ([] = it stays in the core). + */ + public function requirements(array $input): array; +} diff --git a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php index d6d837e..ba7e2db 100644 --- a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php @@ -12,7 +12,9 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; 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\RequiresDiviFeatures; use LIT\MCP\Log; 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 * (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.'; @@ -113,6 +115,25 @@ final class DiviDesignDetachAbility implements Ability 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 { return DesignRights::presets($this->writer) ?? true; diff --git a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php index ac50d73..eaf0126 100644 --- a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php @@ -8,6 +8,8 @@ use Closure; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use WP_Error; 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 * 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'; @@ -76,6 +78,12 @@ final class DiviDesignRestoreAbility implements Ability 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 { return DesignRights::presets($this->writer) ?? true; diff --git a/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php b/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php index 96da6ea..7062d66 100644 --- a/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php @@ -8,12 +8,14 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Cursor; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use WP_Error; defined('ABSPATH') || exit; /** 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) { @@ -70,6 +72,12 @@ final class DiviDesignSnapshotsAbility implements Ability 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 { return current_user_can('edit_theme_options') diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php index 065d50c..dc1f99e 100644 --- a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Design; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use WP_Error; 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 * Etappe 4. */ -final class DiviDesignSystemAbility implements Ability +final class DiviDesignSystemAbility implements Ability, RequiresDiviFeatures { /** Presets per answer (ruling S19); narrow with "module". */ public const PRESET_LIMIT = 200; @@ -121,6 +123,12 @@ final class DiviDesignSystemAbility implements Ability return true; } + /** Only the design system reader. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead]; + } + public function permission(array $input): bool|WP_Error { return current_user_can('edit_posts') diff --git a/src/Integrations/Divi/Design/DiviPresetGetAbility.php b/src/Integrations/Divi/Design/DiviPresetGetAbility.php index cd38f56..2e4b5f5 100644 --- a/src/Integrations/Divi/Design/DiviPresetGetAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetGetAbility.php @@ -11,6 +11,8 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; 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 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 * 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) { @@ -93,6 +95,12 @@ final class DiviPresetGetAbility implements Ability 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 { return current_user_can('edit_posts') diff --git a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php index 7d226df..4d87c3e 100644 --- a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php @@ -10,6 +10,8 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\ToolResult; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use WP_Error; 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. * 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. */ private const ROOT = 'input'; @@ -116,6 +118,28 @@ final class DiviPresetsAssignAbility implements Ability 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 { return DesignRights::presets($this->writer) ?? true; diff --git a/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php b/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php index d2ba25c..d121302 100644 --- a/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php @@ -10,6 +10,8 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use WP_Error; 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 * 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; @@ -97,6 +99,12 @@ final class DiviPresetsDeleteAbility implements Ability 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 { return DesignRights::presets($this->writer) ?? true; diff --git a/src/Integrations/Divi/Design/DiviPresetsSetAbility.php b/src/Integrations/Divi/Design/DiviPresetsSetAbility.php index a5eaf62..7ccbff4 100644 --- a/src/Integrations/Divi/Design/DiviPresetsSetAbility.php +++ b/src/Integrations/Divi/Design/DiviPresetsSetAbility.php @@ -12,6 +12,9 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; 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; 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 * (ruling R11). */ -final class DiviPresetsSetAbility implements Ability +final class DiviPresetsSetAbility implements Ability, RequiresDiviFeatures { public const MAX_ITEMS = 20; @@ -121,6 +124,27 @@ final class DiviPresetsSetAbility implements Ability 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 { return DesignRights::presets($this->writer) ?? true; diff --git a/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php b/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php index a162d31..4267900 100644 --- a/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php +++ b/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php @@ -18,6 +18,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Pages\DiviErrors; 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\WordPress\PostGuard; 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 * 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 DEFAULT_POSTS = 100; @@ -121,6 +123,15 @@ final class LibraryFindSimilarAbility implements Ability 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 { return $this->access->canList() ?? true; diff --git a/src/Integrations/Divi/Library/LibraryInsertAbility.php b/src/Integrations/Divi/Library/LibraryInsertAbility.php index 08462c8..1510248 100644 --- a/src/Integrations/Divi/Library/LibraryInsertAbility.php +++ b/src/Integrations/Divi/Library/LibraryInsertAbility.php @@ -19,6 +19,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviSchema; use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\WordPress\PostGuard; use WP_Error; use WP_Post; @@ -103,6 +104,23 @@ final class LibraryInsertAbility extends DiviWriteAbility 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 { $mode = $input['mode'] ?? null; diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index 48f8862..164acb4 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -13,6 +13,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Pages\DiviPage; 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 WP_Error; use WP_Post; @@ -27,7 +29,7 @@ defined('ABSPATH') || exit; * left out, without title or count). With id, * 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. */ public const MAX_SCAN = 500; @@ -121,6 +123,19 @@ final class LibraryListAbility implements Ability 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 { if (isset($input['id'])) { diff --git a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php index e06e26c..6666a7b 100644 --- a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php +++ b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php @@ -23,6 +23,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviSchema; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; 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\PostGuard; 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 * 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 ' . 'entfernt.'; @@ -176,6 +178,25 @@ final class LibraryMakeGlobalAbility implements Ability 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 { return $this->precheck($input) ?? true; diff --git a/src/Integrations/Divi/Library/LibrarySaveAbility.php b/src/Integrations/Divi/Library/LibrarySaveAbility.php index eae87be..a12e1aa 100644 --- a/src/Integrations/Divi/Library/LibrarySaveAbility.php +++ b/src/Integrations/Divi/Library/LibrarySaveAbility.php @@ -17,6 +17,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; 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\WordPress\Content\NewDraft; 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 * 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']; @@ -112,6 +114,24 @@ final class LibrarySaveAbility implements Ability 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 { $refusal = $this->access->canSave(self::isGlobal($input)); diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index acfe0b6..aac0373 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; 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\Schema\DiviModules; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; @@ -36,6 +37,19 @@ defined('ABSPATH') || exit; */ 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 */ private Closure $writable; @@ -60,6 +74,11 @@ final class DiviContentComposer implements ContentComposer return ($this->writable)(); } + public function requirements(): array + { + return array_map(static fn (DiviFeature $feature): string => $feature->value, self::FEATURES); + } + public function refusal(): ?WP_Error { $status = ReleaseGate::status(); diff --git a/src/Integrations/Divi/Pages/DiviDeleteAbility.php b/src/Integrations/Divi/Pages/DiviDeleteAbility.php index 0d52bea..5dbd777 100644 --- a/src/Integrations/Divi/Pages/DiviDeleteAbility.php +++ b/src/Integrations/Divi/Pages/DiviDeleteAbility.php @@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; defined('ABSPATH') || exit; @@ -60,6 +61,12 @@ final class DiviDeleteAbility extends DiviWriteAbility 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 { /** @var array $nodes */ diff --git a/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php b/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php index c39d8f4..5e220f2 100644 --- a/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php +++ b/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; defined('ABSPATH') || exit; @@ -55,6 +56,12 @@ final class DiviDuplicateNodeAbility extends DiviWriteAbility 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 { $id = is_string($input['id'] ?? null) ? $input['id'] : ''; diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php index f428349..4189f13 100644 --- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -11,6 +11,8 @@ use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Integrations\Divi\Compose\Placeholders; 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\Schema\ContentSchema; 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 * 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. */ private const SAMPLE_CANDIDATES = 10; @@ -130,6 +132,15 @@ final class DiviDynamicSourcesAbility implements Ability 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 { $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php index 3aa0250..8a284dc 100644 --- a/src/Integrations/Divi/Pages/DiviInsertAbility.php +++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; defined('ABSPATH') || exit; @@ -64,6 +65,21 @@ final class DiviInsertAbility extends DiviWriteAbility 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 { $editor = $composition->editor(); diff --git a/src/Integrations/Divi/Pages/DiviMoveAbility.php b/src/Integrations/Divi/Pages/DiviMoveAbility.php index c60de18..2e2d253 100644 --- a/src/Integrations/Divi/Pages/DiviMoveAbility.php +++ b/src/Integrations/Divi/Pages/DiviMoveAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; defined('ABSPATH') || exit; @@ -52,6 +53,15 @@ final class DiviMoveAbility extends DiviWriteAbility 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 { $id = is_string($input['id'] ?? null) ? $input['id'] : ''; diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php index ec99827..035639f 100644 --- a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Pages; use Closure; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\FeatureSets; use WP_Error; defined('ABSPATH') || exit; @@ -117,6 +119,29 @@ final class DiviNodeCopyAbility extends DiviWriteAbility 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 { $source = $this->loader->load($input['source_post_id'] ?? null, false); diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index ee713c1..4733c3f 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -14,6 +14,9 @@ use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; 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 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) – * 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) { @@ -123,6 +126,18 @@ final class DiviNodeGetAbility implements Ability 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 { $page = $this->loader->load($input['post_id'] ?? null, false); diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index acb4e51..4ad865e 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -11,6 +11,9 @@ use LIT\MCP\Abilities\Rev; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; 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; 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 * 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) { @@ -137,6 +140,18 @@ final class DiviOutlineAbility implements Ability 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 { $page = $this->loader->load($input['post_id'] ?? null, false); diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 673f424..aafe2a7 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -13,6 +13,9 @@ use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\BlockTree\Block; 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\Schema\ContentSchema; use LIT\MCP\Log; @@ -36,7 +39,7 @@ defined('ABSPATH') || exit; * others go on. Inserted text is cleaned by the field kind through * 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; @@ -128,6 +131,34 @@ final class DiviReplaceTextAbility implements Ability 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 { $type = PostGuard::postType($input['post_type'] ?? 'page', $this->schema, 'edit_posts'); diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 1d1d231..7c35263 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\Compose\ColumnWidths; use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; defined('ABSPATH') || exit; @@ -101,6 +102,18 @@ final class DiviUpdateAbility extends DiviWriteAbility 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 { $changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : []; diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index 3f6a965..3da25a4 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Ability; 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; defined('ABSPATH') || exit; @@ -17,7 +20,7 @@ defined('ABSPATH') || exit; * 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). */ -abstract class DiviWriteAbility implements Ability +abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures { 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; + /** + * 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 + */ + 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 { return ToolResult::schema([ diff --git a/src/Integrations/Divi/Release/FeatureSets.php b/src/Integrations/Divi/Release/FeatureSets.php new file mode 100644 index 0000000..24bfbee --- /dev/null +++ b/src/Integrations/Divi/Release/FeatureSets.php @@ -0,0 +1,100 @@ + $input + * @return list + */ + 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 ...$lists + * @return list 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 + */ + 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]; + } +} diff --git a/src/Integrations/Divi/Release/RequiresDiviFeatures.php b/src/Integrations/Divi/Release/RequiresDiviFeatures.php new file mode 100644 index 0000000..3a01c7f --- /dev/null +++ b/src/Integrations/Divi/Release/RequiresDiviFeatures.php @@ -0,0 +1,24 @@ + $input + * @return list + */ + public function diviFeatures(array $input): array; +} diff --git a/src/Integrations/Divi/Schema/DiviCatalogAbility.php b/src/Integrations/Divi/Schema/DiviCatalogAbility.php index fe71199..cd178d6 100644 --- a/src/Integrations/Divi/Schema/DiviCatalogAbility.php +++ b/src/Integrations/Divi/Schema/DiviCatalogAbility.php @@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\JsonMap; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use WP_Error; defined('ABSPATH') || exit; @@ -16,7 +18,7 @@ defined('ABSPATH') || exit; * 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). */ -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 ' . 'Divi-Builder nicht visuell bearbeitbar.'; @@ -116,6 +118,12 @@ final class DiviCatalogAbility implements Ability 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 { return current_user_can('edit_posts') diff --git a/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php b/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php index 218d11f..2c79368 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php @@ -12,6 +12,8 @@ use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use Throwable; 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 * 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.'; @@ -97,6 +99,12 @@ final class TbActivateAbility implements Ability 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 { $template = $this->access->changeableTemplate($input, ($input['enabled'] ?? null) === false); diff --git a/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php b/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php index e6d1a07..9b6a0d5 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php @@ -12,6 +12,8 @@ use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use Throwable; 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 * 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.'; @@ -103,6 +105,12 @@ final class TbAssignAbility implements Ability 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 { $template = $this->access->changeableTemplate($input); diff --git a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php index 21e9170..857be64 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php @@ -17,6 +17,9 @@ use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder; 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 Throwable; 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): * 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']; @@ -127,6 +130,29 @@ final class TbCreateTemplateAbility implements Ability 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 { return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true; diff --git a/src/Integrations/Divi/ThemeBuilder/TbListAbility.php b/src/Integrations/Divi/ThemeBuilder/TbListAbility.php index 63fb4a4..3e66d11 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbListAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbListAbility.php @@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; 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 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 * 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; @@ -97,6 +99,12 @@ final class TbListAbility implements Ability 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 { return $this->access->canUse() ?? true; diff --git a/src/Integrations/WordPress/Content/ContentComposer.php b/src/Integrations/WordPress/Content/ContentComposer.php index eec00f0..f5cb59a 100644 --- a/src/Integrations/WordPress/Content/ContentComposer.php +++ b/src/Integrations/WordPress/Content/ContentComposer.php @@ -35,6 +35,15 @@ interface ContentComposer */ 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 + */ + public function requirements(): array; + /** * Validates the description for a new entry of $postType. Nothing is written. * diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index da1477c..f33eeb7 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; +use LIT\MCP\Abilities\DeclaresRequirements; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; 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 * 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). */ 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; } + /** + * 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 { $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts'); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php index ea7475b..8020c92 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; 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\DiviWriterFixture; 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 { self::assertNull($this->composer()->check('page', self::sections())); diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php new file mode 100644 index 0000000..09486d5 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php @@ -0,0 +1,347 @@ + 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, 2: list}> + */ + 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 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 $features + * @return list 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 $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] ?? ['']), + self::values(FeatureSets::declaredBy($ability, [])), + $name + ); + } + } + + /** + * @param array $input + * @param list $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); + } + } + } +} diff --git a/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php b/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php new file mode 100644 index 0000000..81b0f4f --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php @@ -0,0 +1,126 @@ + */ + 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])); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php index 691e2a6..a75539c 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -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 { $composer = $this->composer(); @@ -342,6 +359,11 @@ final class ContentCreateDiviTest extends TestCase return null; } + public function requirements(): array + { + return $this->real->requirements(); + } + public function check(string $postType, array $sections): ?WP_Error { return $this->real->check($postType, $sections);