diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index c5d57a8..24da786 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -25,7 +25,10 @@ final class NodeSummary public static function label(Block $block): string { - $label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel'); + $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null) + ? $block->attrs()['localAttrs'] + : $block->attrs(); + $label = AttrMap::desktop($attrs, 'module.meta.adminLabel'); if (! is_string($label) || $label === '') { return ''; } @@ -55,6 +58,29 @@ final class NodeSummary return ''; } + /** + * Library entry of a global module: a divi/global-layout instance (its root type in blockName) + * or a block that carries globalModule itself (Divi 4 conversions). Null for every other block. + * + * @return array{id: string, block_name: string}|null + */ + public static function globalRef(Block $block): ?array + { + if (! $block->isGlobal()) { + return null; + } + + $attrs = $block->attrs(); + $id = $attrs['globalModule'] ?? null; + if (! is_string($id) && ! is_int($id)) { + return null; + } + + $name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name(); + + return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : '']; + } + /** @return list */ public static function presetIds(Block $block): array { diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index a364371..5d7920a 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -59,7 +59,8 @@ final class Outline /** * @return list, children: int, editable: bool, depth: int, truncated: bool}> + * presets: list, children: int, editable: bool, depth: int, truncated: bool, + * global_id: ?string}> */ public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array { @@ -114,6 +115,7 @@ final class Outline foreach ($rows as $row) { $line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type']; $line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : ''; + $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; @@ -127,15 +129,23 @@ final class Outline /** * @return array{id: string, type: string, label: string, excerpt: string, info: string, - * presets: list, children: int, editable: bool, depth: int, truncated: bool} + * presets: list, children: int, editable: bool, depth: int, truncated: bool, + * global_id: ?string} */ private function row(Block $block, string $id, int $depth): array { $editable = $block->isDivi() && ! $block->isOpaque(); + $global = NodeSummary::globalRef($block); + $type = $block->isDivi() ? $block->shortName() : $block->name(); + if ($global !== null && $global['block_name'] !== '') { + $type = str_starts_with($global['block_name'], 'divi/') + ? substr($global['block_name'], 5) + : $global['block_name']; + } return [ 'id' => $id, - 'type' => $block->isDivi() ? $block->shortName() : $block->name(), + 'type' => $type, 'label' => NodeSummary::label($block), 'excerpt' => NodeSummary::excerpt($block), 'info' => $editable ? NodeSummary::info($block) : '', @@ -147,6 +157,7 @@ final class Outline 'editable' => $editable, 'depth' => $depth, 'truncated' => false, + 'global_id' => $global['id'] ?? null, ]; } } diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php index a85f7fe..1ecf8e8 100644 --- a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -18,6 +18,11 @@ defined('ABSPATH') || exit; */ final class DiviDesignSystemAbility implements Ability { + /** Presets per answer (ruling S19); narrow with "module". */ + public const PRESET_LIMIT = 200; + + private const PARTS = ['colors', 'variables', 'presets']; + public function __construct(private DesignSystemReader $reader) { } @@ -36,12 +41,28 @@ final class DiviDesignSystemAbility implements Ability { return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und ' . '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, ' - . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.'; + . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen. ' + . 'Filter: only (colors/variables/presets) und module (Presets eines Moduls).'; } public function inputSchema(): array { - return ['type' => 'object', 'properties' => [], 'additionalProperties' => false]; + return [ + 'type' => 'object', + 'properties' => [ + 'only' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::PARTS], + 'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer ' + . 'dabei.', + ], + 'module' => [ + 'type' => 'string', + 'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".', + ], + ], + 'additionalProperties' => false, + ]; } public function outputSchema(): ?array @@ -110,26 +131,52 @@ final class DiviDesignSystemAbility implements Ability public function execute(array $input): mixed { $design = $this->reader->read(); + $only = is_array($input['only'] ?? null) && $input['only'] !== [] + ? array_values(array_intersect(self::PARTS, $input['only'])) + : self::PARTS; + $notes = [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ]; + $skipped = array_values(array_diff(self::PARTS, $only)); + if ($skipped !== []) { + $notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.'; + } + + $presets = $design->presets(); + $module = is_string($input['module'] ?? null) ? trim($input['module']) : ''; + if ($module !== '') { + $name = str_contains($module, '/') ? $module : 'divi/' . $module; + $presets = array_values(array_filter( + $presets, + static fn (array $preset): bool => $preset['module'] === $name + )); + } + if (count($presets) > self::PRESET_LIMIT) { + $notes[] = sprintf( + '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.', + self::PRESET_LIMIT, + count($presets) + ); + $presets = array_slice($presets, 0, self::PRESET_LIMIT); + } return [ - 'colors' => array_map( + 'colors' => in_array('colors', $only, true) ? array_map( static fn (array $color): array => $color + [ 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), ], $design->colors() - ), - 'variables' => array_map( + ) : [], + 'variables' => in_array('variables', $only, true) ? array_map( static fn (array $variable): array => $variable + ['placeholder' => '{{var:' . $variable['label'] . '}}'], $design->variables() - ), + ) : [], 'fonts' => $design->fonts(), 'sizes' => $design->sizes(), - 'presets' => $design->presets(), - 'notes' => [ - 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', - 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', - ], + 'presets' => in_array('presets', $only, true) ? $presets : [], + 'notes' => $notes, ]; } } diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index 425bff7..fd1f761 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -85,6 +85,7 @@ final class DiviNodeGetAbility implements Ability ], 'builder_version' => ['type' => ['string', 'null']], 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + 'global' => ['type' => ['object', 'null']], ], 'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], ]; @@ -128,6 +129,16 @@ final class DiviNodeGetAbility implements Ability $editable = $block->isDivi() && ! $block->isOpaque(); $version = $block->attrs()['builderVersion'] ?? null; + $global = NodeSummary::globalRef($block); + if ($global !== null) { + $global['hint'] = sprintf( + 'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); ' + . 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.', + $global['id'], + $global['id'] + ); + } + return [ 'post_id' => (int) $page->post->ID, 'tree_rev' => $page->treeRev(), @@ -147,6 +158,7 @@ final class DiviNodeGetAbility implements Ability 'children' => $editable ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) : [], + 'global' => $global, ]; } } diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 7151b50..70026d9 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -98,10 +98,14 @@ final class DiviOutlineAbility implements Ability 'type' => 'boolean', 'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.', ], + 'global_id' => [ + 'type' => ['string', 'null'], + 'description' => 'Bibliotheks-ID eines globalen Moduls.', + ], ], 'required' => [ 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', - 'truncated', + 'truncated', 'global_id', ], ], ], @@ -187,7 +191,8 @@ final class DiviOutlineAbility implements Ability if (in_array(false, array_column($rows, 'editable'), true)) { $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' - . 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.'; + . 'verschieben, duplizieren oder löschen. Globale Module [global ] im Bibliothekseintrag ' + . 'bearbeiten: lit-divi-outline mit post_id – die Änderung wirkt auf alle Seiten.'; } return $hints; diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 465bb19..d3a0155 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; use stdClass; @@ -26,7 +27,7 @@ final class OutlineTest extends TestCase self::assertSame( ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3, - 'truncated' => false], + 'truncated' => false, 'global_id' => null], $rows[5] ); self::assertSame('Spalten 8_24+16_24', $rows[1]['info']); @@ -40,6 +41,22 @@ final class OutlineTest extends TestCase ); } + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $outline = new Outline(); + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + + $byId = array_column($rows, null, 'id'); + self::assertSame('1058', $byId['g0']['global_id']); + self::assertSame('section', $byId['g0']['type']); + self::assertFalse($byId['g0']['editable']); + self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']); + self::assertSame('text', $byId['s0.r0.c0.g0']['type']); + self::assertNull($byId['s0']['global_id']); + self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows)); + } + public function testSubtreeAndDepthLimit(): void { $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); @@ -104,7 +121,7 @@ final class OutlineTest extends TestCase $text = (new Outline())->text((new Outline())->rows($index)); self::assertStringContainsString('x0 core/paragraph (nur lesbar)', $text); - self::assertStringContainsString('s1.g1 row "Globale Zeile" (nur lesbar)', $text); + self::assertStringContainsString('s1.g1 row "Globale Zeile" [global 913] (nur lesbar)', $text); self::assertStringContainsString('s0 section [specialty]', $text); self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text); } diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php index 6ef4d0e..26b7cc7 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Design\DesignSystem; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Tests\Support\DiviFixtures; @@ -41,4 +42,38 @@ final class DiviDesignSystemAbilityTest extends TestCase self::assertToolError($result, 'forbidden'); } + + public function testFiltersPartsAndPresetsOfOneModule(): void + { + Functions\when('current_user_can')->justReturn(true); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); + $ability = new DiviDesignSystemAbility($reader); + + $result = $ability->execute(['only' => ['presets'], 'module' => 'button']); + + self::assertSame([], $result['colors']); + self::assertSame([], $result['variables']); + self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id')); + self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module')))); + self::assertNotEmpty($result['fonts']['heading']); + self::assertContains('Nicht angefordert: colors, variables.', $result['notes']); + } + + public function testCapsThePresetList(): void + { + Functions\when('current_user_can')->justReturn(true); + $items = []; + for ($i = 0; $i < 250; ++$i) { + $items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text']; + } + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn($design); + + $result = (new DiviDesignSystemAbility($reader))->execute([]); + + self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']); + self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 11e7f0d..4a51280 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -123,4 +123,19 @@ final class DiviReadAbilitiesTest extends TestCase self::assertToolError($ability->execute(['post_id' => 7, 'id' => 'hero']), 'validation_failed'); self::assertToolError($ability->execute(['post_id' => 7, 'id' => 's5.r0']), 'not_found'); } + + public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); + + $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); + $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); + + self::assertSame('1058', $global['global']['id']); + self::assertSame('divi/section', $global['global']['block_name']); + self::assertStringContainsString('post_id 1058', $global['global']['hint']); + self::assertFalse($global['editable']); + self::assertNull($plain['global']); + } }