diff --git a/src/Integrations/Divi/Compose/StructureRules.php b/src/Integrations/Divi/Compose/StructureRules.php index a7da185..c3ad985 100644 --- a/src/Integrations/Divi/Compose/StructureRules.php +++ b/src/Integrations/Divi/Compose/StructureRules.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; defined('ABSPATH') || exit; @@ -18,7 +19,8 @@ defined('ABSPATH') || exit; * rules limit the depth); parent module → its child modules (catalog). Columns only come with * their row; specialty sections, specialty columns (module.advanced.specialtyColumns or * row-inner children, ColumnModule.php:306-324) and read-only nodes (opaque, non-Divi) are not - * rebuilt. Pure PHP. + * rebuilt. F22: in Theme Builder layouts only modules Divi renders there (appearance.themeBuilderLayouts, + * e.g. divi/post-content only in the body). Pure PHP. */ final class StructureRules { @@ -26,7 +28,8 @@ final class StructureRules private const ROWS = ['divi/row', 'divi/row-inner']; private const FULLWIDTH = 'fullwidth-module'; - public function __construct(private ModuleCatalog $catalog) + /** @param ?string $themeBuilderArea "header", "body" or "footer" when the entry is a Theme Builder layout. */ + public function __construct(private ModuleCatalog $catalog, private ?string $themeBuilderArea = null) { } @@ -37,6 +40,11 @@ final class StructureRules return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child); } + $area = $this->areaViolation($child); + if ($area !== null) { + return $area; + } + if ($parent === null) { return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt'; } @@ -116,4 +124,41 @@ final class StructureRules return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)'; } + + /** + * F22 for stored nodes that arrive with their children (copy, library entry, duplicate): the first module + * below $block that Divi does not render in this Theme Builder area; null for pages. + */ + public function nestedAreaViolation(Block $block): ?string + { + if ($this->themeBuilderArea === null) { + return null; + } + + foreach ($block->children() as $child) { + $violation = $this->areaViolation(GlobalInstance::effectiveName($child)) + ?? $this->nestedAreaViolation($child); + if ($violation !== null) { + return $violation; + } + } + + return null; + } + + /** F22: in a Theme Builder layout, a module Divi renders only in other areas (Divi's own render rule). */ + private function areaViolation(string $child): ?string + { + $areas = $this->catalog->themeBuilderAreas($child); + if ($areas === null || $this->themeBuilderArea === null || in_array($this->themeBuilderArea, $areas, true)) { + return null; + } + + return sprintf( + '%s zeigt Divi im Bereich „%s“ nicht (nur %s)', + $child, + $this->themeBuilderArea, + implode(', ', $areas) + ); + } } diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php index dc8bf68..239df48 100644 --- a/src/Integrations/Divi/Compose/TreeEditor.php +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -81,7 +81,8 @@ final class TreeEditor public function insert(array $position, array $blocks): ?string { foreach ($blocks as $block) { - $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)) + ?? $this->rules->nestedAreaViolation($block); if ($violation !== null) { return $violation; } diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 752e3e1..1b606ef 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -33,6 +33,9 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; +use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; +use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; +use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -50,6 +53,7 @@ final class DiviIntegration implements Integration private DiviModules $modules; private DesignSystemReader $design; private LibraryAccess $access; + private ThemeBuilderAccess $themeBuilderAccess; /** * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries @@ -60,6 +64,10 @@ final class DiviIntegration implements Integration * and lit-library-insert follow it (ruling P9, E9, audit M2); null = always. * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled – * lit/divi-node-copy follows it for library entries as source (audit L5); null = always. + * @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in + * the Divi tools (group "Divi Theme Builder" writable, E9); null = always. + * @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi + * tools at all (group "Divi Theme Builder" not disabled); null = always. */ public function __construct( private ContentSchema $schema, @@ -67,11 +75,20 @@ final class DiviIntegration implements Integration ?Closure $libraryWritable = null, ?Closure $libraryVisible = null, private ?Closure $diviWritable = null, - private ?Closure $libraryInsertable = null + private ?Closure $libraryInsertable = null, + ?Closure $themeBuilderWritable = null, + ?Closure $themeBuilderVisible = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); - $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible); + $roles = new DiviRoles(); + $this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible); + $this->themeBuilderAccess = new ThemeBuilderAccess( + new DiviThemeBuilder(), + $roles, + $themeBuilderWritable, + $themeBuilderVisible + ); } public function id(): string @@ -161,12 +178,13 @@ final class DiviIntegration implements Integration $this->log, $this->design, new DiviComposition($this->modules, $this->design), - new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())) + new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())), + new ThemeBuilderEdits($this->themeBuilderAccess) ); } private function loader(): DiviPageLoader { - return new DiviPageLoader($this->schema, new Parser(), $this->access); + return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess); } } diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php index 45b8ec5..bf0d105 100644 --- a/src/Integrations/Divi/Library/GlobalEdits.php +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -12,7 +12,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\SaveScope; -use LIT\MCP\Integrations\WordPress\Status\PostCloner; +use LIT\MCP\Integrations\Divi\Pages\VisibleEdits; use WP_Error; use WP_Post; @@ -27,25 +27,22 @@ defined('ABSPATH') || exit; * confirm: true nothing is saved and the answer lists those pages (a dry run of the fully * validated change); with it the * save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup - * copy is made first – the revision itself is DiviSaver's restore point, as for every page. + * copy is made first – the revision itself is DiviSaver's restore point, as for every page + * (VisibleEdits, shared with live Theme Builder layouts, ruling Q2). * Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may * not edit_post one of the published, private or scheduled posts that show the entry (rulings * P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may * not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above * GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead). * The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for - * users without unfiltered_html, and when the trash is disabled (ruling P9, S37). + * users without unfiltered_html, and when the trash is disabled (ruling P9, S37; VisibleEdits). */ final class GlobalEdits { /** Refusals name at most this many page IDs. */ private const LISTED_IDS = 10; - /** @var Closure(WP_Post): int */ - private Closure $backup; - - /** @var Closure(): bool */ - private Closure $trashEnabled; + private VisibleEdits $visible; /** * @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup(). @@ -58,9 +55,7 @@ final class GlobalEdits private Parser $parser = new Parser(), ?Closure $trashEnabled = null ) { - $this->backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item); - $this->trashEnabled = $trashEnabled - ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + $this->visible = new VisibleEdits($backup, $trashEnabled); } /** @@ -108,17 +103,21 @@ final class GlobalEdits ); } - $refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item); + $refusal = $this->pagesRefusal(self::label($id, $global), $report) + ?? $this->visible->backupRefusal( + $item, + sprintf('Bibliothekseintrag %d', $id), + 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder ' + . 'bearbeiten.' + ); if ($refusal !== null) { return $refusal; } - $details = $report->listing(); - if (! wp_revisions_enabled($item)) { - $details['backup_id'] = ($this->backup)($item); - } - - return ['scope' => SaveScope::GlobalItem, 'details' => $details]; + return [ + 'scope' => SaveScope::GlobalItem, + 'details' => array_merge($report->listing(), $this->visible->backup($item)), + ]; } /** @@ -137,20 +136,15 @@ final class GlobalEdits return $error; } - $data = $error->get_error_data(); - $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; - - return Errors::confirmRequired( - Errors::clientMessage($error) - . sprintf( - ' %s: wirkt auf %d %s (siehe affected).', - $global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)', - $report->count, - self::pages($report->count) - ) - . self::hiddenNote($report) - . self::blockedNote($report), - array_merge($details, $report->details()) + return VisibleEdits::merged( + $error, + sprintf( + '%s: wirkt auf %d %s (siehe affected).', + $global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)', + $report->count, + self::pages($report->count) + ) . self::hiddenNote($report) . self::blockedNote($report), + $report->details() ); } @@ -254,39 +248,4 @@ final class GlobalEdits { return $count === 1 ? 'Seite' : 'Seiten'; } - - /** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */ - private function backupRefusal(WP_Post $item): ?WP_Error - { - if (wp_revisions_enabled($item)) { - return null; - } - - $hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder ' - . 'bearbeiten.'; - if (! current_user_can('unfiltered_html')) { - return Errors::unsupported( - sprintf( - 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses ' - . 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.', - (int) $item->ID - ), - $hint - ); - } - - if (! ($this->trashEnabled)()) { - return Errors::unsupported( - sprintf( - 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert ' - . '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde ' - . 'gespeichert.', - (int) $item->ID - ), - $hint - ); - } - - return null; - } } diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index c81604d..1d960f6 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -36,6 +36,7 @@ defined('ABSPATH') || exit; class DiviComposition { private ?int $postId = null; + private ?string $themeBuilderArea = null; private ?VariableRef $references = null; private ?Placeholders $placeholders = null; private ?ValueCleaner $cleaner = null; @@ -52,11 +53,12 @@ class DiviComposition ) { } - /** A fresh composition for entry $postId (own VariableRef with that entry's live map). */ - public function forPost(int $postId): static + /** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */ + public function forPost(int $postId, ?string $themeBuilderArea = null): static { $bound = clone $this; $bound->postId = $postId; + $bound->themeBuilderArea = $themeBuilderArea; $bound->references = null; $bound->placeholders = null; $bound->cleaner = null; @@ -116,7 +118,7 @@ class DiviComposition /** @internal Public for tests; code uses composer() / editor(). */ public function rules(): StructureRules { - return new StructureRules($this->modules->catalog()); + return new StructureRules($this->modules->catalog(), $this->themeBuilderArea); } /** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */ diff --git a/src/Integrations/Divi/Pages/DiviPage.php b/src/Integrations/Divi/Pages/DiviPage.php index 318f4f4..5e099b1 100644 --- a/src/Integrations/Divi/Pages/DiviPage.php +++ b/src/Integrations/Divi/Pages/DiviPage.php @@ -22,10 +22,23 @@ final class DiviPage public readonly WP_Post $post, public readonly Document $document, public readonly NodeIndex $index, - private bool $libraryItem = false + private bool $libraryItem = false, + private ?string $themeBuilderArea = null ) { } + /** Whether the entry is a Theme Builder layout (header, body or footer), loaded through ThemeBuilderAccess. */ + public function isThemeBuilderLayout(): bool + { + return $this->themeBuilderArea !== null; + } + + /** "header", "body" or "footer" for a Theme Builder layout, else null. */ + public function themeBuilderArea(): ?string + { + return $this->themeBuilderArea; + } + /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */ public function isLibraryItem(): bool { @@ -47,6 +60,11 @@ final class DiviPage public function editUrl(): string { + if ($this->themeBuilderArea !== null) { + // Divi opens TB layouts from the Theme Builder screen (the builder URL needs its nonce). + return (string) admin_url('admin.php?page=et_theme_builder'); + } + // A library entry has no public permalink (public: false); Divi's library list opens it here. return $this->libraryItem ? (string) get_edit_post_link($this->post, 'raw') diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index f60eded..84f890e 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -9,6 +9,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; +use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Log; @@ -18,7 +20,8 @@ use WP_Post; defined('ABSPATH') || exit; /** - * Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other + * Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), Theme Builder + * layouts via ThemeBuilderAccess (Etappe 3b; hidden ones answer like a missing ID, P12), every other * entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4 * shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content * is kept, never edited. @@ -31,7 +34,8 @@ final class DiviPageLoader public function __construct( private ContentSchema $schema, private Parser $parser = new Parser(), - private ?LibraryAccess $library = null + private ?LibraryAccess $library = null, + private ?ThemeBuilderAccess $themeBuilder = null ) { } @@ -50,9 +54,10 @@ final class DiviPageLoader /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { - // Library entries (E1): with a library access, for reading and writing. + // Library entries (E1) and Theme Builder layouts (Etappe 3b) through their access, everything else PostGuard. $item = $this->library?->item($postId, $forWrite); - $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); + $layout = $item === null ? $this->themeBuilder?->layout($postId, $forWrite) : null; + $post = $item ?? $layout ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); if ($post instanceof WP_Error) { return $post; } @@ -84,7 +89,9 @@ final class DiviPageLoader ); } - return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post); + $area = $layout instanceof WP_Post ? DiviThemeBuilder::areaOf((string) $layout->post_type) : null; + + return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post, $area); } /** The entry loaded for writing and checked by writable() – the refusal of either step. */ @@ -99,14 +106,16 @@ final class DiviPageLoader } /** - * Additional checks for writing: the type must use the Divi builder (library entries always do), + * Additional checks for writing: the type must use the Divi builder (library entries and Theme Builder + * layouts always do), * the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule * (spec §2). */ public function writable(DiviPage $page): ?WP_Error { $post = $page->post; - if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) { + $builderPost = $page->isLibraryItem() || $page->isThemeBuilderLayout(); + if (! $builderPost && ! $this->schema->diviEnabled((string) $post->post_type)) { return Errors::unsupported( sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type), 'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).' diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 4697cea..7edb026 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -387,7 +387,8 @@ final class DiviReplaceTextAbility implements Ability } /** - * A page commit() refused: its message, and for a global library entry the pages it shows on. + * A page commit() refused: its message, and for a global library entry the pages it shows on (for a live + * Theme Builder layout the templates). * * @return array */ @@ -398,6 +399,16 @@ final class DiviReplaceTextAbility implements Ability return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.']; } + if (isset($data['templates'])) { + // A layout of a live Theme Builder template (Etappe 3b): visible site-wide, like a global entry. + return [ + 'ok' => false, + 'error' => Errors::clientMessage($error) . ' Sichtbare Theme-Builder-Layouts nicht mit ' + . 'lit/divi-replace-text, sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', + 'templates' => $data['templates'], + ]; + } + return [ 'ok' => false, 'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, ' diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 5cbaa39..1707f5e 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -52,7 +52,8 @@ defined('ABSPATH') || exit; * S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md. * * SaveScope: library entries get no Divi flags; a global (or referenced) entry additionally clears - * the static CSS of every page and the Theme Builder cache. + * the static CSS of every page and the Theme Builder cache; a Theme Builder layout gets the Divi flags + * and clears like a global entry. * * If saving throws, the previous content is written back (WriteFailed: rolled back or not). * Not final so abilities can be tested with a mock. diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index 3bddc36..db031ca 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -36,11 +36,13 @@ abstract class DiviWriteAbility implements Ability return ToolResult::schema([ 'tree_rev' => ['type' => 'string'], // Global library entries (answer F2): the pages that show the entry, and the backup - // made when revisions are off. + // made when revisions are off (also for live Theme Builder layouts, ruling Q2). 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected_count' => ['type' => 'integer'], 'hidden_count' => ['type' => 'integer'], 'backup_id' => ['type' => 'integer'], + // Theme Builder layouts: the enabled templates that show the layout. + 'templates' => ['type' => 'array', 'items' => ['type' => 'object']], ], ['tree_rev']); } diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index f9a48b6..7daab24 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -14,6 +14,7 @@ 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\Library\GlobalEdits; +use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use WP_Error; @@ -25,7 +26,8 @@ defined('ABSPATH') || exit; * tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver, * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point - * of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup). + * of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup), Theme + * Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9). */ final class DiviWriter { @@ -38,7 +40,8 @@ final class DiviWriter private ChangeLog $log, private DesignSystemReader $design, private DiviComposition $composition, - private ?GlobalEdits $globals = null + private ?GlobalEdits $globals = null, + private ?ThemeBuilderEdits $themeBuilder = null ) { } @@ -67,11 +70,12 @@ final class DiviWriter /** * The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$ - * references are checked against Divi's dynamic content options for it (ruling S22). + * references are checked against Divi's dynamic content options for it (ruling S22); a Theme + * Builder layout also by its area (F22: modules Divi renders only elsewhere are refused). */ public function composition(DiviPage $page): DiviComposition { - return $this->composition->forPost((int) $page->post->ID); + return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea()); } /** @@ -151,7 +155,7 @@ final class DiviWriter /** * save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one * needs confirm (dry run first), gets a backup when revisions are off and clears every page's - * cache; the result then names the affected pages. + * cache; the result then names the affected pages. Theme Builder layouts: commitLayout(). * * @param array $input * @return array|WP_Error @@ -159,6 +163,10 @@ final class DiviWriter */ public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error { + if ($page->isThemeBuilderLayout()) { + return $this->commitLayout($ability, $page, $change, $input); + } + if (! $page->isLibraryItem()) { return $this->save($ability, $page, $change, $input); } @@ -179,48 +187,71 @@ final class DiviWriter return $prepared; } + return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Bibliothekseintrags'); + } + + /** + * save() for a Theme Builder layout: a layout visitors see needs confirm and gets the restore point + * of a visible change (ThemeBuilderEdits, rulings Q2/Q9); the result names the templates that show it. + * + * @param array $input + * @return array|WP_Error + * @throws WriteFailed + */ + private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error + { + if ($this->themeBuilder === null) { + return Errors::unsupported( + 'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.', + 'Das Layout im Divi Theme Builder bearbeiten.' + ); + } + + if (Serializer::document($page->document) === (string) $page->post->post_content) { + return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout); + } + + $prepared = $this->themeBuilder->prepare($page, $input); + if ($prepared instanceof WP_Error) { + return $prepared; + } + + return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts'); + } + + /** + * save() with the scope GlobalEdits/ThemeBuilderEdits prepared; their details join the result, and a + * failure after their backup names it (ruling P14, VisibleEdits). $entry names the entry in that error. + * + * @param array $input + * @param array{scope: SaveScope, details: array} $prepared + * @return array + * @throws WriteFailed + */ + private function savePrepared( + string $ability, + DiviPage $page, + DiviChange $change, + array $input, + array $prepared, + string $entry + ): array { try { $saved = $this->save($ability, $page, $change, $input, $prepared['scope']); } catch (WriteFailed $e) { - throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null); + throw VisibleEdits::keepingBackup($e, $prepared['details']['backup_id'] ?? null, $entry); } return array_merge($saved, $prepared['details']); } - /** - * Ruling P14: GlobalEdits made a backup (revisions off) before the save failed – the error names - * it, because it stays in the trash and is the only earlier state. - */ - private static function keepingBackup(WriteFailed $e, mixed $backupId): WriteFailed - { - if (! is_int($backupId)) { - return $e; - } - - $kept = sprintf( - 'Sicherung %d liegt im Papierkorb (vorheriger Stand des Bibliothekseintrags, in WordPress ' - . 'wiederherstellbar).', - $backupId - ); - if ($e->rolledBack) { - return $e->withDetails( - ['backup_id' => $backupId], - $kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.', - Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId)) - ); - } - - return $e->withDetails( - ['backup_id' => $backupId], - 'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und ' - . 'dem Nutzer Bescheid geben.' - ); - } - - /** A tool's confirm_required on a global library entry, extended by the affected pages. */ + /** A tool's confirm_required on a global library entry or a live Theme Builder layout, extended by who sees it. */ public function notice(DiviPage $page, WP_Error $error): WP_Error { + if ($page->isThemeBuilderLayout()) { + return $this->themeBuilder?->notice($page, $error) ?? $error; + } + return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error; } diff --git a/src/Integrations/Divi/Pages/SaveScope.php b/src/Integrations/Divi/Pages/SaveScope.php index 8841307..b20eb9e 100644 --- a/src/Integrations/Divi/Pages/SaveScope.php +++ b/src/Integrations/Divi/Pages/SaveScope.php @@ -21,13 +21,16 @@ enum SaveScope /** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */ case GlobalItem; + /** A Theme Builder layout: Divi flags like the builder (finding 4); shown site-wide – all CSS, TB cache. */ + case ThemeBuilderLayout; + public function setsFlags(): bool { - return $this === self::Page; + return $this === self::Page || $this === self::ThemeBuilderLayout; } public function clearsSiteCss(): bool { - return $this === self::GlobalItem; + return $this === self::GlobalItem || $this === self::ThemeBuilderLayout; } } diff --git a/src/Integrations/Divi/Pages/VisibleEdits.php b/src/Integrations/Divi/Pages/VisibleEdits.php new file mode 100644 index 0000000..ef2e7b5 --- /dev/null +++ b/src/Integrations/Divi/Pages/VisibleEdits.php @@ -0,0 +1,138 @@ +backup = $backup ?? static fn (WP_Post $post): int => (new PostCloner())->backup($post); + $this->trashEnabled = $trashEnabled + ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + } + + /** + * Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. $label + * names the entry in the message ("Bibliothekseintrag 12"), $hint says what to do instead. + */ + public function backupRefusal(WP_Post $post, string $label, string $hint): ?WP_Error + { + if (wp_revisions_enabled($post)) { + return null; + } + + if (! current_user_can('unfiltered_html')) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für %s abgeschaltet, und die Sicherungskopie wäre für dieses Konto nicht ' + . 'verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.', + $label + ), + $hint + ); + } + + if (! ($this->trashEnabled)()) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für %s abgeschaltet, und der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0) ' + . '– die Sicherungskopie würde sofort gelöscht; nichts wurde gespeichert.', + $label + ), + $hint + ); + } + + return null; + } + + /** + * The backup of $post when revisions are off for it (call backupRefusal() first): ['backup_id' => …], + * else nothing – the revision DiviSaver takes is the restore point. + * + * @return array{backup_id?: int} + * @throws WriteFailed When the backup could not be made (nothing was saved). + */ + public function backup(WP_Post $post): array + { + return wp_revisions_enabled($post) ? [] : ['backup_id' => ($this->backup)($post)]; + } + + /** + * A tool's own confirm_required $error extended by $note (appended to its message) and $details (the + * entry's answer fields), keeping the tool's own fields. + * + * @param array $details + */ + public static function merged(WP_Error $error, string $note, array $details): WP_Error + { + $data = $error->get_error_data(); + $own = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; + + return Errors::confirmRequired(Errors::clientMessage($error) . ' ' . $note, array_merge($own, $details)); + } + + /** + * Ruling P14: a backup was made (revisions off) before the save failed – the error names it, because it + * stays in the trash and is the only earlier state. $entry names what it saved ("des Bibliothekseintrags"). + */ + public static function keepingBackup(WriteFailed $e, mixed $backupId, string $entry): WriteFailed + { + if (! is_int($backupId)) { + return $e; + } + + $kept = sprintf( + 'Sicherung %d liegt im Papierkorb (vorheriger Stand %s, in WordPress wiederherstellbar).', + $backupId, + $entry + ); + if ($e->rolledBack) { + return $e->withDetails( + ['backup_id' => $backupId], + $kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.', + Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId)) + ); + } + + return $e->withDetails( + ['backup_id' => $backupId], + 'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und ' + . 'dem Nutzer Bescheid geben.' + ); + } +} diff --git a/src/Integrations/Divi/Schema/ModuleCatalog.php b/src/Integrations/Divi/Schema/ModuleCatalog.php index d54eabf..5520c54 100644 --- a/src/Integrations/Divi/Schema/ModuleCatalog.php +++ b/src/Integrations/Divi/Schema/ModuleCatalog.php @@ -92,6 +92,24 @@ final class ModuleCatalog return array_values(array_unique(array_filter($children, 'is_string'))); } + /** + * Theme Builder areas Divi renders the module in (appearance.themeBuilderLayouts, e.g. divi/post-content: + * ["body"]; ModuleRegistration.php:509-527); null = no limit. + * + * @return list|null + */ + public function themeBuilderAreas(string $name): ?array + { + $areas = $this->modules[$name]['appearance']['themeBuilderLayouts'] ?? null; + if (! is_array($areas)) { + return null; + } + + $areas = array_values(array_filter($areas, 'is_string')); + + return $areas === [] ? null : $areas; + } + /** @return list */ public function paths(string $name): array { diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php new file mode 100644 index 0000000..b7f02f8 --- /dev/null +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php @@ -0,0 +1,123 @@ + Live templates that show layout $page in its area. */ + public function showing(DiviPage $page): array + { + $area = (string) $page->themeBuilderArea(); + $id = (int) $page->post->ID; + + return array_values(array_filter( + $this->access->store()->liveTemplates(), + static fn (Template $template): bool => $template->showsLayout($area, $id) + )); + } + + /** + * @param array $input + * @return array{scope: SaveScope, details: array}|WP_Error + * @throws WriteFailed When the backup could not be made (nothing was saved). + */ + public function prepare(DiviPage $page, array $input): array|WP_Error + { + $templates = $this->showing($page); + $details = ['templates' => self::listing($templates)]; + if ($templates === []) { + return ['scope' => SaveScope::ThemeBuilderLayout, 'details' => $details]; + } + + $id = (int) $page->post->ID; + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + sprintf( + 'Theme-Builder-Layout %d ist auf der Website sichtbar (%s, siehe templates); die Änderung wirkt ' + . 'sofort. Nichts wurde gespeichert (Probelauf).', + $id, + self::count($templates) + ), + $details + ); + } + + $refusal = $this->visible->backupRefusal( + $page->post, + sprintf('Theme-Builder-Layout %d', $id), + 'Revisionen aktivieren (WP_POST_REVISIONS) oder das Layout im Divi Theme Builder bearbeiten.' + ); + if ($refusal !== null) { + return $refusal; + } + + return [ + 'scope' => SaveScope::ThemeBuilderLayout, + 'details' => array_merge($details, $this->visible->backup($page->post)), + ]; + } + + /** A tool's own confirm_required (lit-divi-delete) on a visible layout, extended by the templates. */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + $templates = Errors::codeOf($error) === 'confirm_required' ? $this->showing($page) : []; + if ($templates === []) { + return $error; + } + + return VisibleEdits::merged( + $error, + sprintf('Das Layout ist auf der Website sichtbar (%s, siehe templates).', self::count($templates)), + ['templates' => self::listing($templates)] + ); + } + + /** @param list