Files
WP-LIT-MCP/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php
T
janniclas.loosenandClaude Opus 5.5 14bc954ecc fix: Theme Builder minors of the final review (M-4, M-5, M-6, M-9, M-10, M-11, M-12 c)
- tb-list applies the detached limit after the edit filter (reads up to 500
  IDs) and notes when more are not listed (M-4); an invalid template_id is
  invalid input, shared with ThemeBuilderAccess::template() (M-5).
- Templates carry their status; tb-list notes that a live template outside
  publish (e.g. trashed) can only be switched off (M-10).
- changeableTemplate() refuses without canUse() first, like tb-list and
  tb-create-template; the layout/template refusal difference is documented
  (M-9).
- tb-create-template reads the default template from editableLiveTemplates()
  (M-11); the condition-ID character set (role slugs) is documented (M-6);
  tb-assign documents that a failed reattach keeps Divi's unused marks
  removed (M-12 c).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:12:14 +02:00

360 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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; only one no Theme Builder post lists – tb-list's detached, audit L4)
* – 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). The undo does not put back
* Divi's "unused" marks a reattach removed (review M-12 c): the template is detached again, but Divi no longer
* trashes it after seven days – harmless, it stays under detached in lit/tb-list.
*/
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 = 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);
}
$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). Detached
* means what tb-list shows as detached: no Theme Builder post lists it – a template of a Theme Builder library
* item or draft is none (audit L4).
*/
private function reattachRefusal(Template $template, bool $reattach): ?WP_Error
{
if ($template->attached) {
return null;
}
if ($this->access->store()->isListedAnywhere($template->id)) {
return Errors::invalidInput(
sprintf(
'Vorlage %d ist keine entfernte Vorlage (siehe detached in lit/tb-list); nichts wurde gespeichert.',
$template->id
),
[
'template_id' => 'nur Vorlagen der Website (templates) oder entfernte Vorlagen (detached) aus '
. 'lit/tb-list',
]
);
}
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
{
$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.%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
);
}
/**
* @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
? sprintf(
', wieder zugeordnet (deaktiviert, vorher %s)',
$template->enabled ? 'aktiviert' : '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]
+ ($reattach ? ['enabled' => $template->enabled] : []))
->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)
)
);
}
}