diff --git a/src/Integrations/Core/EnvironmentProbe.php b/src/Integrations/Core/EnvironmentProbe.php index aca8d8e..b2984ec 100644 --- a/src/Integrations/Core/EnvironmentProbe.php +++ b/src/Integrations/Core/EnvironmentProbe.php @@ -4,14 +4,21 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Core; +use LIT\MCP\Integrations\Divi\DiviCompatibility; + defined('ABSPATH') || exit; /** - * Detects the themes/plugins LIT MCP integrates with. + * Detects the themes/plugins LIT MCP integrates with; for Divi also the internals that are missing + * (DiviCompatibility). */ class EnvironmentProbe { - /** @return list */ + public function __construct(private ?DiviCompatibility $divi = null) + { + } + + /** @return list}> */ public function components(): array { $theme = wp_get_theme(); @@ -22,16 +29,23 @@ class EnvironmentProbe $tecActive = class_exists('Tribe__Events__Main'); return [ - ['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion], + [ + 'id' => 'divi', + 'active' => $isDivi, + 'version' => $diviVersion, + 'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [], + ], [ 'id' => 'pods', 'active' => defined('PODS_VERSION'), 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + 'problems' => [], ], [ 'id' => 'the-events-calendar', 'active' => $tecActive, 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, + 'problems' => [], ], ]; } diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php index 2313c7c..e1212fb 100644 --- a/src/Integrations/Core/SiteStatusAbility.php +++ b/src/Integrations/Core/SiteStatusAbility.php @@ -30,7 +30,8 @@ final class SiteStatusAbility implements Ability public function description(): string { return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, ' - . 'erkannte Integrationen (Divi, Pods, The Events Calendar).'; + . 'erkannte Integrationen (Divi, Pods, The Events Calendar). ' + . 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.'; } public function inputSchema(): array diff --git a/src/Integrations/Divi/DiviCompatibility.php b/src/Integrations/Divi/DiviCompatibility.php new file mode 100644 index 0000000..434ad87 --- /dev/null +++ b/src/Integrations/Divi/DiviCompatibility.php @@ -0,0 +1,61 @@ +|null $checks Label => check; null = defaults(). */ + public function __construct(private ?array $checks = null) + { + } + + /** @return list Labels of the checks that failed or threw. */ + public function problems(): array + { + $problems = []; + foreach ($this->checks ?? self::defaults() as $label => $check) { + try { + $ok = (bool) $check(); + } catch (Throwable) { + $ok = false; + } + if (! $ok) { + $problems[] = (string) $label; + } + } + + return $problems; + } + + /** @return array */ + public static function defaults(): array + { + return [ + 'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(), + 'DynamicContentOptions (Dynamic Content)' => + static fn (): bool => class_exists(DynamicContentMap::REGISTRY), + 'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'), + 'Post-Typ et_pb_layout (Bibliothek)' => + static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE), + 'Taxonomien scope, layout_type, module_width (Bibliothek)' => + static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [], + 'GlobalLayout::is_global_layout_template (globale Module)' => + static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'), + 'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'), + ]; + } +} diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php new file mode 100644 index 0000000..c393d22 --- /dev/null +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -0,0 +1,152 @@ +termSlugs($id, 'scope'), true); + } + + /** "layout", "section", "row" or "module"; null when the entry has no known type. */ + public function layoutType(int $id): ?string + { + foreach ($this->termSlugs($id, 'layout_type') as $slug) { + if (in_array($slug, self::LAYOUT_TYPES, true)) { + return $slug; + } + } + + return null; + } + + /** @return list */ + public function termSlugs(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy)); + } + + /** @return list */ + public function termNames(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy)); + } + + /** + * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). + * + * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters + * scope: "global" or "normal"; category: name of a layout_category term. + * @return list + */ + public function find(array $filters, int $limit, int $offset): array + { + $taxQuery = []; + if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) { + $taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]]; + } + if (($filters['scope'] ?? '') === 'global') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']]; + } elseif (($filters['scope'] ?? '') === 'normal') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN']; + } + if (($filters['category'] ?? '') !== '') { + $taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]]; + } + + $args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + if (($filters['search'] ?? '') !== '') { + $args['s'] = $filters['search']; + } + if ($taxQuery !== []) { + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. + $args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery); + } + + return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post)); + } + + /** + * @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string, + * categories: list, tags: list, modified: string} + */ + public function describe(WP_Post $post): array + { + $id = (int) $post->ID; + $moduleType = (string) get_post_meta($id, '_et_pb_module_type', true); + + return [ + 'id' => $id, + 'title' => (string) $post->post_title, + 'layout_type' => $this->layoutType($id), + 'scope' => $this->isGlobal($id) ? 'global' : 'normal', + 'module_type' => $moduleType === '' ? null : $moduleType, + 'categories' => $this->termNames($id, 'layout_category'), + 'tags' => $this->termNames($id, 'layout_tag'), + 'modified' => (string) $post->post_modified_gmt, + ]; + } + + /** + * Library taxonomies not registered in this request (F2: Divi registers them in + * ET_Builder_Library; fixture finding 6: not in every context). + * + * @return list + */ + public function missingTaxonomies(): array + { + return array_values(array_filter( + self::TAXONOMIES, + static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy) + )); + } + + /** @return list */ + private function terms(int $id, string $taxonomy): array + { + $terms = get_the_terms($id, $taxonomy); + + return is_array($terms) + ? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term)) + : []; + } +} diff --git a/src/Integrations/Divi/Library/DiviRoles.php b/src/Integrations/Divi/Library/DiviRoles.php new file mode 100644 index 0000000..1adc241 --- /dev/null +++ b/src/Integrations/Divi/Library/DiviRoles.php @@ -0,0 +1,56 @@ + 'Divi-Bibliothek', + 'save_library' => 'In der Bibliothek speichern', + 'add_library' => 'Aus der Bibliothek hinzufügen', + 'edit_global_library' => 'Globale Bibliothekselemente bearbeiten', + 'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen', + ]; + + /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ + public function __construct(private ?Closure $check = null) + { + } + + public function allows(string $capability): bool + { + if ($this->check !== null) { + return ($this->check)($capability); + } + + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability); + } + + /** Null when the role allows $capability, else a forbidden error naming the option. */ + public function refusal(string $capability): ?WP_Error + { + if ($this->allows($capability)) { + return null; + } + + return Errors::forbidden( + sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability), + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } +} diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 10f0c6a..941ef6c 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -31,7 +31,8 @@ defined('ABSPATH') || exit; */ class DynamicContentMap { - private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; + /** Divi's dynamic-content registry class; also checked by DiviCompatibility. */ + public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; /** * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's. diff --git a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php new file mode 100644 index 0000000..2dfb442 --- /dev/null +++ b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php @@ -0,0 +1,29 @@ +allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])); + $components = array_column($probe->components(), null, 'id'); + + self::assertSame(['GlobalLayout'], $components['divi']['problems']); + self::assertSame([], $components['pods']['problems']); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php new file mode 100644 index 0000000..f3840e7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php @@ -0,0 +1,41 @@ + static fn (): bool => true, + 'B' => static fn (): bool => false, + 'C' => static function (): bool { + throw new RuntimeException('Divi changed'); + }, + ]); + + self::assertSame(['B', 'C'], $compatibility->problems()); + } + + public function testDefaultsCoverTheDiviInternalsInUse(): void + { + self::assertSame( + [ + 'ModuleRegistration (Modul-Katalog)', + 'DynamicContentOptions (Dynamic Content)', + 'et_pb_is_allowed (Divi-Rollen)', + 'Post-Typ et_pb_layout (Bibliothek)', + 'Taxonomien scope, layout_type, module_width (Bibliothek)', + 'GlobalLayout::is_global_layout_template (globale Module)', + 'ET_Core_PageResource (CSS-Cache)', + ], + array_keys(DiviCompatibility::defaults()) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php new file mode 100644 index 0000000..9304c4d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -0,0 +1,102 @@ + [ + 'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + 'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])], + ], + 1055 => [ + 'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + ], + 1057 => [], + ]; + Functions\when('get_the_terms')->alias( + static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : '' + ); + } + + public function testGlobalScopeAndLayoutTypeFromTheTerms(): void + { + $store = new DiviLibraryStore(); + + self::assertTrue($store->isGlobal(1058)); + self::assertFalse($store->isGlobal(1055)); + self::assertSame('section', $store->layoutType(1058)); + self::assertNull($store->layoutType(1057)); + self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category')); + } + + public function testDescribesAnEntry(): void + { + $post = new WP_Post([ + 'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul', + 'post_modified_gmt' => '2026-09-26 20:56:29', + ]); + + self::assertSame( + [ + 'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal', + 'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29', + ], + (new DiviLibraryStore())->describe($post) + ); + } + + public function testFindQueriesPublishedEntriesWithFilters(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find( + ['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'], + 20, + 40 + ); + + self::assertSame('et_pb_layout', $captured['post_type']); + self::assertSame('publish', $captured['post_status']); + self::assertSame(21, $captured['posts_per_page']); + self::assertSame(40, $captured['offset']); + self::assertSame('Kopf', $captured['s']); + self::assertSame( + [ + 'relation' => 'AND', + ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']], + ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'], + ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']], + ], + $captured['tax_query'] + ); + } + + public function testReportsMissingTaxonomies(): void + { + Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); + + self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies()); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php b/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php new file mode 100644 index 0000000..876e5cf --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php @@ -0,0 +1,29 @@ + $cap !== 'edit_global_library'); + + self::assertTrue($roles->allows('divi_library')); + self::assertNull($roles->refusal('divi_library')); + $refusal = $roles->refusal('edit_global_library'); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message()); + self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message()); + } + + public function testWithoutDiviNothingIsAllowed(): void + { + // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". + self::assertFalse((new DiviRoles())->allows('divi_library')); + } +}