feat: lit/tb-assign – conditions with dry run on live templates, reattach

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:28:27 +02:00
co-authored by Claude Opus 5.5
parent 2827fd4bd8
commit 0975b2f69b
8 changed files with 837 additions and 1 deletions
@@ -0,0 +1,329 @@
<?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-assign (spec §5.7): replaces the conditions of a template (_et_use_on/_et_exclude_from, only from
* TemplateConditions). use_on is required; an omitted exclude_from stays as it is, an explicit [] clears it
* (ruling Q3/B3). A live template changes the website at once, so it is a dry run first and needs confirm: true
* (ruling Q9/B22), listing both lists before/after and overlaps (identical and possible, B9) with other live
* templates for the resulting lists. reattach: true puts a template a stale Theme Builder save removed back on the
* live list (only on the user's request, B19) – always disabled, Divi's "unused" marks removed from it and its
* layouts, which must still be published (B10). Templates have no revisions: the previous conditions are in the
* answer and the change log; a failed write restores them (and undoes the reattach).
*/
final class TbAssignAbility implements Ability
{
public const REASON = 'Die Bedingungen der Vorlage konnten nicht gespeichert werden.';
public function __construct(
private ThemeBuilderAccess $access,
private TemplateConditions $conditions,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/tb-assign';
}
public function label(): string
{
return 'Theme-Builder-Vorlage zuordnen';
}
public function description(): string
{
return 'Setzt die Bedingungen einer Theme-Builder-Vorlage neu: use_on ersetzt die bisherigen (z. B. '
. '"singular:post_type:person:all" = alle Einträge des Inhaltstyps person), exclude_from ebenso – fehlt '
. 'exclude_from, bleiben die Ausnahmen, [] entfernt sie. IDs aus lit/tb-list mit conditions: true; '
. 'template_rev aus lit/tb-list ist Pflicht. Bei einer aktiven Vorlage wirkt das sofort auf der Website: '
. 'erst Probelauf (vorher/nachher, Überschneidungen), dann nach Rückfrage beim Nutzer confirm: true. '
. 'reattach: true ordnet eine Vorlage wieder zu, die ein Speichern im Theme Builder entfernt hat (nur auf '
. 'ausdrücklichen Wunsch des Nutzers; sie bleibt deaktiviert). Die Standardvorlage nur im Theme Builder.';
}
public function inputSchema(): array
{
$conditions = ['type' => '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<string> $useOn
* @param list<string> $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<string> $useOn
* @param list<string> $excludeFrom
* @return array{use_on: list<string>, exclude_from: list<string>}
*/
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<string> $useOn
* @param list<string> $excludeFrom
* @return array<string, mixed>
* @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<string> $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)
)
);
}
}
@@ -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<Template> $live
* @param list<string> $useOn
* @return list<array{id: int, title: string, conditions: list<string>}>
*/
public static function possibleOverlaps(array $live, int $exceptId, array $useOn): array
{
$overlaps = [];
foreach ($live as $other) {
if ($other->id === $exceptId || ! $other->isLive()) {
continue;
}
$conditions = [];
foreach ($other->useOn as $theirs) {
foreach ($useOn as $ours) {
if ($theirs !== $ours && self::mayOverlap($theirs, $ours)) {
$conditions[] = $theirs;
break;
}
}
}
if ($conditions !== []) {
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $conditions];
}
}
return $overlaps;
}
private static function mayOverlap(string $a, string $b): bool
{
foreach (self::scopesOf($a) as $x) {
foreach (self::scopesOf($b) as $y) {
if ($x === $y || str_starts_with($x, $y . ':') || str_starts_with($y, $x . ':')) {
return true;
}
}
}
return false;
}
/**
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>,
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
*
* @return list<string>
*/
private static function scopesOf(string $condition): array
{
if ($condition === 'homepage') {
return ['homepage', 'singular:post_type:page', 'archive:post_type:post'];
}
$parts = explode(':', $condition);
$second = $parts[1] ?? '';
if (($parts[0] === 'singular' && $second === 'taxonomy') || $second === 'all') {
return [$parts[0]];
}
return [implode(':', array_slice($parts, 0, in_array($second, ['post_type', 'taxonomy'], true) ? 3 : 2))];
}
/** @return list<string> */
private static function strings(mixed $list): array
{
@@ -154,6 +154,23 @@ class ThemeBuilderAccess
return $forWrite ? ($this->writableGate() ?? $post) : $post;
}
/**
* 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.
*
* @param array<string, mixed> $input template_id, template_rev
*/
public function changeableTemplate(array $input): Template|WP_Error
{
$post = $this->template($input['template_id'] ?? null, true);
if ($post instanceof WP_Error) {
return $post;
}
return $this->checkedTemplate((int) $post->ID, $input);
}
/**
* Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11).
* $id must come from Divi's own template list (templateIds()/liveTemplates()/detachedIds()): this checks
@@ -244,4 +261,42 @@ class ThemeBuilderAccess
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
);
}
/**
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
*
* @param array<string, mixed> $input
*/
private function checkedTemplate(int $id, array $input): Template|WP_Error
{
$refusal = $this->draftRefusal();
if ($refusal !== null) {
return $refusal;
}
$attached = in_array($id, $this->store->templateIds($this->store->liveId()), true);
$template = $this->store->template($id, $attached);
if ($template === null) {
return Errors::notFound(
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
'Vorlagen mit lit/tb-list auflisten.'
);
}
if ($template->default) {
return Errors::unsupported(
'Die Standardvorlage ändert LIT MCP nicht; nichts wurde gespeichert.',
'Die Standardvorlage im Divi Theme Builder bearbeiten.'
);
}
if (! is_string($input['template_rev'] ?? null) || $input['template_rev'] !== $template->rev()) {
return Errors::revMismatch(
'Die Vorlage wurde seit dem Lesen geändert (template_rev passt nicht); nichts wurde gespeichert.',
'Mit lit/tb-list neu lesen und mit der neuen template_rev wiederholen.'
);
}
return $template;
}
}
@@ -52,6 +52,7 @@ final class ThemeBuilderIntegration implements Integration
return [
new TbListAbility($this->access, $conditions),
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
new TbAssignAbility($this->access, $conditions, $this->log),
];
}
}