feat: lit/library-save creates library entries like Divi (E4)
Reuses NewDraft for insert and rollback (P6), requires edit_global_library for scope global (P7), refuses opaque and global roots before wrapping (P11) and checks the node with StoredNodeGuard. The source page is never written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
|
|||||||
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
||||||
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
||||||
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
|
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
|
||||||
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`).
|
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`).
|
||||||
|
|
||||||
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
||||||
|
|
||||||
|
|||||||
@@ -100,15 +100,7 @@ final class DiviIntegration implements Integration
|
|||||||
public function abilities(): array
|
public function abilities(): array
|
||||||
{
|
{
|
||||||
$loader = $this->loader();
|
$loader = $this->loader();
|
||||||
$composition = new DiviComposition($this->modules, $this->design);
|
$writer = $this->writer($loader);
|
||||||
$writer = new DiviWriter(
|
|
||||||
$loader,
|
|
||||||
new DiviSaver(),
|
|
||||||
$this->log,
|
|
||||||
$this->design,
|
|
||||||
$composition,
|
|
||||||
new GlobalEdits($this->access, new GlobalUsages())
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new DiviOutlineAbility($loader, $this->design),
|
new DiviOutlineAbility($loader, $this->design),
|
||||||
@@ -127,7 +119,29 @@ final class DiviIntegration implements Integration
|
|||||||
/** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */
|
/** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */
|
||||||
public function library(): LibraryIntegration
|
public function library(): LibraryIntegration
|
||||||
{
|
{
|
||||||
return new LibraryIntegration($this->access, $this->loader(), $this->design);
|
$loader = $this->loader();
|
||||||
|
|
||||||
|
return new LibraryIntegration(
|
||||||
|
$this->access,
|
||||||
|
$loader,
|
||||||
|
$this->design,
|
||||||
|
$this->writer($loader),
|
||||||
|
new DiviSaver(),
|
||||||
|
$this->log,
|
||||||
|
$this->modules
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writer(DiviPageLoader $loader): DiviWriter
|
||||||
|
{
|
||||||
|
return new DiviWriter(
|
||||||
|
$loader,
|
||||||
|
new DiviSaver(),
|
||||||
|
$this->log,
|
||||||
|
$this->design,
|
||||||
|
new DiviComposition($this->modules, $this->design),
|
||||||
|
new GlobalEdits($this->access, new GlobalUsages())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loader(): DiviPageLoader
|
private function loader(): DiviPageLoader
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Library;
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
use WP_Term;
|
use WP_Term;
|
||||||
|
|
||||||
@@ -126,6 +131,50 @@ class DiviLibraryStore
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Client text when the terms or meta of a new entry could not be written. */
|
||||||
|
public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A published entry with the terms and meta of $spec and empty content (E4; the layout follows
|
||||||
|
* through DiviSaver::saveNew()). All or nothing through $draft (ruling P6, the transaction of
|
||||||
|
* lit/content-create): a refused insert or missing taxonomies create nothing; a failed term or
|
||||||
|
* meta write deletes the entry again (write_failed), or reports partial_write and logs it
|
||||||
|
* when that delete fails; a throwing insert hook is a partial write without ID.
|
||||||
|
*
|
||||||
|
* @throws WriteFailed
|
||||||
|
*/
|
||||||
|
public function create(LibraryItemSpec $spec, NewDraft $draft): int
|
||||||
|
{
|
||||||
|
$missing = $this->missingTaxonomies();
|
||||||
|
if ($missing !== []) {
|
||||||
|
throw WriteFailed::rolledBack(
|
||||||
|
'Die Divi-Bibliothek ist in dieser Anfrage nicht geladen.',
|
||||||
|
detail: 'Divi Library taxonomies not registered: ' . implode(', ', $missing)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $draft->insert([
|
||||||
|
'post_type' => self::POST_TYPE,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_title' => $spec->title,
|
||||||
|
'post_content' => '',
|
||||||
|
]);
|
||||||
|
if ($id instanceof WP_Error) {
|
||||||
|
throw WriteFailed::rolledBack(
|
||||||
|
'WordPress hat den Bibliothekseintrag abgelehnt.',
|
||||||
|
detail: 'wp_insert_post refused the library entry: ' . $id->get_error_message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->describeNew($id, $spec);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$draft->discard($id, $e, self::REASON_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library taxonomies not registered in this request (F2: Divi registers them in
|
* Library taxonomies not registered in this request (F2: Divi registers them in
|
||||||
* ET_Builder_Library; fixture finding 6: not in every context).
|
* ET_Builder_Library; fixture finding 6: not in every context).
|
||||||
@@ -140,6 +189,27 @@ class DiviLibraryStore
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terms and meta of $spec on the new entry $id, each checked.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
private function describeNew(int $id, LibraryItemSpec $spec): void
|
||||||
|
{
|
||||||
|
foreach ($spec->terms() as $taxonomy => $terms) {
|
||||||
|
$result = wp_set_object_terms($id, $terms, $taxonomy);
|
||||||
|
if ($result instanceof WP_Error) {
|
||||||
|
throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($spec->meta() as $key => $value) {
|
||||||
|
update_post_meta($id, $key, $value);
|
||||||
|
if ((string) get_post_meta($id, $key, true) !== $value) {
|
||||||
|
throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<WP_Term> */
|
/** @return list<WP_Term> */
|
||||||
private function terms(int $id, string $taxonomy): array
|
private function terms(int $id, string $taxonomy): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,6 +120,34 @@ class LibraryAccess
|
|||||||
return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null;
|
return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always
|
||||||
|
* published, F2), the role option save_library and the Tools-tab gate; a global entry
|
||||||
|
* additionally edit_global_library (ruling P7 – the same option as for editing one, F12).
|
||||||
|
*/
|
||||||
|
public function canSave(bool $global = false): ?WP_Error
|
||||||
|
{
|
||||||
|
$refusal = $this->canList();
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = get_post_type_object(DiviLibraryStore::POST_TYPE);
|
||||||
|
$publish = is_object($type) && isset($type->cap->publish_posts)
|
||||||
|
? (string) $type->cap->publish_posts
|
||||||
|
: 'publish_posts';
|
||||||
|
if (! current_user_can($publish)) {
|
||||||
|
return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$refusal = $this->roles->refusal('save_library') ?? $this->writableGate();
|
||||||
|
if ($refusal !== null || ! $global) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->roles->refusal('edit_global_library');
|
||||||
|
}
|
||||||
|
|
||||||
/** Refusal while the Tools tab keeps this connection from writing to the library. */
|
/** Refusal while the Tools tab keeps this connection from writing to the library. */
|
||||||
public function writableGate(): ?WP_Error
|
public function writableGate(): ?WP_Error
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Library;
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The group "Divi-Bibliothek" (E9): lit/library-list, later lit/library-save and lit-library-insert.
|
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, later lit-library-insert.
|
||||||
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
||||||
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
||||||
* Divi 5 builder, like the Divi group.
|
* Divi 5 builder, like the Divi group.
|
||||||
@@ -22,7 +25,11 @@ final class LibraryIntegration implements Integration
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private LibraryAccess $access,
|
private LibraryAccess $access,
|
||||||
private DiviPageLoader $loader,
|
private DiviPageLoader $loader,
|
||||||
private DesignSystemReader $design
|
private DesignSystemReader $design,
|
||||||
|
private DiviWriter $writer,
|
||||||
|
private DiviSaver $saver,
|
||||||
|
private ChangeLog $log,
|
||||||
|
private DiviModules $modules
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +52,14 @@ final class LibraryIntegration implements Integration
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
|
new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
|
||||||
|
new LibrarySaveAbility(
|
||||||
|
$this->access,
|
||||||
|
$this->loader,
|
||||||
|
$this->writer,
|
||||||
|
$this->saver,
|
||||||
|
$this->log,
|
||||||
|
$this->modules
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title, terms and meta of a new library entry, like Divi's et_pb_submit_layout() writes them
|
||||||
|
* (builder/shortcode-core.php:654-734; compared with the real builder entries, fixtures stage3):
|
||||||
|
* terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag;
|
||||||
|
* meta _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
|
||||||
|
* _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
|
||||||
|
* "[object Object]" there (finding 5). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class LibraryItemSpec
|
||||||
|
{
|
||||||
|
private const MODULE_CATEGORIES = ['module', 'fullwidth-module'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $categories layout_category term IDs.
|
||||||
|
* @param list<int> $tags layout_tag term IDs.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $title,
|
||||||
|
public readonly string $layoutType,
|
||||||
|
public readonly bool $global,
|
||||||
|
public readonly string $moduleWidth,
|
||||||
|
public readonly ?string $moduleType,
|
||||||
|
public readonly string $builtFor,
|
||||||
|
public readonly array $categories = [],
|
||||||
|
public readonly array $tags = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry that holds $node, or the German reason it cannot be saved: sections, rows and
|
||||||
|
* modules only (Divi's "add to library"); a global module is already an entry; a global entry
|
||||||
|
* holds no global module (F10). Opaque roots and global instances are refused before anything
|
||||||
|
* wraps them (ruling P11), so no entry ever starts with an instance.
|
||||||
|
*/
|
||||||
|
public static function forNode(
|
||||||
|
Block $node,
|
||||||
|
ModuleCatalog $catalog,
|
||||||
|
string $title,
|
||||||
|
bool $global,
|
||||||
|
string $builtFor
|
||||||
|
): self|string {
|
||||||
|
if ($node->isGlobal()) {
|
||||||
|
$ref = NodeSummary::globalRef($node);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert '
|
||||||
|
. 'einfügen statt erneut speichern.',
|
||||||
|
$ref['id'] ?? '?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($node->isOpaque()) {
|
||||||
|
return sprintf(
|
||||||
|
'Die Einstellungen von %s sind nicht lesbar; der Knoten lässt sich nicht in der Bibliothek speichern.',
|
||||||
|
$node->name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $node->name();
|
||||||
|
if ($name === 'divi/section') {
|
||||||
|
$type = 'section';
|
||||||
|
$width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular';
|
||||||
|
} elseif ($name === 'divi/row') {
|
||||||
|
$type = 'row';
|
||||||
|
$width = 'regular';
|
||||||
|
} elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) {
|
||||||
|
$type = 'module';
|
||||||
|
$width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular';
|
||||||
|
} else {
|
||||||
|
return sprintf(
|
||||||
|
'%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).',
|
||||||
|
$name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($global && LibraryContent::containsGlobal($node)) {
|
||||||
|
return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
|
||||||
|
. 'Verschachtelung); als normalen Eintrag speichern.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $categories
|
||||||
|
* @param list<int> $tags
|
||||||
|
*/
|
||||||
|
public function withTerms(array $categories, array $tags): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->title,
|
||||||
|
$this->layoutType,
|
||||||
|
$this->global,
|
||||||
|
$this->moduleWidth,
|
||||||
|
$this->moduleType,
|
||||||
|
$this->builtFor,
|
||||||
|
$categories,
|
||||||
|
$tags
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<int|string>> Taxonomy => slugs (system terms) or term IDs. */
|
||||||
|
public function terms(): array
|
||||||
|
{
|
||||||
|
$terms = [
|
||||||
|
'scope' => [$this->global ? 'global' : 'non_global'],
|
||||||
|
'layout_type' => [$this->layoutType],
|
||||||
|
'module_width' => [$this->moduleWidth],
|
||||||
|
];
|
||||||
|
if ($this->categories !== []) {
|
||||||
|
$terms['layout_category'] = $this->categories;
|
||||||
|
}
|
||||||
|
if ($this->tags !== []) {
|
||||||
|
$terms['layout_tag'] = $this->tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function meta(): array
|
||||||
|
{
|
||||||
|
$meta = [];
|
||||||
|
if ($this->moduleType !== null) {
|
||||||
|
$meta['_et_pb_module_type'] = $this->moduleType;
|
||||||
|
}
|
||||||
|
if ($this->global && $this->layoutType === 'module') {
|
||||||
|
$meta['_et_pb_excluded_global_options'] = '[]';
|
||||||
|
}
|
||||||
|
$meta['_et_pb_built_for_post_type'] = $this->builtFor;
|
||||||
|
$meta['_et_pb_template_type'] = $this->layoutType;
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Term;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/library-save: a node of a Divi page (or library entry) becomes a new Divi Library entry,
|
||||||
|
* normal or global (spec §5.7, E4). The entry gets the node's stored bytes in the
|
||||||
|
* divi/placeholder frame (LibraryContent::wrap, line breaks between blocks unified like
|
||||||
|
* LibraryContent::load), saved through DiviSaver::saveNew with SaveScope::LibraryItem (the one
|
||||||
|
* content write, D7; no Divi flags). The node is checked by StoredNodeGuard (E6) because it moves
|
||||||
|
* to another post. The source is never written – linking it to the entry is
|
||||||
|
* lit/library-make-global (Etappe 3b). All or nothing through NewDraft (ruling P6): any failure
|
||||||
|
* after the insert deletes the new entry, a failed delete is an honest partial_write.
|
||||||
|
*/
|
||||||
|
final class LibrarySaveAbility implements Ability
|
||||||
|
{
|
||||||
|
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
|
||||||
|
|
||||||
|
private const SCOPES = ['normal', 'global'];
|
||||||
|
|
||||||
|
private const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private DiviWriter $writer,
|
||||||
|
private DiviSaver $saver,
|
||||||
|
private ChangeLog $log,
|
||||||
|
private DiviModules $modules
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/library-save';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'In der Divi-Bibliothek speichern';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – '
|
||||||
|
. 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die '
|
||||||
|
. 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit '
|
||||||
|
. 'lit-library-insert (mode "global") auf Seiten einfügen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'post_id' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'minimum' => 1,
|
||||||
|
'description' => 'Seite (oder Eintrag) mit dem Knoten.',
|
||||||
|
],
|
||||||
|
'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'],
|
||||||
|
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
|
||||||
|
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||||||
|
'scope' => ['type' => 'string', 'enum' => self::SCOPES, 'description' => 'Standard: normal.'],
|
||||||
|
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||||||
|
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||||||
|
],
|
||||||
|
'required' => ['post_id', 'tree_rev', 'id', 'title'],
|
||||||
|
'additionalProperties' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputSchema(): ?array
|
||||||
|
{
|
||||||
|
return ToolResult::schema(
|
||||||
|
['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']],
|
||||||
|
['library_id', 'tree_rev']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annotations(): array
|
||||||
|
{
|
||||||
|
return Annotations::write(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDirect(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permission(array $input): bool|WP_Error
|
||||||
|
{
|
||||||
|
$refusal = $this->access->canSave(self::isGlobal($input));
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->loader->load($input['post_id'] ?? null, false);
|
||||||
|
|
||||||
|
return $page instanceof WP_Error ? $page : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6). */
|
||||||
|
public function execute(array $input): mixed
|
||||||
|
{
|
||||||
|
if (! in_array($input['scope'] ?? 'normal', self::SCOPES, true)) {
|
||||||
|
return Errors::invalidInput('scope ist „normal“ oder „global“; nichts wurde angelegt.', [
|
||||||
|
'scope' => 'normal oder global',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$refusal = $this->access->canSave(self::isGlobal($input));
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->loader->load($input['post_id'] ?? null, false);
|
||||||
|
if ($page instanceof WP_Error) {
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
|
||||||
|
return Errors::revMismatch(
|
||||||
|
'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.',
|
||||||
|
DiviPageLoader::OUTLINE_HINT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||||||
|
$node = $page->index->find($id);
|
||||||
|
if ($node === null) {
|
||||||
|
return DiviErrors::unknownNode($id, $page->post);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||||||
|
if ($title === '') {
|
||||||
|
return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$spec = LibraryItemSpec::forNode(
|
||||||
|
$node,
|
||||||
|
$this->modules->catalog(),
|
||||||
|
$title,
|
||||||
|
self::isGlobal($input),
|
||||||
|
$this->builtFor($page)
|
||||||
|
);
|
||||||
|
if (is_string($spec)) {
|
||||||
|
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$problems = $this->writer->composition($page)->storedGuard()->problems($node);
|
||||||
|
if ($problems !== []) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
|
||||||
|
. 'angelegt.',
|
||||||
|
['id' => implode('; ', $problems)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = $this->termIds('layout_category', $input['categories'] ?? []);
|
||||||
|
$tags = $this->termIds('layout_tag', $input['tags'] ?? []);
|
||||||
|
foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) {
|
||||||
|
if (is_string($ids)) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.',
|
||||||
|
[$field => $ids]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->create($page, $spec->withTerms($categories, $tags), self::content($node), $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws WriteFailed
|
||||||
|
*/
|
||||||
|
private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array
|
||||||
|
{
|
||||||
|
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||||||
|
$libraryId = $this->access->store()->create($spec, $draft);
|
||||||
|
|
||||||
|
$warnings = [];
|
||||||
|
try {
|
||||||
|
$warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$draft->discard($libraryId, $e, self::REASON_LAYOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings))
|
||||||
|
->afterSave($input);
|
||||||
|
$created = $followUp->reread();
|
||||||
|
$kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : '');
|
||||||
|
$followUp->log(
|
||||||
|
DiviLibraryStore::POST_TYPE,
|
||||||
|
sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created)
|
||||||
|
->with('library_id', $libraryId)
|
||||||
|
->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content))
|
||||||
|
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
|
||||||
|
if ($spec->global) {
|
||||||
|
$result->warning(
|
||||||
|
'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert '
|
||||||
|
. '(mode "global") einfügen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node's stored bytes framed like the builder's "add to library", line breaks unified. */
|
||||||
|
private static function content(Block $node): string
|
||||||
|
{
|
||||||
|
$copy = $node->copy();
|
||||||
|
$copy->unifyLineBreaks();
|
||||||
|
|
||||||
|
return LibraryContent::wrap($copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */
|
||||||
|
private function builtFor(DiviPage $page): string
|
||||||
|
{
|
||||||
|
if (! $page->isLibraryItem()) {
|
||||||
|
return (string) $page->post->post_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true);
|
||||||
|
|
||||||
|
return $builtFor !== '' ? $builtFor : 'page';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $input */
|
||||||
|
private static function isGlobal(array $input): bool
|
||||||
|
{
|
||||||
|
return ($input['scope'] ?? 'normal') === 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
|
||||||
|
private function termIds(string $taxonomy, mixed $names): array|string
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach (is_array($names) ? $names : [] as $name) {
|
||||||
|
$term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false;
|
||||||
|
if (! $term instanceof WP_Term) {
|
||||||
|
return sprintf(
|
||||||
|
'„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).',
|
||||||
|
is_string($name) ? $name : '?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$ids[] = (int) $term->term_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ defined('ABSPATH') || exit;
|
|||||||
* returned like any invalid input; partial write if the delete fails.
|
* returned like any invalid input; partial write if the delete fails.
|
||||||
* - discard(): writing after the insert failed – created terms and the draft are deleted;
|
* - discard(): writing after the insert failed – created terms and the draft are deleted;
|
||||||
* rolled back, or partial write if that cleanup failed.
|
* rolled back, or partial write if that cleanup failed.
|
||||||
* Partial writes are logged with result "partial".
|
* Partial writes are logged with result "partial". lit/library-save reuses it for a new library
|
||||||
|
* entry (ruling P6): no PostWriter (no terms of its own to clean up), $noun names the entry.
|
||||||
*/
|
*/
|
||||||
final class NewDraft
|
final class NewDraft
|
||||||
{
|
{
|
||||||
@@ -30,11 +31,13 @@ final class NewDraft
|
|||||||
public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
||||||
public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.';
|
public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.';
|
||||||
|
|
||||||
|
/** @param string $noun What the new post is called in change-log summaries. */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private PostWriter $writer,
|
private ?PostWriter $writer,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private string $ability,
|
private string $ability,
|
||||||
private string $postType
|
private string $postType,
|
||||||
|
private string $noun = 'Entwurf'
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ final class NewDraft
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
|
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
|
||||||
// searched or deleted, the client is told to check (lit_partial_write without id).
|
// searched or deleted, the client is told to check (lit_partial_write without id).
|
||||||
$this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
|
$this->logPartial('', sprintf('Anlegen abgebrochen; %s möglicherweise vorhanden', $this->noun));
|
||||||
throw WriteFailed::partial(self::REASON_INSERT, $e);
|
throw WriteFailed::partial(self::REASON_INSERT, $e);
|
||||||
}
|
}
|
||||||
if ($postId instanceof WP_Error) {
|
if ($postId instanceof WP_Error) {
|
||||||
@@ -77,7 +80,7 @@ final class NewDraft
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $deleted) {
|
if (! $deleted) {
|
||||||
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
|
$summary = sprintf('%s %d: Anlegen nicht zurückgenommen', $this->noun, $postId);
|
||||||
$this->logPartial((string) $postId, $summary);
|
$this->logPartial((string) $postId, $summary);
|
||||||
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
|
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
|
||||||
throw WriteFailed::partial(
|
throw WriteFailed::partial(
|
||||||
@@ -102,7 +105,7 @@ final class NewDraft
|
|||||||
public function discard(int $postId, Throwable $cause, string $reason): never
|
public function discard(int $postId, Throwable $cause, string $reason): never
|
||||||
{
|
{
|
||||||
$problems = Rollback::attempt(
|
$problems = Rollback::attempt(
|
||||||
fn () => $this->writer->deleteCreatedTerms(),
|
fn () => $this->writer?->deleteCreatedTerms(),
|
||||||
static fn () => self::deleteDraft($postId)
|
static fn () => self::deleteDraft($postId)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ final class NewDraft
|
|||||||
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
|
$summary = sprintf('%s %d: Anlegen nur teilweise zurückgenommen', $this->noun, $postId);
|
||||||
$this->logPartial((string) $postId, $summary);
|
$this->logPartial((string) $postId, $summary);
|
||||||
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
|
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertSame('Divi-Bibliothek', $library->label());
|
self::assertSame('Divi-Bibliothek', $library->label());
|
||||||
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['lit/library-list'],
|
['lit/library-list', 'lit/library-save'],
|
||||||
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
||||||
);
|
);
|
||||||
foreach ($library->abilities() as $ability) {
|
foreach ($library->abilities() as $ability) {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||||
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
use WP_Term;
|
use WP_Term;
|
||||||
@@ -99,4 +103,130 @@ final class DiviLibraryStoreTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies());
|
self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function draft(?FakeChangeLog $log = null): NewDraft
|
||||||
|
{
|
||||||
|
return new NewDraft(null, $log ?? FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreatesAPublishedEntryWithTermsAndMeta(): void
|
||||||
|
{
|
||||||
|
Functions\when('taxonomy_exists')->justReturn(true);
|
||||||
|
$inserted = [];
|
||||||
|
Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int {
|
||||||
|
$inserted = $data;
|
||||||
|
|
||||||
|
return 2001;
|
||||||
|
});
|
||||||
|
$terms = [];
|
||||||
|
Functions\when('wp_set_object_terms')->alias(
|
||||||
|
static function (int $id, array $values, string $taxonomy) use (&$terms): array {
|
||||||
|
$terms[$taxonomy] = $values;
|
||||||
|
|
||||||
|
return [1];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$meta = [];
|
||||||
|
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool {
|
||||||
|
$meta[$key] = $value;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// By reference: an arrow function would capture the still empty $meta.
|
||||||
|
Functions\when('get_post_meta')->alias(static function (int $id, string $key) use (&$meta): string {
|
||||||
|
return $meta[$key] ?? '';
|
||||||
|
});
|
||||||
|
$spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page');
|
||||||
|
|
||||||
|
self::assertSame(2001, (new DiviLibraryStore())->create($spec, self::draft()));
|
||||||
|
self::assertSame(
|
||||||
|
['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''],
|
||||||
|
$inserted
|
||||||
|
);
|
||||||
|
self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms);
|
||||||
|
self::assertSame('[]', $meta['_et_pb_excluded_global_options']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedStepDeletesTheNewEntry(): void
|
||||||
|
{
|
||||||
|
Functions\when('taxonomy_exists')->justReturn(true);
|
||||||
|
Functions\when('wp_insert_post')->justReturn(2001);
|
||||||
|
Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.'));
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft());
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P6: a failed rollback is an honest partial write, logged as partial (NewDraft). */
|
||||||
|
public function testAFailedRollbackIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
Functions\when('taxonomy_exists')->justReturn(true);
|
||||||
|
Functions\when('wp_insert_post')->justReturn(2001);
|
||||||
|
Functions\when('wp_set_object_terms')->justReturn([1]);
|
||||||
|
Functions\when('update_post_meta')->justReturn(false);
|
||||||
|
Functions\when('get_post_meta')->justReturn('');
|
||||||
|
Functions\when('wp_delete_post')->justReturn(false);
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log));
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame(2001, $e->postId);
|
||||||
|
}
|
||||||
|
$log->assertPartials([
|
||||||
|
'ability' => 'lit/library-save',
|
||||||
|
'object_type' => 'et_pb_layout',
|
||||||
|
'object_id' => '2001',
|
||||||
|
'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P6: a throwing insert hook leaves the entry's existence unknown – partial write without id. */
|
||||||
|
public function testAThrowingInsertHookIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
Functions\when('taxonomy_exists')->justReturn(true);
|
||||||
|
Functions\when('wp_insert_post')->alias(static function (): never {
|
||||||
|
throw new \RuntimeException('hook kaputt');
|
||||||
|
});
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log));
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertNull($e->postId);
|
||||||
|
}
|
||||||
|
self::assertCount(1, $log->partials);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARefusedInsertCreatesNothing(): void
|
||||||
|
{
|
||||||
|
Functions\when('taxonomy_exists')->justReturn(true);
|
||||||
|
Functions\when('wp_insert_post')->justReturn(new \WP_Error('db_insert_error', 'Datenbankfehler.'));
|
||||||
|
Functions\expect('wp_delete_post')->never();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft());
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void
|
||||||
|
{
|
||||||
|
Functions\when('taxonomy_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_post')->never();
|
||||||
|
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,4 +142,28 @@ final class LibraryAccessTest extends TestCase
|
|||||||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false));
|
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false));
|
||||||
self::assertNull(LibraryFixtures::access()->writableGate());
|
self::assertNull(LibraryFixtures::access()->writableGate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
self::assertNull(LibraryFixtures::access()->canSave());
|
||||||
|
|
||||||
|
LibraryFixtures::given([], ['publish_posts']);
|
||||||
|
self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden');
|
||||||
|
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden');
|
||||||
|
self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden');
|
||||||
|
self::assertToolError(LibraryFixtures::access(['divi_library'])->canSave(), 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P7: a global entry additionally needs Divi's edit_global_library. */
|
||||||
|
public function testSavingAGlobalEntryNeedsEditGlobalLibrary(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
|
||||||
|
self::assertNull(LibraryFixtures::access(['edit_global_library'])->canSave());
|
||||||
|
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden');
|
||||||
|
self::assertNull(LibraryFixtures::access()->canSave(true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class LibraryItemSpecTest extends TestCase
|
||||||
|
{
|
||||||
|
private static function pageNode(string $id): Block
|
||||||
|
{
|
||||||
|
return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')))
|
||||||
|
->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function libraryRoot(string $file): Block
|
||||||
|
{
|
||||||
|
return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, bool}> Fixture role => [node source, global]. */
|
||||||
|
public static function builderEntries(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'library--library-abschnitt' => ['page:s1', false],
|
||||||
|
'library--library-zeile' => ['page:s1.r0', false],
|
||||||
|
'library--library-modul' => ['page:s1.r0.c0.m0', false],
|
||||||
|
'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true],
|
||||||
|
'library-global--globales-modul' => ['library:library-global--globales-modul.html', true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry),
|
||||||
|
* without Divi's broken _et_pb_row_layout ("[object Object]", finding 5).
|
||||||
|
*
|
||||||
|
* @dataProvider builderEntries
|
||||||
|
*/
|
||||||
|
public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void
|
||||||
|
{
|
||||||
|
[$kind, $ref] = explode(':', $source, 2);
|
||||||
|
$node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref);
|
||||||
|
$role = (string) $this->dataName();
|
||||||
|
$json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true);
|
||||||
|
|
||||||
|
$spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page');
|
||||||
|
|
||||||
|
self::assertInstanceOf(LibraryItemSpec::class, $spec);
|
||||||
|
$expectedTerms = [];
|
||||||
|
foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) {
|
||||||
|
$expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug');
|
||||||
|
}
|
||||||
|
self::assertEquals($expectedTerms, $spec->terms());
|
||||||
|
$expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']);
|
||||||
|
unset($expectedMeta['_et_pb_row_layout']);
|
||||||
|
self::assertEquals($expectedMeta, $spec->meta());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void
|
||||||
|
{
|
||||||
|
$catalog = DiviFixtures::catalog();
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'bereits ein globales Modul (Bibliothekseintrag 1058)',
|
||||||
|
(string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page')
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'nur Abschnitte, Zeilen und Module',
|
||||||
|
(string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page')
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'keine globalen Module',
|
||||||
|
(string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page')
|
||||||
|
);
|
||||||
|
self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P11: opaque roots and instances without a readable id are refused before wrapping. */
|
||||||
|
public function testRefusesOpaqueRootsAndUnreadableInstances(): void
|
||||||
|
{
|
||||||
|
$catalog = DiviFixtures::catalog();
|
||||||
|
$document = (new Parser())->parse(
|
||||||
|
'<!-- wp:divi/section {"module":kaputt} --><!-- /wp:divi/section -->'
|
||||||
|
. '<!-- wp:divi/global-layout {"globalModule":["x"],"blockName":"divi/section"} /-->'
|
||||||
|
);
|
||||||
|
[$opaque, $instance] = $document->children();
|
||||||
|
self::assertTrue($opaque->isOpaque());
|
||||||
|
|
||||||
|
self::assertStringContainsString('nicht lesbar', (string) LibraryItemSpec::forNode($opaque, $catalog, 'T', false, 'page'));
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'globales Modul',
|
||||||
|
(string) LibraryItemSpec::forNode($instance, $catalog, 'T', false, 'page')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullwidthAndTerms(): void
|
||||||
|
{
|
||||||
|
$section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]);
|
||||||
|
|
||||||
|
$spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page');
|
||||||
|
|
||||||
|
self::assertSame(['fullwidth'], $spec->terms()['module_width']);
|
||||||
|
self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']);
|
||||||
|
self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibrarySaveAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Post;
|
||||||
|
use WP_Term;
|
||||||
|
|
||||||
|
final class LibrarySaveAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private WP_Post $page;
|
||||||
|
|
||||||
|
/** @var list<LibraryItemSpec> */
|
||||||
|
private array $specs = [];
|
||||||
|
|
||||||
|
/** @var list<array{int, string, SaveScope}> */
|
||||||
|
private array $contents = [];
|
||||||
|
|
||||||
|
/** @var list<string> Contents DiviWriter saved (the source page must never be written). */
|
||||||
|
private array $saved = [];
|
||||||
|
|
||||||
|
private FakeChangeLog $log;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||||||
|
LibraryFixtures::given([$this->page]);
|
||||||
|
Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture'
|
||||||
|
? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy])
|
||||||
|
: false);
|
||||||
|
$this->log = new FakeChangeLog(4711);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $deniedRoles */
|
||||||
|
private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility
|
||||||
|
{
|
||||||
|
$store = LibraryFixtures::store();
|
||||||
|
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
|
||||||
|
$this->specs[] = $spec;
|
||||||
|
|
||||||
|
return 2001;
|
||||||
|
});
|
||||||
|
$saver = Mockery::mock(DiviSaver::class);
|
||||||
|
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array {
|
||||||
|
if ($layoutFailure !== null) {
|
||||||
|
throw $layoutFailure;
|
||||||
|
}
|
||||||
|
$this->contents[] = [$id, $content, $scope];
|
||||||
|
LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
$access = LibraryFixtures::access($deniedRoles, $writable, $store);
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
return new LibrarySaveAbility(
|
||||||
|
$access,
|
||||||
|
LibraryFixtures::loader($access),
|
||||||
|
DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)),
|
||||||
|
$saver,
|
||||||
|
$this->log,
|
||||||
|
DiviWriterFixture::modules()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function input(array $extra = []): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
|
||||||
|
$extra
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavesASectionLikeTheBuilder(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['categories' => ['Fixture']]));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertSame(2001, $result['library_id']);
|
||||||
|
self::assertSame('section', $this->specs[0]->layoutType);
|
||||||
|
self::assertSame([26], $this->specs[0]->terms()['layout_category']);
|
||||||
|
self::assertSame(
|
||||||
|
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
|
||||||
|
$this->contents[0][1]
|
||||||
|
);
|
||||||
|
self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]);
|
||||||
|
self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']);
|
||||||
|
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
|
||||||
|
self::assertSame('2001', $this->log->records[0]['object_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavesAGlobalModule(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global']));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertTrue($this->specs[0]->global);
|
||||||
|
self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']);
|
||||||
|
self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void
|
||||||
|
{
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed');
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStaleSourceTreeRevCreatesNothing(): void
|
||||||
|
{
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNeedsPublishPostsAndTheDiviRole(): void
|
||||||
|
{
|
||||||
|
self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden');
|
||||||
|
self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden');
|
||||||
|
LibraryFixtures::given([$this->page], ['publish_posts']);
|
||||||
|
self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditorCannotSaveACodeModule(): void
|
||||||
|
{
|
||||||
|
$this->page = LibraryFixtures::page(
|
||||||
|
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||||
|
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
|
||||||
|
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||||||
|
);
|
||||||
|
LibraryFixtures::given([$this->page]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result)));
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownCategoryCreatesNothing(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']]));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P6: the layout write failing rolls the new entry back through NewDraft (write_failed). */
|
||||||
|
public function testFailedLayoutWriteDeletesTheEntry(): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->ability([], true, new RuntimeException('hook failed'))->execute($this->input());
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
self::assertToolError($e->toError(), 'write_failed');
|
||||||
|
}
|
||||||
|
self::assertSame([], $this->log->records);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P6: when the rollback fails too, the answer is partial_write and the change log says so. */
|
||||||
|
public function testFailedRollbackIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_delete_post')->justReturn(false);
|
||||||
|
$this->log = FakeChangeLog::any(4711);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->ability([], true, new RuntimeException('hook failed'))->execute($this->input());
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame(2001, $e->postId);
|
||||||
|
self::assertToolError($e->toError(), 'partial_write');
|
||||||
|
}
|
||||||
|
self::assertSame('et_pb_layout', $this->log->partials[0]['object_type']);
|
||||||
|
self::assertSame('2001', $this->log->partials[0]['object_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P7: scope "global" also needs Divi's edit_global_library. */
|
||||||
|
public function testGlobalScopeNeedsEditGlobalLibrary(): void
|
||||||
|
{
|
||||||
|
$input = $this->input(['id' => 's0.r0.c0.m0', 'scope' => 'global']);
|
||||||
|
|
||||||
|
self::assertToolError($this->ability(['edit_global_library'])->permission($input), 'forbidden');
|
||||||
|
self::assertToolError($this->ability(['edit_global_library'])->execute($input), 'forbidden');
|
||||||
|
self::assertTrue($this->ability(['edit_global_library'])->permission($this->input()));
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheSourcePageIsNeverWritten(): void
|
||||||
|
{
|
||||||
|
$this->ability()->execute($this->input(['scope' => 'normal']));
|
||||||
|
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
self::assertCount(1, $this->contents);
|
||||||
|
self::assertSame(2001, $this->contents[0][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line breaks between blocks are unified like LibraryContent::load() (fixture finding 9). */
|
||||||
|
public function testLineBreaksBetweenBlocksAreUnified(): void
|
||||||
|
{
|
||||||
|
$this->page = LibraryFixtures::page(
|
||||||
|
"<!-- wp:divi/placeholder --><!-- wp:divi/section -->\r\n<!-- wp:divi/row -->\r\n<!-- wp:divi/column -->"
|
||||||
|
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"<p>a</p>"}}}} /-->'
|
||||||
|
. "<!-- /wp:divi/column -->\r\n<!-- /wp:divi/row -->\r\n<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->"
|
||||||
|
);
|
||||||
|
LibraryFixtures::given([$this->page]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringNotContainsString("\r", $this->contents[0][1]);
|
||||||
|
self::assertStringContainsString("<!-- wp:divi/section -->\n<!-- wp:divi/row -->", $this->contents[0][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownScopeCreatesNothing(): void
|
||||||
|
{
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed');
|
||||||
|
self::assertSame([], $this->specs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,4 +62,26 @@ final class NewDraftTest extends TestCase
|
|||||||
self::assertSame(NewDraft::REASON_EXTRAS, $e->reason);
|
self::assertSame(NewDraft::REASON_EXTRAS, $e->reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** lit/library-save reuses the transaction without a PostWriter and names its entry (ruling P6). */
|
||||||
|
public function testWithoutAWriterOnlyTheEntryIsDeletedAndPartialsNameTheEntry(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_delete_post')->justReturn(false);
|
||||||
|
$log = FakeChangeLog::partialsOnly();
|
||||||
|
$draft = new NewDraft(null, $log, 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$draft->discard(2001, new RuntimeException('layout kaputt'), 'Grund.');
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame(2001, $e->postId);
|
||||||
|
}
|
||||||
|
$log->assertPartials([
|
||||||
|
'ability' => 'lit/library-save',
|
||||||
|
'object_type' => 'et_pb_layout',
|
||||||
|
'object_id' => '2001',
|
||||||
|
'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user