From 0975b2f69ba7d95e9501f82e1452e65a059beedf Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 16:28:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/tb-assign=20=E2=80=93=20conditions?= =?UTF-8?q?=20with=20dry=20run=20on=20live=20templates,=20reattach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeBuilderAccess::changeableTemplate(): rights (Q18 not_found), group gate, no TB draft, never the default template, current template_rev - omitted exclude_from stays, [] clears (Q3); live dry run shows use_on and exclude_from before/after, overlaps and possible overlaps (B9) - reattach only with published layouts (B10) and a live TB post; always disabled, unmarks template and layouts; failure restores everything Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/ThemeBuilder/TbAssignAbility.php | 329 ++++++++++++++++++ .../Divi/ThemeBuilder/Template.php | 69 ++++ .../Divi/ThemeBuilder/ThemeBuilderAccess.php | 55 +++ .../ThemeBuilder/ThemeBuilderIntegration.php | 1 + .../Integrations/Divi/DiviIntegrationTest.php | 2 +- .../Divi/ThemeBuilder/TbAssignAbilityTest.php | 309 ++++++++++++++++ .../Divi/ThemeBuilder/TemplateTest.php | 37 ++ .../ThemeBuilder/ThemeBuilderAccessTest.php | 36 ++ 8 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php create mode 100644 tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php diff --git a/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php b/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php new file mode 100644 index 0000000..5f3843b --- /dev/null +++ b/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php @@ -0,0 +1,329 @@ + 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']]; + + return [ + 'type' => 'object', + 'properties' => [ + 'template_id' => ['type' => 'integer', 'minimum' => 1], + 'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'], + 'use_on' => $conditions, + 'exclude_from' => $conditions + [ + 'description' => 'Fehlt es, bleiben die bisherigen Ausnahmen; [] entfernt sie.', + ], + 'reattach' => ['type' => 'boolean'], + 'confirm' => ['type' => 'boolean'], + ], + 'required' => ['template_id', 'template_rev', 'use_on'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'template_id' => ['type' => 'integer'], + 'template_rev' => ['type' => 'string'], + 'live' => ['type' => 'boolean'], + 'previous' => ['type' => 'object'], + ], ['template_id', 'template_rev', 'live']); + } + + public function annotations(): array + { + return Annotations::destructive(true); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $template = $this->access->changeableTemplate($input); + + return $template instanceof WP_Error ? $template : true; + } + + /** @throws WriteFailed */ + public function execute(array $input): mixed + { + $template = $this->access->changeableTemplate($input); + if ($template instanceof WP_Error) { + return $template; + } + + $useOn = is_array($input['use_on'] ?? null) + ? $this->conditions->check($input['use_on'], 'use_on') + : ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']]; + $excludeFrom = array_key_exists('exclude_from', $input) + ? $this->conditions->check($input['exclude_from'] ?? [], 'exclude_from') + : ['ids' => $template->excludeFrom, 'errors' => []]; + $errors = array_merge($useOn['errors'], $excludeFrom['errors']); + if ($errors !== []) { + return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors); + } + + $reattach = ! $template->attached && ($input['reattach'] ?? false) === true; + $refusal = $this->reattachRefusal($template, $reattach); + if ($refusal !== null) { + return $refusal; + } + + if ($template->isLive() && ($input['confirm'] ?? false) !== true) { + return $this->dryRun($template, $useOn['ids'], $excludeFrom['ids']); + } + + return $this->write($template, $useOn['ids'], $excludeFrom['ids'], $reattach); + } + + /** A detached template needs reattach: true, a live Theme Builder post and published layouts (B10). */ + private function reattachRefusal(Template $template, bool $reattach): ?WP_Error + { + if ($template->attached) { + return null; + } + + if (! $reattach) { + $marked = $template->markedUnused === null + ? '' + : sprintf(' (seit %s als ungenutzt markiert; Divi löscht sie 7 Tage danach)', $template->markedUnused); + + return Errors::invalidInput( + sprintf( + 'Vorlage %d ist im Theme Builder nicht mehr zugeordnet%s; nichts wurde gespeichert.', + $template->id, + $marked + ), + [ + 'reattach' => 'nur auf Wunsch des Nutzers mit reattach: true wieder zuordnen (die Vorlage bleibt ' + . 'deaktiviert)', + ] + ); + } + + if ($this->access->store()->liveId() <= 0) { + return Errors::unsupported( + 'Der Divi Theme Builder wurde noch nie gespeichert; es gibt keine Liste, der die Vorlage wieder ' + . 'zugeordnet werden kann. Nichts wurde gespeichert.', + 'Den Theme Builder einmal speichern (Divi → Theme Builder), dann erneut aufrufen.' + ); + } + + return $this->access->layoutsRefusal($template); + } + + /** + * @param list $useOn + * @param list $excludeFrom + */ + private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error + { + $live = $this->access->store()->liveTemplates(); + + return Errors::confirmRequired( + sprintf( + 'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf ' + . 'der Website – vorher/nachher unter before/after. Nichts wurde gespeichert (Probelauf).', + $template->title + ), + [ + 'before' => $this->labels($template->useOn, $template->excludeFrom), + 'after' => $this->labels($useOn, $excludeFrom), + 'overlaps' => Template::overlaps($live, $template->id, $useOn), + 'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $useOn), + ] + ); + } + + /** + * @param list $useOn + * @param list $excludeFrom + * @return array{use_on: list, exclude_from: list} + */ + private function labels(array $useOn, array $excludeFrom): array + { + return [ + 'use_on' => array_map($this->conditions->label(...), $useOn), + 'exclude_from' => array_map($this->conditions->label(...), $excludeFrom), + ]; + } + + /** + * @param list $useOn + * @param list $excludeFrom + * @return array + * @throws WriteFailed + */ + private function write(Template $template, array $useOn, array $excludeFrom, bool $reattach): array + { + $store = $this->access->store(); + $themeBuilderId = $store->liveId(); + try { + if ($reattach) { + $store->setEnabled($template->id, false); + $store->attach($themeBuilderId, $template->id); + foreach (array_merge([$template->id], array_column($template->layouts, 'id')) as $id) { + if ($id > 0) { + $store->unmarkUnused($id); + } + } + } + $store->setConditions($template->id, $useOn, $excludeFrom); + } catch (Throwable $e) { + $this->undo($template, $reattach, $themeBuilderId, $e); + } + + $warnings = []; + if ($template->isLive()) { + FollowUp::run( + static fn () => $store->clearCaches(), + 'theme builder cache', + 'Der Cache des Theme Builders konnte nicht geleert werden; die Bedingungen sind gespeichert, wirken ' + . 'aber evtl. erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', + $warnings + ); + } + if ($reattach) { + $warnings[] = TbCreateTemplateAbility::STALE_EDITOR; + } + $logId = $this->log->record( + $this->name(), + DiviThemeBuilder::TEMPLATE, + (string) $template->id, + sprintf( + 'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)', + $template->title, + $reattach ? ', wieder zugeordnet (deaktiviert)' : '', + self::listed($template->useOn), + self::listed($template->excludeFrom) + ) + ); + $after = $store->template($template->id, $template->attached || $reattach); + $live = $after?->isLive() ?? false; + + return ToolResult::ok(sprintf( + 'Bedingungen der Vorlage „%s“ gesetzt%s%s.', + $template->title, + $reattach ? ' und wieder zugeordnet' : '', + $live + ? ' – wirkt sofort auf der Website; zurück mit lit/tb-assign und den Bedingungen unter previous, ' + . 'ausblenden mit lit/tb-activate (enabled: false)' + : ' (Vorlage deaktiviert, ohne Wirkung auf die Website)' + )) + ->with('template_id', $template->id) + ->with('template_rev', $after?->rev() ?? '') + ->with('live', $live) + ->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom]) + ->warnings($warnings) + ->logId($logId) + ->urls(null, (string) admin_url('admin.php?page=et_theme_builder')) + ->toArray(); + } + + /** @param list $conditions */ + private static function listed(array $conditions): string + { + return $conditions === [] ? 'keine' : implode(', ', $conditions); + } + + /** @throws WriteFailed Always. */ + private function undo(Template $template, bool $reattach, int $themeBuilderId, Throwable $cause): never + { + $store = $this->access->store(); + $steps = [static fn () => $store->setConditions($template->id, $template->useOn, $template->excludeFrom)]; + if ($reattach) { + $steps[] = static fn () => $store->detach($themeBuilderId, $template->id); + $steps[] = static fn () => $store->setEnabled($template->id, $template->enabled); + } + + $failures = Rollback::attempt(...$steps); + if ($failures === []) { + throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-assign failed: ' . $cause->getMessage()); + } + + $this->log->recordPartial( + $this->name(), + DiviThemeBuilder::TEMPLATE, + (string) $template->id, + sprintf( + 'Theme Builder: Bedingungen von „%s“ nur teilweise zurückgenommen (%s)', + $template->title, + implode('; ', $failures) + ) + ); + throw WriteFailed::partial( + self::REASON, + $cause, + 'tb-assign rollback: ' . implode('; ', $failures), + $template->id, + hint: sprintf( + 'Mit lit/tb-list prüfen; vorher galt: %s; Ausnahmen: %s.', + self::listed($template->useOn), + self::listed($template->excludeFrom) + ) + ); + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/Template.php b/src/Integrations/Divi/ThemeBuilder/Template.php index d303804..2afd138 100644 --- a/src/Integrations/Divi/ThemeBuilder/Template.php +++ b/src/Integrations/Divi/ThemeBuilder/Template.php @@ -145,6 +145,75 @@ final class Template return $overlaps; } + /** + * Live templates other than $exceptId whose conditions are not identical to one of $useOn but may cover + * the same pages (ruling Q8/B9 – "kann sich überschneiden"): same family, e.g. all pages vs. one page, + * all archives vs. one category archive, posts in a category vs. any single post, the front page vs. pages. + * Deliberately generous – the dry run is the user's only preview. Identical conditions are overlaps(). + * + * @param list