feat: lit-library-insert as copy or global instance in the builder's format (E5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte
|
|||||||
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
|
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
|
||||||
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`,
|
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`,
|
||||||
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
||||||
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
|
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`.
|
||||||
|
|
||||||
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter
|
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter
|
||||||
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
||||||
|
|||||||
@@ -116,8 +116,11 @@ final class DiviIntegration implements Integration
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */
|
/**
|
||||||
public function library(): LibraryIntegration
|
* The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access;
|
||||||
|
* $diviWritable is whether lit/divi-insert is enabled (lit-library-insert follows it, audit M2).
|
||||||
|
*/
|
||||||
|
public function library(?Closure $diviWritable = null): LibraryIntegration
|
||||||
{
|
{
|
||||||
$loader = $this->loader();
|
$loader = $this->loader();
|
||||||
|
|
||||||
@@ -128,7 +131,8 @@ final class DiviIntegration implements Integration
|
|||||||
$this->writer($loader),
|
$this->writer($loader),
|
||||||
new DiviSaver(),
|
new DiviSaver(),
|
||||||
$this->log,
|
$this->log,
|
||||||
$this->modules
|
$this->modules,
|
||||||
|
$diviWritable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,16 @@ class LibraryAccess
|
|||||||
return $this->roles->refusal('edit_global_library');
|
return $this->roles->refusal('edit_global_library');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserting from the library into a page (E3): canList(), the role option add_library (Divi's
|
||||||
|
* "Aus der Bibliothek hinzufügen") and a group "Divi-Bibliothek" that is not read-only or
|
||||||
|
* disabled (E9). Reading the entry itself stays item() (ruling P5).
|
||||||
|
*/
|
||||||
|
public function canInsert(): ?WP_Error
|
||||||
|
{
|
||||||
|
return $this->canList() ?? $this->roles->refusal('add_library') ?? $this->writableGate();
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/library-insert (spec §5.7, E5): a Divi Library entry into a Divi page – as the global instance
|
||||||
|
* the Visual Builder writes (GlobalInstance::of; Divi renders the entry's current content, F7) or
|
||||||
|
* as an independent copy of its roots (a global entry deglobalized like Divi's /load, F10). The
|
||||||
|
* target works like lit-divi-insert (tree_rev lock, StructureRules by effective name, all or
|
||||||
|
* nothing, DiviWriter); the entry is read through LibraryAccess::item() (ruling P5), never nests
|
||||||
|
* instances (ruling P11), and its nodes pass StoredNodeGuard (E6). Needs add_library (E3) and
|
||||||
|
* follows lit-divi-insert's Tools-tab state (audit M2).
|
||||||
|
*/
|
||||||
|
final class LibraryInsertAbility extends DiviWriteAbility
|
||||||
|
{
|
||||||
|
private const MODES = ['copy', 'global'];
|
||||||
|
|
||||||
|
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */
|
||||||
|
public function __construct(
|
||||||
|
DiviWriter $writer,
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private ?Closure $diviWritable = null,
|
||||||
|
private Parser $parser = new Parser()
|
||||||
|
) {
|
||||||
|
parent::__construct($writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/library-insert';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Aus der Divi-Bibliothek einfügen';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Fügt einen Eintrag der Divi-Bibliothek in eine Divi-Seite ein: mode "global" als verknüpfte Instanz '
|
||||||
|
. '(der Inhalt kommt immer aus der Bibliothek; Änderungen dort wirken auf allen Seiten) oder mode "copy" '
|
||||||
|
. 'als unabhängige Kopie. Standard: global für globale Einträge, sonst copy. Einträge über '
|
||||||
|
. 'lit/library-list; Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans '
|
||||||
|
. 'Seitenende. tree_rev ist Pflicht; alles oder nichts.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => array_merge(DiviSchema::write(), [
|
||||||
|
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'],
|
||||||
|
'mode' => ['type' => 'string', 'enum' => self::MODES],
|
||||||
|
], DiviSchema::position()),
|
||||||
|
'required' => ['post_id', 'tree_rev', 'library_id'],
|
||||||
|
'additionalProperties' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annotations(): array
|
||||||
|
{
|
||||||
|
return Annotations::write(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function precheck(array $input): ?WP_Error
|
||||||
|
{
|
||||||
|
$refusal = $this->access->canInsert();
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
|
||||||
|
return Errors::unsupported(
|
||||||
|
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
|
||||||
|
. 'lit-divi-insert deaktiviert).',
|
||||||
|
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||||||
|
{
|
||||||
|
$mode = $input['mode'] ?? null;
|
||||||
|
if ($mode !== null && ! in_array($mode, self::MODES, true)) {
|
||||||
|
return Errors::invalidInput('mode ist „copy“ oder „global“; nichts wurde gespeichert.', [
|
||||||
|
'mode' => 'copy oder global',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = $this->item($input['library_id'] ?? null);
|
||||||
|
if ($item instanceof WP_Error) {
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $item->ID;
|
||||||
|
$global = $this->access->isGlobal($item);
|
||||||
|
$mode = is_string($mode) ? $mode : ($global ? 'global' : 'copy');
|
||||||
|
if ($mode === 'global' && ! $global) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.',
|
||||||
|
['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocks = $this->blocks($item, $mode === 'global', $global);
|
||||||
|
if ($blocks instanceof WP_Error) {
|
||||||
|
return $blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$guard = $composition->storedGuard();
|
||||||
|
foreach ($blocks as $block) {
|
||||||
|
$problems = $guard->problems($block);
|
||||||
|
if ($problems !== []) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; '
|
||||||
|
. 'nichts wurde gespeichert.',
|
||||||
|
['library_id' => implode('; ', $problems)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$editor = $composition->editor();
|
||||||
|
$position = $editor->position($page->index, $input);
|
||||||
|
if (is_string($position)) {
|
||||||
|
return DiviErrors::position($position);
|
||||||
|
}
|
||||||
|
|
||||||
|
$violation = $editor->insert($position, $blocks);
|
||||||
|
if ($violation !== null) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.',
|
||||||
|
['library_id' => $violation]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$change = new DiviChange(sprintf(
|
||||||
|
'Bibliothekseintrag „%s“ (%d) %s eingefügt',
|
||||||
|
(string) $item->post_title,
|
||||||
|
$id,
|
||||||
|
$mode === 'global' ? 'als globale Instanz' : 'als Kopie'
|
||||||
|
));
|
||||||
|
foreach ($blocks as $block) {
|
||||||
|
$change->record($block, 'inserted');
|
||||||
|
}
|
||||||
|
if ($mode === 'global') {
|
||||||
|
$change->warning(sprintf(
|
||||||
|
'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit '
|
||||||
|
. 'diesem Modul (lit-divi-outline mit post_id %d).',
|
||||||
|
$id,
|
||||||
|
$id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $change;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active library entry $libraryId after LibraryAccess' read checks (ruling P5: the library
|
||||||
|
* right and edit_post on the entry), or the refusal. Anything else is not found.
|
||||||
|
*/
|
||||||
|
private function item(mixed $libraryId): WP_Post|WP_Error
|
||||||
|
{
|
||||||
|
$id = PostGuard::id($libraryId);
|
||||||
|
if ($id === null) {
|
||||||
|
return Errors::invalidInput('library_id fehlt oder ist keine ID; nichts wurde gespeichert.', [
|
||||||
|
'library_id' => 'ID aus lit/library-list',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = $this->access->item($id, false);
|
||||||
|
if ($item instanceof WP_Error) {
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $item instanceof WP_Post || (string) $item->post_status !== 'publish') {
|
||||||
|
return Errors::notFound(
|
||||||
|
sprintf('Bibliothekseintrag %d gibt es nicht (oder er ist nicht aktiv).', $id),
|
||||||
|
'Einträge mit lit/library-list auflisten.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Block>|WP_Error The blocks to insert. */
|
||||||
|
private function blocks(WP_Post $item, bool $instance, bool $global): array|WP_Error
|
||||||
|
{
|
||||||
|
$id = (int) $item->ID;
|
||||||
|
try {
|
||||||
|
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||||||
|
} catch (MalformedMarkup) {
|
||||||
|
return Errors::unsupported(
|
||||||
|
sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id),
|
||||||
|
'Den Eintrag im Divi-Builder öffnen und speichern.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($roots === []) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id),
|
||||||
|
['library_id' => 'leer']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($instance) {
|
||||||
|
return $this->instance($roots, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(),
|
||||||
|
$roots
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one instance of entry $id (GlobalInstance::of). Ruling P11: never nested instances – the
|
||||||
|
* root must be a single readable node holding no global module (Divi forbids nesting, F10).
|
||||||
|
*
|
||||||
|
* @param list<Block> $roots
|
||||||
|
* @return list<Block>|WP_Error
|
||||||
|
*/
|
||||||
|
private function instance(array $roots, int $id): array|WP_Error
|
||||||
|
{
|
||||||
|
if (count($roots) !== 1) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde '
|
||||||
|
. 'gespeichert.',
|
||||||
|
['mode' => 'mode "copy" verwenden']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = $roots[0];
|
||||||
|
if ($root->isOpaque() || LibraryContent::containsGlobal($root)) {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
sprintf(
|
||||||
|
'Bibliothekseintrag %d ist selbst ein globales Modul, enthält eines oder ist nicht lesbar; als '
|
||||||
|
. 'Instanz entstünde eine Verschachtelung, die Divi nicht erlaubt. Nichts wurde gespeichert.',
|
||||||
|
$id
|
||||||
|
),
|
||||||
|
['mode' => 'mode "copy" verwenden oder den Eintrag im Divi-Builder prüfen']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [GlobalInstance::of($root, $id)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Library;
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
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;
|
||||||
@@ -15,13 +16,14 @@ use LIT\MCP\Integrations\Integration;
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, later lit-library-insert.
|
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct).
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
final class LibraryIntegration implements Integration
|
final class LibraryIntegration implements Integration
|
||||||
{
|
{
|
||||||
|
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private LibraryAccess $access,
|
private LibraryAccess $access,
|
||||||
private DiviPageLoader $loader,
|
private DiviPageLoader $loader,
|
||||||
@@ -29,7 +31,8 @@ final class LibraryIntegration implements Integration
|
|||||||
private DiviWriter $writer,
|
private DiviWriter $writer,
|
||||||
private DiviSaver $saver,
|
private DiviSaver $saver,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private DiviModules $modules
|
private DiviModules $modules,
|
||||||
|
private ?Closure $diviWritable = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ final class LibraryIntegration implements Integration
|
|||||||
$this->log,
|
$this->log,
|
||||||
$this->modules
|
$this->modules
|
||||||
),
|
),
|
||||||
|
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -95,7 +95,7 @@ final class Plugin
|
|||||||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||||||
new MediaIntegration($schema, $changeLog, $limits),
|
new MediaIntegration($schema, $changeLog, $limits),
|
||||||
$divi,
|
$divi,
|
||||||
$divi->library()
|
$divi->library($diviWritable)
|
||||||
);
|
);
|
||||||
|
|
||||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||||
|
|||||||
@@ -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-save'],
|
['lit/library-list', 'lit/library-save', 'lit/library-insert'],
|
||||||
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) {
|
||||||
|
|||||||
@@ -166,4 +166,18 @@ final class LibraryAccessTest extends TestCase
|
|||||||
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden');
|
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden');
|
||||||
self::assertNull(LibraryFixtures::access()->canSave(true));
|
self::assertNull(LibraryFixtures::access()->canSave(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inserting from the library (E3): canList(), Divi's add_library and a group that is not read-only. */
|
||||||
|
public function testInsertingNeedsAddLibrary(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
|
||||||
|
self::assertNull(LibraryFixtures::access()->canInsert());
|
||||||
|
self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden');
|
||||||
|
self::assertToolError(LibraryFixtures::access(['divi_library'])->canInsert(), 'forbidden');
|
||||||
|
self::assertToolError(LibraryFixtures::access([], false)->canInsert(), 'forbidden');
|
||||||
|
|
||||||
|
LibraryFixtures::given([], ['edit_posts']);
|
||||||
|
self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<?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\Integrations\Divi\Library\LibraryInsertAbility;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class LibraryInsertAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private WP_Post $page;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $saved = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
|
||||||
|
LibraryFixtures::given(array_merge(
|
||||||
|
[$this->page],
|
||||||
|
array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $deniedRoles */
|
||||||
|
private function ability(array $deniedRoles = [], bool $diviWritable = true, bool $unfiltered = false): LibraryInsertAbility
|
||||||
|
{
|
||||||
|
$access = LibraryFixtures::access($deniedRoles);
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
return new LibraryInsertAbility(
|
||||||
|
DiviWriterFixture::writer($this->saved, $failures, $unfiltered, LibraryFixtures::loader($access)),
|
||||||
|
$access,
|
||||||
|
static fn (): bool => $diviWritable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
* @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)], $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fixtureInstance(int $id, string $end): string
|
||||||
|
{
|
||||||
|
$page = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||||||
|
$start = (int) strpos($page, '<!-- wp:divi/global-layout {"globalModule":"' . $id . '"');
|
||||||
|
|
||||||
|
return substr($page, $start, (int) strpos($page, $end, $start) + strlen($end) - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces library entry $id by one with $content (still global/normal as in ITEMS). */
|
||||||
|
private function givenItem(int $id, string $content, array $fields = []): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([
|
||||||
|
$this->page,
|
||||||
|
LibraryFixtures::item($id, array_merge(['post_content' => $content], $fields)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInsertsAGlobalSectionExactlyLikeTheBuilder(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1058, 'after' => 's0']));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString(self::fixtureInstance(1058, '<!-- /wp:divi/global-layout -->'), $this->saved[0]);
|
||||||
|
self::assertSame('g0', $result['changed'][0]['id']);
|
||||||
|
self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInsertsAGlobalModuleIntoAColumn(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1']));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUntouchedContentKeepsItsBytes(): void
|
||||||
|
{
|
||||||
|
$before = (string) $this->page->post_content;
|
||||||
|
|
||||||
|
$this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1']));
|
||||||
|
|
||||||
|
$instance = self::fixtureInstance(1059, '/-->');
|
||||||
|
$at = (int) strpos($this->saved[0], $instance);
|
||||||
|
self::assertSame($before, substr($this->saved[0], 0, $at) . substr($this->saved[0], $at + strlen($instance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1055]));
|
||||||
|
|
||||||
|
$section = str_replace(
|
||||||
|
['<!-- wp:divi/placeholder -->', '<!-- /wp:divi/placeholder -->', "\r\n"],
|
||||||
|
['', '', "\n"],
|
||||||
|
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||||||
|
);
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString($section, $this->saved[0]);
|
||||||
|
self::assertStringNotContainsString("\r", $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLayoutCopyInsertsEverySection(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy']));
|
||||||
|
|
||||||
|
self::assertCount(3, $result['changed']);
|
||||||
|
self::assertStringContainsString('Library-Blurb', $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** F10: a copy of a global entry is deglobalized like Divi's /load – its root bytes, no globalParent. */
|
||||||
|
public function testCopyOfAGlobalEntryIsItsPlainRoot(): void
|
||||||
|
{
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy']));
|
||||||
|
|
||||||
|
$section = str_replace(
|
||||||
|
['<!-- wp:divi/placeholder -->', '<!-- /wp:divi/placeholder -->', "\r\n"],
|
||||||
|
['', '', "\n"],
|
||||||
|
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
|
||||||
|
);
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString($section, $this->saved[0]);
|
||||||
|
self::assertStringNotContainsString('globalParent', $this->saved[0]);
|
||||||
|
self::assertStringNotContainsString('global-layout', $this->saved[0]);
|
||||||
|
self::assertSame([], $result['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** F10: nested instances in a (stored) global entry become plain nodes in the copy. */
|
||||||
|
public function testCopyDeglobalizesNestedInstances(): void
|
||||||
|
{
|
||||||
|
$instance = self::fixtureInstance(1059, '/-->');
|
||||||
|
$this->givenItem(1058, '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} -->'
|
||||||
|
. '<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->'
|
||||||
|
. $instance . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||||||
|
. '<!-- /wp:divi/placeholder -->');
|
||||||
|
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy']));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringNotContainsString('globalModule', $this->saved[0]);
|
||||||
|
self::assertStringContainsString('<!-- wp:divi/text {"builderVersion":"5.2.1","content"', $this->saved[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P11: never an instance of an entry whose root is (or holds) an instance or is unreadable. */
|
||||||
|
public function testNoNestedInstances(): void
|
||||||
|
{
|
||||||
|
$instance = self::fixtureInstance(1059, '/-->');
|
||||||
|
foreach (
|
||||||
|
[
|
||||||
|
'<!-- wp:divi/placeholder -->' . $instance . '<!-- /wp:divi/placeholder -->',
|
||||||
|
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} -->'
|
||||||
|
. '<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->'
|
||||||
|
. $instance . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||||||
|
. '<!-- /wp:divi/placeholder -->',
|
||||||
|
'<!-- wp:divi/placeholder --><!-- wp:divi/text {"globalModule":"1057"} /--><!-- /wp:divi/placeholder -->',
|
||||||
|
'<!-- wp:divi/placeholder --><!-- wp:divi/text {"a":1,} /--><!-- /wp:divi/placeholder -->',
|
||||||
|
] as $content
|
||||||
|
) {
|
||||||
|
$this->givenItem(1059, $content);
|
||||||
|
|
||||||
|
$result = $this->ability([], true, true)->execute($this->input(['library_id' => 1059]));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertStringContainsString('Verschachtelung', $result->get_error_message());
|
||||||
|
}
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void
|
||||||
|
{
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed');
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found');
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found');
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'link'])), 'validation_failed');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATrashedEntryIsNotFound(): void
|
||||||
|
{
|
||||||
|
$this->givenItem(1055, DiviFixtures::markup('stage3/library--library-abschnitt.html'), ['post_status' => 'trash']);
|
||||||
|
|
||||||
|
self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055])), 'not_found');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P5: the entry is read through LibraryAccess – edit_post on it, not read_post. */
|
||||||
|
public function testNeedsEditPostOnTheEntry(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && (int) ($args[0] ?? 0) === 1058)
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1058]));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'forbidden');
|
||||||
|
self::assertStringNotContainsString('[Fixture]', $result->get_error_message());
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNeedsAddLibraryAndAWritableDiviGroup(): void
|
||||||
|
{
|
||||||
|
self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden');
|
||||||
|
self::assertToolError($this->ability(['add_library'])->execute($this->input(['library_id' => 1058])), 'forbidden');
|
||||||
|
self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported');
|
||||||
|
self::assertToolError($this->ability([], false)->execute($this->input(['library_id' => 1058])), 'unsupported');
|
||||||
|
self::assertTrue($this->ability()->permission($this->input(['library_id' => 1058])));
|
||||||
|
self::assertTrue($this->ability()->isDirect());
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheTargetKeepsTheTreeRevLockAndStructureRules(): void
|
||||||
|
{
|
||||||
|
self::assertToolError(
|
||||||
|
$this->ability()->execute(['post_id' => 7, 'tree_rev' => 'alt', 'library_id' => 1058]),
|
||||||
|
'rev_mismatch'
|
||||||
|
);
|
||||||
|
self::assertToolError(
|
||||||
|
$this->ability()->execute($this->input(['library_id' => 1058, 'parent' => 's0.r0.c1'])),
|
||||||
|
'validation_failed'
|
||||||
|
);
|
||||||
|
self::assertToolError(
|
||||||
|
$this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's9'])),
|
||||||
|
'validation_failed'
|
||||||
|
);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditorCannotInsertAnItemWithCustomCss(): void
|
||||||
|
{
|
||||||
|
$custom = LibraryFixtures::item(1055, [
|
||||||
|
'ID' => 1080,
|
||||||
|
'post_content' => '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":'
|
||||||
|
. '{"freeForm":"body{display:none}"}}}} --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
|
||||||
|
]);
|
||||||
|
LibraryFixtures::given([$this->page, $custom]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute($this->input(['library_id' => 1080]));
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed');
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminMayInsertAnItemWithCustomCss(): void
|
||||||
|
{
|
||||||
|
$custom = LibraryFixtures::item(1055, [
|
||||||
|
'ID' => 1080,
|
||||||
|
'post_content' => '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":'
|
||||||
|
. '{"freeForm":"body{display:none}"}}}} --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
|
||||||
|
]);
|
||||||
|
LibraryFixtures::given([$this->page, $custom]);
|
||||||
|
|
||||||
|
$result = $this->ability([], true, true)->execute($this->input(['library_id' => 1080]));
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString('body{display:none}', $this->saved[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user