feat: lit/tb-create-template – disabled templates, all or nothing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,6 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
@@ -70,7 +68,7 @@ final class DiviContentComposer implements ContentComposer
|
||||
);
|
||||
}
|
||||
|
||||
$layout = $this->layout($this->composition->forPost(0), $sections);
|
||||
$layout = LayoutBuilder::build($this->composition->forPost(0), $sections, 'divi');
|
||||
|
||||
return $layout instanceof WP_Error ? $layout : null;
|
||||
}
|
||||
@@ -78,7 +76,7 @@ final class DiviContentComposer implements ContentComposer
|
||||
public function write(int $postId, string $postType, array $sections): array|WP_Error
|
||||
{
|
||||
$composition = $this->composition->forPost($postId);
|
||||
$layout = $this->layout($composition, $sections);
|
||||
$layout = LayoutBuilder::build($composition, $sections, 'divi');
|
||||
if ($layout instanceof WP_Error) {
|
||||
return $layout;
|
||||
}
|
||||
@@ -98,37 +96,4 @@ final class DiviContentComposer implements ContentComposer
|
||||
'edit_url' => DiviPage::builderUrl($postId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole description composed and inserted into an empty document – or every error.
|
||||
*
|
||||
* @param list<mixed> $sections
|
||||
* @return array{document: Document, blocks: list<Block>}|WP_Error
|
||||
*/
|
||||
private function layout(DiviComposition $composition, array $sections): array|WP_Error
|
||||
{
|
||||
if ($sections === []) {
|
||||
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
|
||||
'divi' => 'mindestens ein Abschnitt',
|
||||
]);
|
||||
}
|
||||
|
||||
$composed = $composition->composer()->compose(array_values($sections), null, 'divi');
|
||||
if ($composed['errors'] !== []) {
|
||||
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
$violation = $composition->editor()->insert(
|
||||
['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX],
|
||||
$composed['blocks']
|
||||
);
|
||||
if ($violation !== null) {
|
||||
return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [
|
||||
'divi' => $violation,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['document' => $document, 'blocks' => $composed['blocks']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Sections in short/full form composed into an empty document (lit/content-create, lit/tb-create-template): the
|
||||
* blocks come only from Composer (the single choke point, D7) and enter the document only through
|
||||
* TreeEditor::insert(), which wraps the first section in divi/placeholder (ruling S9). All or nothing.
|
||||
*/
|
||||
final class LayoutBuilder
|
||||
{
|
||||
/**
|
||||
* @param list<mixed> $sections
|
||||
* @param string $field Input path the errors are reported under (e.g. "divi", "body.nodes").
|
||||
* @return array{document: Document, blocks: list<Block>}|WP_Error
|
||||
*/
|
||||
public static function build(DiviComposition $composition, array $sections, string $field): array|WP_Error
|
||||
{
|
||||
if ($sections === []) {
|
||||
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
|
||||
$field => 'mindestens ein Abschnitt',
|
||||
]);
|
||||
}
|
||||
|
||||
$composed = $composition->composer()->compose(array_values($sections), null, $field);
|
||||
if ($composed['errors'] !== []) {
|
||||
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
$violation = $composition->editor()->insert(
|
||||
['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX],
|
||||
$composed['blocks']
|
||||
);
|
||||
if ($violation !== null) {
|
||||
return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [
|
||||
$field => $violation,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['document' => $document, 'blocks' => $composed['blocks']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
<?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\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/tb-create-template (spec §5.7, E7, answer F1): a Theme Builder template, ALWAYS disabled, with per area a
|
||||
* new layout (optionally with nodes – Composer/TreeEditor, stored by DiviSaver::saveNew, D7), the default
|
||||
* template's layout ("global" header/footer), nothing of its own ("default") or the area hidden. Conditions only
|
||||
* from TemplateConditions. Everything is validated first; Divi then creates layouts and template
|
||||
* (DiviThemeBuilder), the template is read back and appended to the live list; any failure removes all of it
|
||||
* (TemplateBuild). Refused while the Theme Builder has an unsaved draft (E7). Never enables anything (F1, Q5):
|
||||
* Divi stores it with enabled '0' and a template that reads back enabled or as default is rolled back.
|
||||
*/
|
||||
final class TbCreateTemplateAbility implements Ability
|
||||
{
|
||||
public const SOURCES = ['new', 'global', 'default', 'hidden'];
|
||||
|
||||
public const STALE_EDITOR = 'Ist der Divi Theme Builder gerade in einem anderen Fenster geöffnet, dort vor dem '
|
||||
. 'nächsten Speichern neu laden – sonst entfernt Divi die neue Vorlage wieder aus der Liste (lit/tb-list zeigt '
|
||||
. 'sie dann unter detached).';
|
||||
|
||||
private const AREA_LABELS = ['header' => 'Kopfzeile', 'body' => 'Inhalt', 'footer' => 'Fußzeile'];
|
||||
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private TemplateConditions $conditions,
|
||||
private DiviComposition $composition,
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/tb-create-template';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Theme-Builder-Vorlage anlegen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt eine Vorlage im Divi Theme Builder an – immer deaktiviert, also ohne Wirkung auf die Website. Je '
|
||||
. 'Bereich (header, body, footer) source "new" (neues Layout, optional mit nodes: Abschnitte in Kurz- oder '
|
||||
. 'Vollform wie bei lit-divi-insert, z. B. {{post:title}} oder {{field:<Feld>}}), "global" (Kopf-/Fußzeile '
|
||||
. 'der Standardvorlage mitbenutzen), "default" (nichts Eigenes) oder "hidden" (Bereich ausblenden). '
|
||||
. 'Bedingungen optional (use_on, exclude_from; IDs aus lit/tb-list mit conditions: true). Aktivieren '
|
||||
. 'nur mit lit/tb-activate.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
$area = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'source' => ['type' => 'string', 'enum' => self::SOURCES],
|
||||
'title' => ['type' => 'string', 'maxLength' => 200, 'description' => 'Titel des neuen Layouts.'],
|
||||
'nodes' => [
|
||||
'type' => 'array',
|
||||
'maxItems' => 20,
|
||||
'items' => ['type' => 'object'],
|
||||
'description' => 'Nur mit source "new": Abschnitte wie bei lit-divi-insert.',
|
||||
],
|
||||
],
|
||||
'required' => ['source'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
|
||||
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||||
'header' => $area,
|
||||
'body' => $area,
|
||||
'footer' => $area,
|
||||
'use_on' => $conditions,
|
||||
'exclude_from' => $conditions,
|
||||
],
|
||||
'required' => ['title'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema([
|
||||
'template_id' => ['type' => 'integer'],
|
||||
'template_rev' => ['type' => 'string'],
|
||||
'enabled' => ['type' => 'boolean'],
|
||||
'layouts' => ['type' => 'object'],
|
||||
], ['template_id', 'template_rev', 'enabled']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::write(false);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed After the first post exists (everything was rolled back first, if possible). */
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = $this->access->canCreate() ?? $this->access->draftRefusal();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||||
if ($title === '') {
|
||||
$errors['title'] = 'nicht leer';
|
||||
}
|
||||
$plan = $this->plan($input, $title, $errors);
|
||||
$useOn = $this->conditions->check($input['use_on'] ?? null, 'use_on');
|
||||
$excludeFrom = $this->conditions->check($input['exclude_from'] ?? null, 'exclude_from');
|
||||
$errors = array_merge($errors, $useOn['errors'], $excludeFrom['errors']);
|
||||
foreach ($plan as $area => $spec) {
|
||||
if ($spec['nodes'] !== []) {
|
||||
$layout = LayoutBuilder::build($this->composition->forPost(0, $area), $spec['nodes'], $area . '.nodes');
|
||||
if ($layout instanceof WP_Error) {
|
||||
$errors = array_merge($errors, self::fields($layout, $area));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Die Vorlage ist ungültig; nichts wurde angelegt.', $errors);
|
||||
}
|
||||
|
||||
return $this->create($title, $plan, $useOn['ids'], $excludeFrom['ids']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, string> $errors
|
||||
* @return array<string, array{source: string, title: string, nodes: list<mixed>, id: int}>
|
||||
*/
|
||||
private function plan(array $input, string $title, array &$errors): array
|
||||
{
|
||||
$default = null;
|
||||
$plan = [];
|
||||
foreach (Template::AREAS as $area) {
|
||||
$spec = is_array($input[$area] ?? null) ? $input[$area] : ['source' => 'default'];
|
||||
$source = $spec['source'] ?? null;
|
||||
if (! in_array($source, self::SOURCES, true)) {
|
||||
$errors[$area . '.source'] = 'new, global, default oder hidden';
|
||||
continue;
|
||||
}
|
||||
|
||||
$nodes = is_array($spec['nodes'] ?? null) ? array_values($spec['nodes']) : [];
|
||||
if ($nodes !== [] && $source !== 'new') {
|
||||
$errors[$area . '.nodes'] = 'nur mit source "new"';
|
||||
}
|
||||
|
||||
$id = 0;
|
||||
if ($source === 'global') {
|
||||
if ($area === 'body') {
|
||||
$errors['body.source'] = 'für den Inhalt gibt es kein globales Layout';
|
||||
continue;
|
||||
}
|
||||
$default ??= $this->defaultTemplate();
|
||||
$id = $this->reusable($area, $default);
|
||||
if ($id === null) {
|
||||
$errors[$area . '.source'] = sprintf(
|
||||
'die Standardvorlage hat keine eigene %s, die sich mitbenutzen lässt',
|
||||
self::AREA_LABELS[$area]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$layoutTitle = sanitize_text_field(is_string($spec['title'] ?? null) ? $spec['title'] : '');
|
||||
$plan[$area] = [
|
||||
'source' => (string) $source,
|
||||
'title' => $layoutTitle !== '' ? $layoutTitle : sprintf('%s – %s', $title, self::AREA_LABELS[$area]),
|
||||
'nodes' => $nodes,
|
||||
'id' => $id,
|
||||
];
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
private function defaultTemplate(): ?Template
|
||||
{
|
||||
foreach ($this->access->store()->liveTemplates() as $template) {
|
||||
if ($template->default) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default template's layout of $area when it can be reused: a published layout of that area the user may
|
||||
* edit (ruling Q8 – Divi would silently drop anything else, F17). Missing, trashed and not editable read the
|
||||
* same (P12). It is referenced by ID, never copied, so no stored content moves (StoredNodeGuard not needed).
|
||||
*/
|
||||
private function reusable(string $area, ?Template $default): ?int
|
||||
{
|
||||
$id = $default?->layouts[$area]['id'] ?? 0;
|
||||
$post = $id > 0 ? get_post($id) : null;
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== DiviThemeBuilder::LAYOUT_TYPES[$area]
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
|| ! current_user_can('edit_post', $id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{source: string, title: string, nodes: list<mixed>, id: int}> $plan
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function create(string $title, array $plan, array $useOn, array $excludeFrom): array|WP_Error
|
||||
{
|
||||
$store = $this->access->store();
|
||||
$build = new TemplateBuild($store, $this->log, $this->name());
|
||||
$layouts = [];
|
||||
$warnings = [];
|
||||
try {
|
||||
foreach ($plan as $area => $spec) {
|
||||
$layouts[$area] = ['id' => $spec['id'], 'enabled' => $spec['source'] !== 'hidden'];
|
||||
if ($spec['source'] !== 'new') {
|
||||
continue;
|
||||
}
|
||||
$id = $build->layout($area, $spec['title']);
|
||||
$layouts[$area]['id'] = $id;
|
||||
if ($spec['nodes'] === []) {
|
||||
continue;
|
||||
}
|
||||
$composition = $this->composition->forPost($id, $area);
|
||||
$layout = LayoutBuilder::build($composition, $spec['nodes'], $area . '.nodes');
|
||||
if ($layout instanceof WP_Error) {
|
||||
return $build->refuse($layout);
|
||||
}
|
||||
$warnings = array_merge(
|
||||
$warnings,
|
||||
$composition->warnings()->collect(NodeIndex::build($layout['document']), $layout['blocks']),
|
||||
$this->saver->saveNew(
|
||||
$id,
|
||||
Serializer::document($layout['document']),
|
||||
SaveScope::ThemeBuilderLayout
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$themeBuilderId = $build->themeBuilder();
|
||||
$templateId = $build->template($themeBuilderId, [
|
||||
'title' => $title,
|
||||
'layouts' => $layouts,
|
||||
'use_on' => $useOn,
|
||||
'exclude_from' => $excludeFrom,
|
||||
]);
|
||||
$mismatch = self::mismatch($store->template($templateId, false), $layouts, $useOn, $excludeFrom);
|
||||
if ($mismatch !== null) {
|
||||
throw new RuntimeException($mismatch);
|
||||
}
|
||||
$build->attach($themeBuilderId, $templateId);
|
||||
} catch (WriteFailed $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$build->rollBack($e);
|
||||
}
|
||||
|
||||
$after = [];
|
||||
FollowUp::run(
|
||||
static fn () => $store->clearCaches(),
|
||||
'theme builder cache',
|
||||
'Der Cache des Theme Builders konnte nicht geleert werden; die Vorlage ist angelegt (deaktiviert).',
|
||||
$after
|
||||
);
|
||||
$newLayouts = [];
|
||||
foreach ($plan as $area => $spec) {
|
||||
if ($spec['source'] === 'new') {
|
||||
$newLayouts[] = $area . ' ' . $layouts[$area]['id'];
|
||||
}
|
||||
}
|
||||
$logId = $this->log->record(
|
||||
$this->name(),
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
(string) $templateId,
|
||||
sprintf(
|
||||
'Theme Builder: Vorlage „%s“ angelegt (deaktiviert; neue Layouts: %s)',
|
||||
$title,
|
||||
$newLayouts === [] ? 'keine' : implode(', ', $newLayouts)
|
||||
)
|
||||
);
|
||||
$template = $store->template($templateId, true);
|
||||
$out = [];
|
||||
foreach ($layouts as $area => $layout) {
|
||||
$out[$area] = $layout + ['source' => $plan[$area]['source']];
|
||||
}
|
||||
|
||||
return ToolResult::ok(sprintf(
|
||||
'Theme-Builder-Vorlage „%s“ angelegt – deaktiviert, ohne Wirkung auf die Website. Aktivieren nur mit '
|
||||
. 'lit/tb-activate.',
|
||||
$title
|
||||
))
|
||||
->with('template_id', $templateId)
|
||||
->with('template_rev', $template?->rev() ?? '')
|
||||
->with('enabled', false)
|
||||
->with('layouts', $out)
|
||||
->warnings(array_merge($warnings, $after, [self::STALE_EDITOR]))
|
||||
->logId($logId)
|
||||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the stored template is not what was asked for (Divi silently drops layout IDs, F17), else null.
|
||||
*
|
||||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
*/
|
||||
private static function mismatch(?Template $stored, array $layouts, array $useOn, array $excludeFrom): ?string
|
||||
{
|
||||
if ($stored === null) {
|
||||
return 'template not readable after store';
|
||||
}
|
||||
if ($stored->enabled || $stored->default) {
|
||||
return 'template stored enabled or as default';
|
||||
}
|
||||
foreach ($layouts as $area => $layout) {
|
||||
if ($stored->layouts[$area] !== $layout) {
|
||||
return sprintf('layout %s stored as %d', $area, $stored->layouts[$area]['id']);
|
||||
}
|
||||
}
|
||||
|
||||
$same = $stored->useOn === $useOn && $stored->excludeFrom === $excludeFrom;
|
||||
|
||||
return $same ? null : 'conditions stored differently';
|
||||
}
|
||||
|
||||
/** @return array<string, string> The refusal's fields, or its message under "<area>.nodes". */
|
||||
private static function fields(WP_Error $error, string $area): array
|
||||
{
|
||||
$fields = Errors::fieldsOf($error);
|
||||
|
||||
return $fields !== [] ? $fields : [$area . '.nodes' => Errors::clientMessage($error)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Rollback;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The transaction of lit/tb-create-template (all or nothing, like NewDraft for one post): records every post
|
||||
* created for it (layouts, a live Theme Builder post Divi creates on the way – ruling Q8/B12 –, the template)
|
||||
* and the assignment to the Theme Builder post; refuse() and rollBack() remove all of it again, the assignment
|
||||
* first, then the posts newest first (wp_delete_post with force – nothing can use them yet; Divi's own trash
|
||||
* routine is never called). A cleanup that fails is an honest partial_write naming the IDs (logged).
|
||||
*/
|
||||
final class TemplateBuild
|
||||
{
|
||||
public const REASON = 'Die Theme-Builder-Vorlage konnte nicht angelegt werden.';
|
||||
|
||||
/** @var list<int> */
|
||||
private array $created = [];
|
||||
|
||||
/** @var array{0: int, 1: int}|null Theme Builder post, template. */
|
||||
private ?array $attached = null;
|
||||
|
||||
public function __construct(private DiviThemeBuilder $store, private ChangeLog $log, private string $ability)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function layout(string $area, string $title): int
|
||||
{
|
||||
$id = $this->store->insertLayout($area, $title);
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live Theme Builder post; when the site has none yet, Divi creates it like on its first save and the
|
||||
* new post belongs to this build (B12).
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function themeBuilder(): int
|
||||
{
|
||||
$id = $this->store->liveId(false);
|
||||
if ($id > 0) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
$id = $this->store->liveId(true);
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('no live Theme Builder post');
|
||||
}
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
|
||||
* exclude_from: list<string>} $template
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function template(int $themeBuilderId, array $template): int
|
||||
{
|
||||
$id = $this->store->storeTemplate($themeBuilderId, $template);
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function attach(int $themeBuilderId, int $templateId): void
|
||||
{
|
||||
$this->attached = [$themeBuilderId, $templateId];
|
||||
$this->store->attach($themeBuilderId, $templateId);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function created(): array
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal after posts exist (a layout the new post's own dynamic content map refuses): everything created
|
||||
* is removed, then the refusal is returned.
|
||||
*
|
||||
* @throws WriteFailed When the cleanup fails.
|
||||
*/
|
||||
public function refuse(WP_Error $refusal): WP_Error
|
||||
{
|
||||
$failures = $this->undo();
|
||||
if ($failures !== []) {
|
||||
throw $this->partial(new RuntimeException($failures[0]), '');
|
||||
}
|
||||
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed Always. */
|
||||
public function rollBack(Throwable $cause): never
|
||||
{
|
||||
$failures = $this->undo();
|
||||
if ($failures !== []) {
|
||||
throw $this->partial($cause, '; rollback: ' . implode('; ', $failures));
|
||||
}
|
||||
|
||||
throw WriteFailed::rolledBack(
|
||||
self::REASON,
|
||||
$cause,
|
||||
'Theme Builder template not created: ' . $cause->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function undo(): array
|
||||
{
|
||||
$steps = [];
|
||||
if ($this->attached !== null) {
|
||||
[$themeBuilderId, $templateId] = $this->attached;
|
||||
$steps[] = fn () => $this->store->detach($themeBuilderId, $templateId);
|
||||
}
|
||||
foreach (array_reverse($this->created) as $id) {
|
||||
$steps[] = static function () use ($id): void {
|
||||
if (! wp_delete_post($id, true)) {
|
||||
throw new RuntimeException(sprintf('post %d not deleted', $id));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return Rollback::attempt(...$steps);
|
||||
}
|
||||
|
||||
private function partial(Throwable $cause, string $detail): WriteFailed
|
||||
{
|
||||
$ids = implode(', ', $this->created);
|
||||
$this->log->recordPartial(
|
||||
$this->ability,
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
$ids,
|
||||
sprintf('Theme Builder: Anlegen nicht vollständig zurückgenommen (Beiträge %s)', $ids)
|
||||
);
|
||||
|
||||
return WriteFailed::partial(
|
||||
self::REASON,
|
||||
$cause,
|
||||
'Theme Builder rollback incomplete' . $detail,
|
||||
state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids),
|
||||
hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids),
|
||||
details: ['created_ids' => $this->created]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,10 @@ class ThemeBuilderAccess
|
||||
return $post;
|
||||
}
|
||||
|
||||
/** Template $templateId for the Theme Builder tools; any other ID, or a hidden Theme Builder, is not found. */
|
||||
/**
|
||||
* Template $templateId for the Theme Builder tools. Any other ID, a hidden Theme Builder and a template the
|
||||
* user may not edit (canEditTemplate) answer identically: not found (ruling Q18, P12 – like tb-list, Q8/B11).
|
||||
*/
|
||||
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
|
||||
{
|
||||
$id = PostGuard::id($templateId);
|
||||
@@ -140,7 +143,7 @@ class ThemeBuilderAccess
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
|| ! $this->visible()
|
||||
|| ! $this->canEditTemplate($id)
|
||||
) {
|
||||
return Errors::notFound(
|
||||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||||
@@ -148,10 +151,6 @@ class ThemeBuilderAccess
|
||||
);
|
||||
}
|
||||
|
||||
if (! current_user_can('edit_post', $id)) {
|
||||
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Vorlage %d zu bearbeiten.', $id));
|
||||
}
|
||||
|
||||
return $forWrite ? ($this->writableGate() ?? $post) : $post;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ defined('ABSPATH') || exit;
|
||||
* lit/tb-activate, all indirect. Built by DiviIntegration::themeBuilder() so it shares the Theme Builder access
|
||||
* (rights, Tools-tab gates – its id ThemeBuilderAccess::GROUP is what the gates of the Divi tools ask; once this
|
||||
* group is registered, its mode and "nur lesen" switch in the Tools tab drive them). Available with the Divi 5
|
||||
* builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log are
|
||||
* for the writing tools of the later tasks.
|
||||
* builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log serve
|
||||
* the writing tools.
|
||||
*/
|
||||
final class ThemeBuilderIntegration implements Integration
|
||||
{
|
||||
@@ -51,6 +51,7 @@ final class ThemeBuilderIntegration implements Integration
|
||||
|
||||
return [
|
||||
new TbListAbility($this->access, $conditions),
|
||||
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user