fix: TB dry runs hide uneditable live templates, off-switch despite draft (rulings Q20, Q21)

- ThemeBuilderAccess::editableLiveTemplates() shared by tb-list and the tb-assign/tb-activate dry runs;
  uneditable live templates only as a count (hidden_overlaps, hidden_live)
- tb-activate enabled:false works while the Theme Builder has a draft, with a warning
- possible_overlaps skips identical conditions; reattach records the previous enabled state;
  exclude_from must be a list; tb-activate live from the read-back

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 20:55:53 +02:00
co-authored by Claude Opus 5.5
parent 64fc439cb2
commit a31d4340f9
11 changed files with 355 additions and 37 deletions
@@ -24,12 +24,18 @@ defined('ABSPATH') || exit;
* 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.
* published, e.g. in the trash (ruling Q16), never needs the layouts and works despite a Theme Builder draft (with
* DRAFT_WARNING, ruling Q20). Overlaps name only templates the user may edit, the others as a count (ruling Q21).
*/
final class TbActivateAbility implements Ability
{
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
/** Ruling Q20: the off-switch works despite a Theme Builder draft – which may restore its own state. */
public const DRAFT_WARNING = 'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); wird ein dort '
. 'geöffnetes Fenster gespeichert, stellt Divi evtl. seinen eigenen Stand wieder her und die Vorlage ist '
. 'wieder aktiv – danach mit lit/tb-list prüfen oder die Änderungen im Theme Builder verwerfen.';
public function __construct(
private ThemeBuilderAccess $access,
private TemplateConditions $conditions,
@@ -171,15 +177,26 @@ final class TbActivateAbility implements Ability
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
implode('; ', $labels)
);
$live = $enabled ? $this->access->store()->liveTemplates() : [];
$overlaps = Template::overlapDetails(
$enabled ? $this->access->editableLiveTemplates() : ['templates' => [], 'hidden' => []],
$template->id,
$template->useOn
);
$message .= Template::hiddenOverlapsNote($overlaps['hidden_overlaps']);
if (! $enabled && $this->draftPending()) {
$message .= ' ' . self::DRAFT_WARNING;
}
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),
]);
] + $overlaps);
}
private function draftPending(): bool
{
return $this->access->store()->draftId() > 0;
}
/**
@@ -203,6 +220,9 @@ final class TbActivateAbility implements Ability
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
$warnings
);
if (! $enabled && $this->draftPending()) {
$warnings[] = self::DRAFT_WARNING;
}
$logId = $this->log->record(
$this->name(),
DiviThemeBuilder::TEMPLATE,
@@ -214,7 +234,7 @@ final class TbActivateAbility implements Ability
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
)
);
$after = $store->template($template->id, true) ?? $template;
$after = $store->template($template->id, true);
$summary = $enabled
? sprintf(
@@ -224,7 +244,7 @@ final class TbActivateAbility implements Ability
)
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
return $this->result($after, $summary, $logId, $enabled, $warnings);
return $this->result($after ?? $template, $summary, $logId, $enabled, $after?->isLive() ?? $enabled, $warnings);
}
/** @throws WriteFailed Always. */
@@ -260,13 +280,14 @@ final class TbActivateAbility implements Ability
string $summary,
?int $logId,
?bool $enabled = null,
?bool $live = 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())
->with('live', $live ?? $template->isLive())
->warnings($warnings)
->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
@@ -118,9 +118,14 @@ final class TbAssignAbility implements Ability
$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' => []];
$excludeFrom = match (true) {
! array_key_exists('exclude_from', $input) => ['ids' => $template->excludeFrom, 'errors' => []],
is_array($input['exclude_from']) => $this->conditions->check($input['exclude_from'], 'exclude_from'),
default => [
'ids' => [],
'errors' => ['exclude_from' => 'Liste von Bedingungs-IDs erwartet ([] entfernt die Ausnahmen)'],
],
};
$errors = array_merge($useOn['errors'], $excludeFrom['errors']);
if ($errors !== []) {
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
@@ -181,20 +186,19 @@ final class TbAssignAbility implements Ability
*/
private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error
{
$live = $this->access->store()->liveTemplates();
$overlaps = Template::overlapDetails($this->access->editableLiveTemplates(), $template->id, $useOn);
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
. 'der Website – vorher/nachher unter before/after.%s Nichts wurde gespeichert (Probelauf).',
$template->title,
Template::hiddenOverlapsNote($overlaps['hidden_overlaps'])
),
[
'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),
]
] + $overlaps
);
}
@@ -256,7 +260,12 @@ final class TbAssignAbility implements Ability
sprintf(
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)',
$template->title,
$reattach ? ', wieder zugeordnet (deaktiviert)' : '',
$reattach
? sprintf(
', wieder zugeordnet (deaktiviert, vorher %s)',
$template->enabled ? 'aktiviert' : 'deaktiviert'
)
: '',
self::listed($template->useOn),
self::listed($template->excludeFrom)
)
@@ -276,7 +285,8 @@ final class TbAssignAbility implements Ability
->with('template_id', $template->id)
->with('template_rev', $after?->rev() ?? '')
->with('live', $live)
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom])
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom]
+ ($reattach ? ['enabled' => $template->enabled] : []))
->warnings($warnings)
->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
@@ -19,8 +19,8 @@ defined('ABSPATH') || exit;
* Divi's unused mark – Divi trashes marked posts 7 days later, F19, stage3b finding 10) so they can be
* reattached; optionally Divi's condition catalog. Needs ThemeBuilderAccess::canUse(). Ruling Q8/B11: a
* template the user may not edit does not appear at all (not listed, not in shared_with, asked for by ID it
* reads like a missing one – P12); layout titles only for layouts the user may edit; condition labels name
* objects by the stored ID only, never by title.
* reads like a missing one – P12) – active ones only as a count (hidden_live, ruling Q21); layout titles only for
* layouts the user may edit; condition labels name objects by the stored ID only, never by title.
*/
final class TbListAbility implements Ability
{
@@ -71,12 +71,16 @@ final class TbListAbility implements Ability
'properties' => [
'theme_builder_id' => ['type' => 'integer'],
'draft_pending' => ['type' => 'boolean'],
'hidden_live' => [
'type' => 'integer',
'description' => 'Aktive Vorlagen, die der Nutzer nicht bearbeiten darf (nur die Anzahl).',
],
'templates' => $objects,
'detached' => $objects,
'conditions' => $objects,
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['theme_builder_id', 'draft_pending', 'templates', 'detached', 'notes'],
'required' => ['theme_builder_id', 'draft_pending', 'hidden_live', 'templates', 'detached', 'notes'],
];
}
@@ -103,12 +107,13 @@ final class TbListAbility implements Ability
}
$store = $this->access->store();
$editable = fn (Template $template): bool => $this->access->canEditTemplate($template->id);
$live = array_values(array_filter($store->liveTemplates(), $editable));
$split = $this->access->editableLiveTemplates();
$live = $split['templates'];
$hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive()));
$detached = [];
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) {
$template = $store->template($id, false);
if ($template !== null && $editable($template)) {
if ($template !== null && $this->access->canEditTemplate($template->id)) {
$detached[] = $template;
}
}
@@ -134,7 +139,15 @@ final class TbListAbility implements Ability
$notes = [];
if ($draft) {
$notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder '
. 'verworfen sind, ändert LIT MCP keine Vorlagen.';
. 'verworfen sind, ändert LIT MCP keine Vorlagen – nur ausblenden (lit/tb-activate mit enabled: '
. 'false) geht.';
}
if ($hiddenLive > 0) {
$notes[] = sprintf(
'Weitere aktive Vorlagen ohne Berechtigung: %d (nicht aufgeführt; sie können sich mit den '
. 'aufgeführten überschneiden).',
$hiddenLive
);
}
if ($detached !== []) {
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
@@ -146,6 +159,7 @@ final class TbListAbility implements Ability
$result = [
'theme_builder_id' => $store->liveId(),
'draft_pending' => $draft,
'hidden_live' => $hiddenLive,
'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live),
'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached),
'notes' => $notes,
@@ -164,8 +164,11 @@ final class Template
}
$conditions = [];
foreach ($other->useOn as $theirs) {
if (in_array($theirs, $useOn, true)) {
continue; // identical: overlaps()
}
foreach ($useOn as $ours) {
if ($theirs !== $ours && self::mayOverlap($theirs, $ours)) {
if (self::mayOverlap($theirs, $ours)) {
$conditions[] = $theirs;
break;
}
@@ -179,6 +182,38 @@ final class Template
return $overlaps;
}
/**
* The overlap details of a dry run (tb-assign, tb-activate) for template $exceptId with conditions $useOn:
* identical and possible overlaps with the live templates the user may edit, and only the number of
* overlapping live templates the user may not edit (ruling Q21, Q8/B11).
*
* @param array{templates: list<Template>, hidden: list<Template>} $live ThemeBuilderAccess::editableLiveTemplates()
* @param list<string> $useOn
* @return array{overlaps: list<array{id: int, title: string, conditions: list<string>}>,
* possible_overlaps: list<array{id: int, title: string, conditions: list<string>}>, hidden_overlaps: int}
*/
public static function overlapDetails(array $live, int $exceptId, array $useOn): array
{
$hidden = array_merge(
self::overlaps($live['hidden'], $exceptId, $useOn),
self::possibleOverlaps($live['hidden'], $exceptId, $useOn)
);
return [
'overlaps' => self::overlaps($live['templates'], $exceptId, $useOn),
'possible_overlaps' => self::possibleOverlaps($live['templates'], $exceptId, $useOn),
'hidden_overlaps' => count(array_unique(array_column($hidden, 'id'))),
];
}
/** The sentence for hidden_overlaps > 0 (never names them). */
public static function hiddenOverlapsNote(int $count): string
{
return $count > 0
? sprintf(' Weitere aktive Vorlagen ohne Berechtigung, die sich überschneiden können: %d.', $count)
: '';
}
private static function mayOverlap(string $a, string $b): bool
{
foreach (self::scopesOf($a) as $x) {
@@ -160,7 +160,7 @@ class ThemeBuilderAccess
* 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.
* still reads as missing. The off-switch also works while the Theme Builder has unsaved changes (ruling Q20).
*
* @param array<string, mixed> $input template_id, template_rev
*/
@@ -177,10 +177,27 @@ class ThemeBuilderAccess
$gate = $this->writableGate();
return $gate ?? $this->checkedTemplate($id, $input);
return $gate ?? $this->checkedTemplate($id, $input, true);
}
return $this->checkedTemplate((int) $post->ID, $input);
return $this->checkedTemplate((int) $post->ID, $input, $offSwitch);
}
/**
* The templates of the live Theme Builder post split by canEditTemplate() (ruling Q21 – one place for
* tb-list and the dry runs of tb-assign/tb-activate): only "templates" may be shown; "hidden" serves counts
* ("weitere aktive Vorlagen ohne Berechtigung"), never its IDs, titles or conditions (Q8/B11, P12).
*
* @return array{templates: list<Template>, hidden: list<Template>}
*/
public function editableLiveTemplates(): array
{
$split = ['templates' => [], 'hidden' => []];
foreach ($this->store->liveTemplates() as $template) {
$split[$this->canEditTemplate($template->id) ? 'templates' : 'hidden'][] = $template;
}
return $split;
}
/**
@@ -297,12 +314,14 @@ class ThemeBuilderAccess
/**
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
* The off-switch skips the draft refusal (ruling Q20): hiding a template must always be possible; the caller
* warns that saving an open Theme Builder editor may restore its own state.
*
* @param array<string, mixed> $input
*/
private function checkedTemplate(int $id, array $input): Template|WP_Error
private function checkedTemplate(int $id, array $input, bool $offSwitch): Template|WP_Error
{
$refusal = $this->draftRefusal();
$refusal = $offSwitch ? null : $this->draftRefusal();
if ($refusal !== null) {
return $refusal;
}