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 Closure;
|
||||||
use LIT\MCP\Abilities\Errors;
|
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\NodeIndex;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
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;
|
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
|
public function write(int $postId, string $postType, array $sections): array|WP_Error
|
||||||
{
|
{
|
||||||
$composition = $this->composition->forPost($postId);
|
$composition = $this->composition->forPost($postId);
|
||||||
$layout = $this->layout($composition, $sections);
|
$layout = LayoutBuilder::build($composition, $sections, 'divi');
|
||||||
if ($layout instanceof WP_Error) {
|
if ($layout instanceof WP_Error) {
|
||||||
return $layout;
|
return $layout;
|
||||||
}
|
}
|
||||||
@@ -98,37 +96,4 @@ final class DiviContentComposer implements ContentComposer
|
|||||||
'edit_url' => DiviPage::builderUrl($postId),
|
'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;
|
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
|
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
|
||||||
{
|
{
|
||||||
$id = PostGuard::id($templateId);
|
$id = PostGuard::id($templateId);
|
||||||
@@ -140,7 +143,7 @@ class ThemeBuilderAccess
|
|||||||
! $post instanceof WP_Post
|
! $post instanceof WP_Post
|
||||||
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
||||||
|| (string) $post->post_status !== 'publish'
|
|| (string) $post->post_status !== 'publish'
|
||||||
|| ! $this->visible()
|
|| ! $this->canEditTemplate($id)
|
||||||
) {
|
) {
|
||||||
return Errors::notFound(
|
return Errors::notFound(
|
||||||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
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;
|
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
|
* 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
|
* (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
|
* 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
|
* builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log serve
|
||||||
* for the writing tools of the later tasks.
|
* the writing tools.
|
||||||
*/
|
*/
|
||||||
final class ThemeBuilderIntegration implements Integration
|
final class ThemeBuilderIntegration implements Integration
|
||||||
{
|
{
|
||||||
@@ -51,6 +51,7 @@ final class ThemeBuilderIntegration implements Integration
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
new TbListAbility($this->access, $conditions),
|
new TbListAbility($this->access, $conditions),
|
||||||
|
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertSame('Divi Theme Builder', $group->label());
|
self::assertSame('Divi Theme Builder', $group->label());
|
||||||
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
|
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['lit/tb-list'],
|
['lit/tb-list', 'lit/tb-create-template'],
|
||||||
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
|
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
|
||||||
);
|
);
|
||||||
foreach ($group->abilities() as $ability) {
|
foreach ($group->abilities() as $ability) {
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class TbCreateTemplateAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<array{int, string, SaveScope}> */
|
||||||
|
private array $savedNew = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
// The default template's global header 1061 is a published layout the user may edit (ruling Q8).
|
||||||
|
LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]);
|
||||||
|
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The template 2100 as Divi would store it for input(). */
|
||||||
|
private static function stored(array $overrides = []): Template
|
||||||
|
{
|
||||||
|
return ThemeBuilderFixtures::template(2100, array_merge([
|
||||||
|
'title' => 'Person',
|
||||||
|
'layouts' => [
|
||||||
|
'header' => ['id' => 1061, 'enabled' => true],
|
||||||
|
'body' => ['id' => 2101, 'enabled' => true],
|
||||||
|
'footer' => ['id' => 0, 'enabled' => false],
|
||||||
|
],
|
||||||
|
'use_on' => ['singular:post_type:lit_person:all'],
|
||||||
|
'attached' => false,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function store(int $draftId = 0, ?Template $stored = null): DiviThemeBuilder
|
||||||
|
{
|
||||||
|
return ThemeBuilderFixtures::store(
|
||||||
|
[ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)],
|
||||||
|
$draftId,
|
||||||
|
[],
|
||||||
|
[$stored ?? self::stored()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $deniedRoles */
|
||||||
|
private function ability(
|
||||||
|
DiviThemeBuilder $store,
|
||||||
|
?FakeChangeLog $log = null,
|
||||||
|
array $deniedRoles = [],
|
||||||
|
?Throwable $saveFailure = null
|
||||||
|
): TbCreateTemplateAbility {
|
||||||
|
$saver = Mockery::mock(DiviSaver::class);
|
||||||
|
$saver->allows('saveNew')->andReturnUsing(
|
||||||
|
function (int $id, string $content, SaveScope $scope) use ($saveFailure): array {
|
||||||
|
if ($saveFailure !== null) {
|
||||||
|
throw $saveFailure;
|
||||||
|
}
|
||||||
|
$this->savedNew[] = [$id, $content, $scope];
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TbCreateTemplateAbility(
|
||||||
|
ThemeBuilderFixtures::access($deniedRoles, true, $store),
|
||||||
|
new TemplateConditions($store),
|
||||||
|
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
|
||||||
|
$saver,
|
||||||
|
$log ?? DiviWriterFixture::log()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function input(array $extra = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'title' => 'Person',
|
||||||
|
'header' => ['source' => 'global'],
|
||||||
|
'body' => [
|
||||||
|
'source' => 'new',
|
||||||
|
'nodes' => [[
|
||||||
|
'type' => 'section',
|
||||||
|
'label' => 'Person',
|
||||||
|
'modules' => [
|
||||||
|
['type' => 'heading', 'text' => '{{post:title}}'],
|
||||||
|
['type' => 'text', 'content' => '{{field:role}}'],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
'footer' => ['source' => 'hidden'],
|
||||||
|
'use_on' => ['singular:post_type:lit_person:all'],
|
||||||
|
], $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function expectCreation(DiviThemeBuilder $store): void
|
||||||
|
{
|
||||||
|
$store->shouldReceive('insertLayout')->once()->with('body', 'Person – Inhalt')->andReturn(2101);
|
||||||
|
$store->shouldReceive('storeTemplate')->once()->with(1064, [
|
||||||
|
'title' => 'Person',
|
||||||
|
'layouts' => [
|
||||||
|
'header' => ['id' => 1061, 'enabled' => true],
|
||||||
|
'body' => ['id' => 2101, 'enabled' => true],
|
||||||
|
'footer' => ['id' => 0, 'enabled' => false],
|
||||||
|
],
|
||||||
|
'use_on' => ['singular:post_type:lit_person:all'],
|
||||||
|
'exclude_from' => [],
|
||||||
|
])->andReturn(2100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<int> $deleted Receives the IDs wp_delete_post() is called with. */
|
||||||
|
private static function deletions(array &$deleted): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post {
|
||||||
|
$deleted[] = $id;
|
||||||
|
|
||||||
|
return new WP_Post(['ID' => $id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewTemplateIsDisabledAndAttached(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
self::expectCreation($store);
|
||||||
|
$store->shouldReceive('attach')->once()->with(1064, 2100);
|
||||||
|
$store->shouldReceive('clearCaches')->once();
|
||||||
|
$store->shouldReceive('setEnabled')->never();
|
||||||
|
$log = DiviWriterFixture::log();
|
||||||
|
|
||||||
|
$result = $this->ability($store, $log)->execute(self::input());
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertSame(2100, $result['template_id']);
|
||||||
|
self::assertFalse($result['enabled']);
|
||||||
|
self::assertSame($store->template(2100, true)?->rev(), $result['template_rev']);
|
||||||
|
self::assertSame(['id' => 2101, 'enabled' => true, 'source' => 'new'], $result['layouts']['body']);
|
||||||
|
self::assertSame(['id' => 1061, 'enabled' => true, 'source' => 'global'], $result['layouts']['header']);
|
||||||
|
self::assertSame(['id' => 0, 'enabled' => false, 'source' => 'hidden'], $result['layouts']['footer']);
|
||||||
|
self::assertCount(1, $this->savedNew);
|
||||||
|
[$id, $content, $scope] = $this->savedNew[0];
|
||||||
|
self::assertSame(2101, $id);
|
||||||
|
self::assertSame(SaveScope::ThemeBuilderLayout, $scope);
|
||||||
|
self::assertStringStartsWith('<!-- wp:divi/placeholder -->', $content);
|
||||||
|
self::assertStringContainsString('custom_meta_role', $content);
|
||||||
|
self::assertContains(TbCreateTemplateAbility::STALE_EDITOR, $result['warnings']);
|
||||||
|
self::assertStringContainsString('deaktiviert', $result['summary']);
|
||||||
|
$log->assertRecorded([
|
||||||
|
'ability' => 'lit/tb-create-template',
|
||||||
|
'object_type' => 'et_template',
|
||||||
|
'object_id' => '2100',
|
||||||
|
]);
|
||||||
|
self::assertStringContainsString('body 2101', $log->summaries()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewLayoutsWithoutNodesAndDefaultAreasStoreNoContent(): void
|
||||||
|
{
|
||||||
|
$stored = self::stored(['layouts' => [
|
||||||
|
'header' => ['id' => 2101, 'enabled' => true],
|
||||||
|
'body' => ['id' => 0, 'enabled' => true],
|
||||||
|
'footer' => ['id' => 0, 'enabled' => true],
|
||||||
|
], 'use_on' => []]);
|
||||||
|
$store = self::store(0, $stored);
|
||||||
|
$store->shouldReceive('insertLayout')->once()->with('header', 'Eigener Kopf')->andReturn(2101);
|
||||||
|
$store->shouldReceive('storeTemplate')->once()->andReturn(2100);
|
||||||
|
$store->shouldReceive('attach')->once()->with(1064, 2100);
|
||||||
|
$store->shouldReceive('clearCaches')->once();
|
||||||
|
|
||||||
|
$result = $this->ability($store)->execute([
|
||||||
|
'title' => 'Person',
|
||||||
|
'header' => ['source' => 'new', 'title' => 'Eigener Kopf'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertSame([], $this->savedNew);
|
||||||
|
self::assertSame(['id' => 0, 'enabled' => true, 'source' => 'default'], $result['layouts']['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E7: an unsaved Theme Builder session would later replace the template list. */
|
||||||
|
public function testRefusesWhileTheThemeBuilderHasADraft(): void
|
||||||
|
{
|
||||||
|
$store = self::store(999);
|
||||||
|
$store->shouldReceive('insertLayout')->never();
|
||||||
|
$store->shouldReceive('storeTemplate')->never();
|
||||||
|
$ability = $this->ability($store);
|
||||||
|
|
||||||
|
self::assertToolError($ability->permission(self::input()), 'unsupported');
|
||||||
|
self::assertToolError($ability->execute(self::input()), 'unsupported');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidInputCreatesNothing(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->never();
|
||||||
|
|
||||||
|
$result = $this->ability($store)->execute(self::input([
|
||||||
|
'title' => '<b></b>',
|
||||||
|
'body' => ['source' => 'global'],
|
||||||
|
'footer' => ['source' => 'new', 'nodes' => [['type' => 'section', 'modules' => [['type' => 'post-content']]]]],
|
||||||
|
'use_on' => ['singular:post_type:lit_person:all', 'erfunden'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
$fields = self::errorFields($result);
|
||||||
|
self::assertArrayHasKey('title', $fields);
|
||||||
|
self::assertArrayHasKey('body.source', $fields);
|
||||||
|
self::assertArrayHasKey('use_on.1', $fields);
|
||||||
|
self::assertNotSame([], array_filter(array_keys($fields), static fn (string $key): bool => str_starts_with($key, 'footer.nodes')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNodesOnlyWithANewLayout(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->never();
|
||||||
|
|
||||||
|
$result = $this->ability($store)->execute(self::input([
|
||||||
|
'header' => ['source' => 'hidden', 'nodes' => [['type' => 'section']]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertSame('nur mit source "new"', self::errorFields($result)['header.nodes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling Q8: a reused ("global") layout must be a published layout of the area the user may edit; missing,
|
||||||
|
* trashed and not editable read the same.
|
||||||
|
*/
|
||||||
|
public function testTheGlobalHeaderMustBeAPublishedEditableLayout(): void
|
||||||
|
{
|
||||||
|
$messages = [];
|
||||||
|
foreach ([[[], []], [[ThemeBuilderFixtures::layout(1061, ['post_status' => 'trash'])], []], [[ThemeBuilderFixtures::layout(1061)], ['edit_post']]] as [$posts, $denied]) {
|
||||||
|
LibraryFixtures::given($posts, $denied);
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->never();
|
||||||
|
|
||||||
|
$result = $this->ability($store)->execute(self::input());
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
$messages[] = self::errorFields($result)['header.source'] ?? null;
|
||||||
|
}
|
||||||
|
self::assertNotNull($messages[0]);
|
||||||
|
self::assertSame([$messages[0], $messages[0], $messages[0]], $messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedTemplateStoreDeletesTheLayouts(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
|
||||||
|
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('Divi called wp_die(): '));
|
||||||
|
$store->shouldReceive('attach')->never();
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101]));
|
||||||
|
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
$this->ability($store)->execute(self::input());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedLayoutSaveDeletesTheLayout(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
|
||||||
|
$store->shouldReceive('storeTemplate')->never();
|
||||||
|
$deleted = [];
|
||||||
|
self::deletions($deleted);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->ability($store, null, [], new RuntimeException('kses window'))->execute(self::input());
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
self::assertSame([2101], $deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Divi drops layout IDs that are no published layout of the area (theme-builder.php:1486-1496). */
|
||||||
|
public function testDiviDroppingALayoutIsRolledBack(): void
|
||||||
|
{
|
||||||
|
$dropped = self::stored(['layouts' => [
|
||||||
|
'header' => ['id' => 1061, 'enabled' => true],
|
||||||
|
'body' => ['id' => 0, 'enabled' => true],
|
||||||
|
'footer' => ['id' => 0, 'enabled' => false],
|
||||||
|
]]);
|
||||||
|
$store = self::store(0, $dropped);
|
||||||
|
self::expectCreation($store);
|
||||||
|
$store->shouldReceive('attach')->never();
|
||||||
|
$deleted = [];
|
||||||
|
self::deletions($deleted);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->ability($store)->execute(self::input());
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
self::assertSame([2100, 2101], $deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer F1 / ruling Q5: a template that reads back enabled is removed again, never left live. */
|
||||||
|
public function testATemplateStoredEnabledIsRolledBack(): void
|
||||||
|
{
|
||||||
|
$store = self::store(0, self::stored(['enabled' => true]));
|
||||||
|
self::expectCreation($store);
|
||||||
|
$store->shouldReceive('attach')->never();
|
||||||
|
$deleted = [];
|
||||||
|
self::deletions($deleted);
|
||||||
|
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
try {
|
||||||
|
$this->ability($store)->execute(self::input());
|
||||||
|
} finally {
|
||||||
|
self::assertSame([2100, 2101], $deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedAttachDetachesAndDeletesEverything(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
self::expectCreation($store);
|
||||||
|
$store->shouldReceive('attach')->once()->andThrow(new RuntimeException('not added'));
|
||||||
|
$store->shouldReceive('detach')->once()->with(1064, 2100);
|
||||||
|
$deleted = [];
|
||||||
|
self::deletions($deleted);
|
||||||
|
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
try {
|
||||||
|
$this->ability($store)->execute(self::input());
|
||||||
|
} finally {
|
||||||
|
self::assertSame([2100, 2101], $deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testACleanupThatFailsIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
|
||||||
|
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('store failed'));
|
||||||
|
Functions\when('wp_delete_post')->justReturn(false);
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->ability($store, $log)->execute(self::input());
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertToolError($e->toError(), 'partial_write');
|
||||||
|
}
|
||||||
|
$log->assertPartials(['ability' => 'lit/tb-create-template', 'object_id' => '2101']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNeedsTheThemeBuilderRole(): void
|
||||||
|
{
|
||||||
|
$store = self::store();
|
||||||
|
$store->shouldReceive('insertLayout')->never();
|
||||||
|
$ability = $this->ability($store, null, ['theme_builder']);
|
||||||
|
|
||||||
|
self::assertToolError($ability->permission(self::input()), 'forbidden');
|
||||||
|
self::assertToolError($ability->execute(self::input()), 'forbidden');
|
||||||
|
self::assertFalse($ability->isDirect());
|
||||||
|
self::assertFalse($ability->annotations()['destructive']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNeverOffersToEnable(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability(self::store());
|
||||||
|
$schema = $ability->inputSchema();
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('enabled', $schema['properties']);
|
||||||
|
self::assertFalse($schema['additionalProperties']);
|
||||||
|
self::assertSame(TbCreateTemplateAbility::SOURCES, $schema['properties']['body']['properties']['source']['enum']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateBuild;
|
||||||
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
|
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class TemplateBuildTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRollsBackEveryCreatedPostInReverse(): void
|
||||||
|
{
|
||||||
|
$store = ThemeBuilderFixtures::store();
|
||||||
|
$store->shouldReceive('insertLayout')->andReturn(2101, 2102);
|
||||||
|
$store->shouldReceive('storeTemplate')->andReturn(2100);
|
||||||
|
$store->shouldReceive('attach')->once()->with(1064, 2100);
|
||||||
|
$store->shouldReceive('detach')->once()->with(1064, 2100);
|
||||||
|
$deleted = [];
|
||||||
|
Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post {
|
||||||
|
$deleted[] = [$id, $force];
|
||||||
|
|
||||||
|
return new WP_Post(['ID' => $id]);
|
||||||
|
});
|
||||||
|
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||||||
|
$build->layout('header', 'Kopf');
|
||||||
|
$build->layout('body', 'Inhalt');
|
||||||
|
$build->template(1064, ['title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]);
|
||||||
|
$build->attach(1064, 2100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$build->rollBack(new RuntimeException('Divi dropped a layout'));
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
self::assertSame([[2100, true], [2102, true], [2101, true]], $deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q4/B4: the partial write is logged as a partial row naming the posts left behind. */
|
||||||
|
public function testAFailedDeleteIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
$store = ThemeBuilderFixtures::store();
|
||||||
|
$store->shouldReceive('insertLayout')->andReturn(2101);
|
||||||
|
Functions\when('wp_delete_post')->justReturn(false);
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
$build = new TemplateBuild($store, $log, 'lit/tb-create-template');
|
||||||
|
$build->layout('body', 'Inhalt');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$build->rollBack(new RuntimeException('store failed'));
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame([2101], $e->details['created_ids']);
|
||||||
|
self::assertToolError($e->toError(), 'partial_write');
|
||||||
|
}
|
||||||
|
$log->assertPartials([
|
||||||
|
'ability' => 'lit/tb-create-template',
|
||||||
|
'object_type' => 'et_template',
|
||||||
|
'object_id' => '2101',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARefusalAfterTheInsertRemovesTheNewPostsAndReturnsTheRefusal(): void
|
||||||
|
{
|
||||||
|
$store = ThemeBuilderFixtures::store();
|
||||||
|
$store->shouldReceive('insertLayout')->andReturn(2101);
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101]));
|
||||||
|
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||||||
|
$build->layout('body', 'Inhalt');
|
||||||
|
$refusal = Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', ['body.nodes' => 'x']);
|
||||||
|
|
||||||
|
self::assertSame($refusal, $build->refuse($refusal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedCleanupAfterARefusalIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
$store = ThemeBuilderFixtures::store();
|
||||||
|
$store->shouldReceive('insertLayout')->andReturn(2101);
|
||||||
|
Functions\when('wp_delete_post')->justReturn(null);
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
$build = new TemplateBuild($store, $log, 'lit/tb-create-template');
|
||||||
|
$build->layout('body', 'Inhalt');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$build->refuse(Errors::invalidInput('x', ['body.nodes' => 'x']));
|
||||||
|
self::fail('no WriteFailed');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
}
|
||||||
|
$log->assertPartials(['object_id' => '2101']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q8/B12: a live Theme Builder post Divi creates for this build is part of it (all or nothing). */
|
||||||
|
public function testALiveThemeBuilderPostCreatedHereIsRolledBackToo(): void
|
||||||
|
{
|
||||||
|
$store = Mockery::mock(DiviThemeBuilder::class);
|
||||||
|
$store->shouldReceive('liveId')->once()->with(false)->andReturn(0);
|
||||||
|
$store->shouldReceive('liveId')->once()->with(true)->andReturn(3000);
|
||||||
|
$store->shouldReceive('insertLayout')->andReturn(2101);
|
||||||
|
$deleted = [];
|
||||||
|
Functions\when('wp_delete_post')->alias(static function (int $id) use (&$deleted): WP_Post {
|
||||||
|
$deleted[] = $id;
|
||||||
|
|
||||||
|
return new WP_Post(['ID' => $id]);
|
||||||
|
});
|
||||||
|
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||||||
|
$build->layout('body', 'Inhalt');
|
||||||
|
|
||||||
|
self::assertSame(3000, $build->themeBuilder());
|
||||||
|
self::assertSame([2101, 3000], $build->created());
|
||||||
|
try {
|
||||||
|
$build->rollBack(new RuntimeException('later failure'));
|
||||||
|
} catch (WriteFailed) {
|
||||||
|
}
|
||||||
|
self::assertSame([3000, 2101], $deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnExistingLiveThemeBuilderPostIsNeverDeleted(): void
|
||||||
|
{
|
||||||
|
$store = Mockery::mock(DiviThemeBuilder::class);
|
||||||
|
$store->shouldReceive('liveId')->once()->with(false)->andReturn(1064);
|
||||||
|
$store->shouldReceive('liveId')->with(true)->never();
|
||||||
|
Functions\expect('wp_delete_post')->never();
|
||||||
|
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||||||
|
|
||||||
|
self::assertSame(1064, $build->themeBuilder());
|
||||||
|
self::assertSame([], $build->created());
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
$build->rollBack(new RuntimeException('later failure'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoLiveThemeBuilderPostThrows(): void
|
||||||
|
{
|
||||||
|
$store = Mockery::mock(DiviThemeBuilder::class);
|
||||||
|
$store->shouldReceive('liveId')->andReturn(0);
|
||||||
|
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$build->themeBuilder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,22 @@ final class ThemeBuilderAccessTest extends TestCase
|
|||||||
self::assertToolError(ThemeBuilderFixtures::access([], false)->template(1066, true), 'forbidden');
|
self::assertToolError(ThemeBuilderFixtures::access([], false)->template(1066, true), 'forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling Q18: a template the user may not edit answers exactly like a missing one (like tb-list, Q8/B11). */
|
||||||
|
public function testATemplateTheUserMayNotEditReadsLikeAMissingOne(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([ThemeBuilderFixtures::templatePost()], ['edit_post']);
|
||||||
|
|
||||||
|
foreach ([false, true] as $forWrite) {
|
||||||
|
$hidden = ThemeBuilderFixtures::access()->template(ThemeBuilderFixtures::TEMPLATE, $forWrite);
|
||||||
|
$missing = ThemeBuilderFixtures::access()->template(1067, $forWrite);
|
||||||
|
self::assertToolError($hidden, 'not_found');
|
||||||
|
self::assertSame(
|
||||||
|
str_replace('1067', '1066', (string) json_encode(self::clientError($missing))),
|
||||||
|
(string) json_encode(self::clientError($hidden))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testCreatingNeedsPublishRightsAndTheGroup(): void
|
public function testCreatingNeedsPublishRightsAndTheGroup(): void
|
||||||
{
|
{
|
||||||
LibraryFixtures::given([]);
|
LibraryFixtures::given([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user