- 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
360 lines
14 KiB
PHP
360 lines
14 KiB
PHP
<?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)
|
||
)
|
||
);
|
||
}
|
||
}
|