feat: lit/tb-activate – the explicit switch, always a dry run first (answer F1)

- only _et_enabled; dry run in both directions lists conditions, exclusions,
  layouts, overlaps and possible overlaps (B9); confirm: true switches
- enabling needs conditions and published layouts (B10); a detached
  template needs reattach first; the default template is refused
- the off-switch reaches an attached, enabled template outside publish,
  e.g. in the trash (ruling Q16); result names the fast way back
- every switch in the change log; failed switch rolled back

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:33:29 +02:00
co-authored by Claude Opus 5.5
parent 0975b2f69b
commit 64fc439cb2
6 changed files with 598 additions and 4 deletions
@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/tb-activate (answer F1): the one place where LIT MCP makes a template visible to visitors or hides it
* again – _et_enabled, nothing else. Always a dry run first (conditions with labels, layouts, identical and
* possible overlaps with other live templates – ruling Q8/B9), then confirm: true; both directions, every switch
* in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach
* first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer
* published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not
* published, e.g. in the trash (ruling Q16), and never needs the layouts.
*/
final class TbActivateAbility implements Ability
{
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
public function __construct(
private ThemeBuilderAccess $access,
private TemplateConditions $conditions,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/tb-activate';
}
public function label(): string
{
return 'Theme-Builder-Vorlage aktivieren';
}
public function description(): string
{
return 'Aktiviert (enabled: true) oder deaktiviert (enabled: false) eine Vorlage des Divi Theme Builders – die '
. 'einzige Stelle, an der LIT MCP eine Vorlage für Besucher sichtbar macht. Immer zuerst Probelauf '
. '(Bedingungen, Layouts, Überschneidungen mit anderen aktiven Vorlagen), dann nach Rückfrage beim Nutzer '
. 'confirm: true. Deaktivieren ist der schnelle Weg zurück. template_rev aus lit/tb-list ist Pflicht; die '
. 'Standardvorlage nur im Theme Builder.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'template_id' => ['type' => 'integer', 'minimum' => 1],
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
'enabled' => ['type' => 'boolean'],
'confirm' => ['type' => 'boolean'],
],
'required' => ['template_id', 'template_rev', 'enabled'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'template_id' => ['type' => 'integer'],
'template_rev' => ['type' => 'string'],
'enabled' => ['type' => 'boolean'],
'live' => ['type' => 'boolean'],
], ['template_id', 'template_rev', 'enabled', '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, ($input['enabled'] ?? null) === false);
return $template instanceof WP_Error ? $template : true;
}
/** @throws WriteFailed */
public function execute(array $input): mixed
{
$enabled = $input['enabled'] ?? null;
$template = $this->access->changeableTemplate($input, $enabled === false);
if ($template instanceof WP_Error) {
return $template;
}
if (! is_bool($enabled)) {
return Errors::invalidInput('enabled fehlt; nichts wurde gespeichert.', ['enabled' => 'true oder false']);
}
if (! $template->attached) {
return Errors::invalidInput(
sprintf('Vorlage %d ist im Theme Builder nicht zugeordnet; nichts wurde gespeichert.', $template->id),
['template_id' => 'zuerst mit lit/tb-assign und reattach: true wieder zuordnen']
);
}
if ($template->enabled === $enabled) {
return $this->result($template, sprintf(
'Keine Änderung – die Vorlage „%s“ ist bereits %s.',
$template->title,
$enabled ? 'aktiv' : 'deaktiviert'
), null);
}
if ($enabled) {
$refusal = $this->activationRefusal($template);
if ($refusal !== null) {
return $refusal;
}
}
if (($input['confirm'] ?? false) !== true) {
return $this->dryRun($template, $enabled);
}
return $this->switch($template, $enabled);
}
/** Enabling needs conditions and published layouts (B10). */
private function activationRefusal(Template $template): ?WP_Error
{
if ($template->useOn === []) {
return Errors::invalidInput(
'Ohne Bedingungen gilt eine Vorlage nirgends; zuerst mit lit/tb-assign Bedingungen setzen. Nichts '
. 'wurde gespeichert.',
['template_id' => 'keine Bedingungen (use_on)']
);
}
return $this->access->layoutsRefusal($template);
}
private function dryRun(Template $template, bool $enabled): WP_Error
{
$labels = array_map($this->conditions->label(...), $template->useOn);
$message = $enabled
? sprintf(
'Aktivieren: Die Vorlage „%s“ gilt danach sofort für Besucher (%s). Überschneidungen mit anderen '
. 'aktiven Vorlagen unter overlaps/possible_overlaps. Nichts wurde gespeichert (Probelauf).',
$template->title,
implode('; ', $labels)
)
: sprintf(
'Deaktivieren: Die Vorlage „%s“%s gilt danach nicht mehr (%s); dort zeigt Divi wieder eine andere '
. 'Vorlage, die Standardvorlage oder das Theme. Nichts wurde gespeichert (Probelauf).',
$template->title,
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
implode('; ', $labels)
);
$live = $enabled ? $this->access->store()->liveTemplates() : [];
return Errors::confirmRequired($message, [
'use_on' => $labels,
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
'layouts' => $template->layouts,
'overlaps' => Template::overlaps($live, $template->id, $template->useOn),
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $template->useOn),
]);
}
/**
* @return array<string, mixed>
* @throws WriteFailed
*/
private function switch(Template $template, bool $enabled): array
{
$store = $this->access->store();
try {
$store->setEnabled($template->id, $enabled);
} catch (Throwable $e) {
$this->undo($template, $e);
}
$warnings = [];
FollowUp::run(
static fn () => $store->clearCaches(),
'theme builder cache',
'Der Cache des Theme Builders konnte nicht geleert werden; die Änderung ist gespeichert, wirkt aber evtl. '
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
$warnings
);
$logId = $this->log->record(
$this->name(),
DiviThemeBuilder::TEMPLATE,
(string) $template->id,
sprintf(
'Theme Builder: Vorlage „%s“ %s (Bedingungen: %s)',
$template->title,
$enabled ? 'aktiviert' : 'deaktiviert',
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
)
);
$after = $store->template($template->id, true) ?? $template;
$summary = $enabled
? sprintf(
'Vorlage „%s“ aktiviert – sie gilt jetzt für Besucher. Schneller Weg zurück: lit/tb-activate mit '
. 'enabled: false (template_rev aus dieser Antwort).',
$template->title
)
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
return $this->result($after, $summary, $logId, $enabled, $warnings);
}
/** @throws WriteFailed Always. */
private function undo(Template $template, Throwable $cause): never
{
$store = $this->access->store();
$failures = Rollback::attempt(static fn () => $store->setEnabled($template->id, $template->enabled));
if ($failures === []) {
throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-activate failed: ' . $cause->getMessage());
}
$this->log->recordPartial(
$this->name(),
DiviThemeBuilder::TEMPLATE,
(string) $template->id,
sprintf('Theme Builder: Umschalten von „%s“ nicht zurückgenommen (%s)', $template->title, $failures[0])
);
throw WriteFailed::partial(
self::REASON,
$cause,
'tb-activate rollback: ' . $failures[0],
$template->id,
hint: 'Mit lit/tb-list prüfen, ob die Vorlage aktiv ist.'
);
}
/**
* @param list<string> $warnings
* @return array<string, mixed>
*/
private function result(
Template $template,
string $summary,
?int $logId,
?bool $enabled = null,
array $warnings = []
): array {
return ToolResult::ok($summary)
->with('template_id', $template->id)
->with('template_rev', $template->rev())
->with('enabled', $enabled ?? $template->enabled)
->with('live', $enabled ?? $template->isLive())
->warnings($warnings)
->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
->toArray();
}
}
@@ -157,15 +157,27 @@ class ThemeBuilderAccess
/**
* A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate
* (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7),
* never the default template (E8), the current template_rev.
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template
* still reads as missing.
*
* @param array<string, mixed> $input template_id, template_rev
*/
public function changeableTemplate(array $input): Template|WP_Error
public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error
{
$post = $this->template($input['template_id'] ?? null, true);
if ($post instanceof WP_Error) {
return $post;
$id = $offSwitch && Errors::codeOf($post) === 'not_found'
? $this->liveUnpublished($input['template_id'] ?? null)
: null;
if ($id === null) {
return $post;
}
$gate = $this->writableGate();
return $gate ?? $this->checkedTemplate($id, $input);
}
return $this->checkedTemplate((int) $post->ID, $input);
@@ -262,6 +274,27 @@ class ThemeBuilderAccess
);
}
/**
* ID of a template that is not published but live (listed by the live Theme Builder post and enabled) and the
* user may edit; null otherwise – the raw lookup of the off-switch (ruling Q16).
*/
private function liveUnpublished(mixed $templateId): ?int
{
$id = PostGuard::id($templateId);
$post = $id === null ? null : get_post($id);
if (
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|| (string) $post->post_status === 'publish'
|| ! $this->canEditTemplate((int) $post->ID)
|| ! in_array((int) $post->ID, $this->store->templateIds($this->store->liveId()), true)
) {
return null;
}
return $this->store->template((int) $post->ID, true)?->isLive() === true ? (int) $post->ID : null;
}
/**
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
*
@@ -53,6 +53,7 @@ final class ThemeBuilderIntegration implements Integration
new TbListAbility($this->access, $conditions),
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
new TbAssignAbility($this->access, $conditions, $this->log),
new TbActivateAbility($this->access, $conditions, $this->log),
];
}
}