From 5035092805eef66de7332d471c2dca789e3b997b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 02:12:50 +0200 Subject: [PATCH] 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) --- docs/guides/overview.md | 2 +- src/Integrations/Divi/DiviIntegration.php | 34 ++- .../Divi/Library/DiviLibraryStore.php | 70 +++++ .../Divi/Library/LibraryAccess.php | 28 ++ .../Divi/Library/LibraryIntegration.php | 19 +- .../Divi/Library/LibraryItemSpec.php | 148 +++++++++ .../Divi/Library/LibrarySaveAbility.php | 284 ++++++++++++++++++ .../WordPress/Content/NewDraft.php | 17 +- .../Integrations/Divi/DiviIntegrationTest.php | 2 +- .../Divi/Library/DiviLibraryStoreTest.php | 130 ++++++++ .../Divi/Library/LibraryAccessTest.php | 24 ++ .../Divi/Library/LibraryItemSpecTest.php | 113 +++++++ .../Divi/Library/LibrarySaveAbilityTest.php | 240 +++++++++++++++ .../WordPress/Content/NewDraftTest.php | 22 ++ 14 files changed, 1112 insertions(+), 21 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryItemSpec.php create mode 100644 src/Integrations/Divi/Library/LibrarySaveAbility.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 6b053c4..ebe14a2 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -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/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`, -`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. diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 4ea0410..221f67a 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -100,15 +100,7 @@ final class DiviIntegration implements Integration public function abilities(): array { $loader = $this->loader(); - $composition = new DiviComposition($this->modules, $this->design); - $writer = new DiviWriter( - $loader, - new DiviSaver(), - $this->log, - $this->design, - $composition, - new GlobalEdits($this->access, new GlobalUsages()) - ); + $writer = $this->writer($loader); return [ 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. */ 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 diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index c393d22..19432c9 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -4,6 +4,11 @@ declare(strict_types=1); 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_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 * 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 */ private function terms(int $id, string $taxonomy): array { diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index f097fed..83932b3 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -120,6 +120,34 @@ class LibraryAccess 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. */ public function writableGate(): ?WP_Error { diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index 88c91d4..34dc133 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -4,15 +4,18 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; 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\Integration; 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 – * its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the * Divi 5 builder, like the Divi group. @@ -22,7 +25,11 @@ final class LibraryIntegration implements Integration public function __construct( private LibraryAccess $access, 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 [ new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()), + new LibrarySaveAbility( + $this->access, + $this->loader, + $this->writer, + $this->saver, + $this->log, + $this->modules + ), ]; } } diff --git a/src/Integrations/Divi/Library/LibraryItemSpec.php b/src/Integrations/Divi/Library/LibraryItemSpec.php new file mode 100644 index 0000000..e5fa4c8 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryItemSpec.php @@ -0,0 +1,148 @@ + $categories layout_category term IDs. + * @param list $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 $categories + * @param list $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> 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 */ + 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; + } +} diff --git a/src/Integrations/Divi/Library/LibrarySaveAbility.php b/src/Integrations/Divi/Library/LibrarySaveAbility.php new file mode 100644 index 0000000..5e3e1f0 --- /dev/null +++ b/src/Integrations/Divi/Library/LibrarySaveAbility.php @@ -0,0 +1,284 @@ + '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 $input + * @return array + * @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 $input */ + private static function isGlobal(array $input): bool + { + return ($input['scope'] ?? 'normal') === 'global'; + } + + /** @return list|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)); + } +} diff --git a/src/Integrations/WordPress/Content/NewDraft.php b/src/Integrations/WordPress/Content/NewDraft.php index 317a69a..e00987c 100644 --- a/src/Integrations/WordPress/Content/NewDraft.php +++ b/src/Integrations/WordPress/Content/NewDraft.php @@ -22,7 +22,8 @@ defined('ABSPATH') || exit; * returned like any invalid input; partial write if the delete fails. * - discard(): writing after the insert failed – created terms and the draft are deleted; * 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 { @@ -30,11 +31,13 @@ final class NewDraft public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten 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( - private PostWriter $writer, + private ?PostWriter $writer, private ChangeLog $log, private string $ability, - private string $postType + private string $postType, + private string $noun = 'Entwurf' ) { } @@ -50,7 +53,7 @@ final class NewDraft } catch (Throwable $e) { // 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). - $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); } if ($postId instanceof WP_Error) { @@ -77,7 +80,7 @@ final class NewDraft } 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); $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; throw WriteFailed::partial( @@ -102,7 +105,7 @@ final class NewDraft public function discard(int $postId, Throwable $cause, string $reason): never { $problems = Rollback::attempt( - fn () => $this->writer->deleteCreatedTerms(), + fn () => $this->writer?->deleteCreatedTerms(), static fn () => self::deleteDraft($postId) ); @@ -111,7 +114,7 @@ final class NewDraft 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); throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 1d14e80..a239cb9 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -58,7 +58,7 @@ final class DiviIntegrationTest extends TestCase self::assertSame('Divi-Bibliothek', $library->label()); self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests'); self::assertSame( - ['lit/library-list'], + ['lit/library-list', 'lit/library-save'], array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities()) ); foreach ($library->abilities() as $ability) { diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php index 9304c4d..74aa01e 100644 --- a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -5,7 +5,11 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; 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 WP_Post; use WP_Term; @@ -99,4 +103,130 @@ final class DiviLibraryStoreTest extends TestCase 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()); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index 59fe296..2e425a0 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -142,4 +142,28 @@ final class LibraryAccessTest extends TestCase self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false)); 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)); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php b/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php new file mode 100644 index 0000000..44d2b20 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php @@ -0,0 +1,113 @@ +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 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( + '' + . '' + ); + [$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()); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php new file mode 100644 index 0000000..1f35080 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php @@ -0,0 +1,240 @@ + */ + private array $specs = []; + + /** @var list */ + private array $contents = []; + + /** @var list 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 $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 */ + 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( + '' + . '' + . '' + ); + 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( + "\r\n\r\n" + . '' + . "\r\n\r\n" + ); + 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("\n", $this->contents[0][1]); + } + + public function testUnknownScopeCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed'); + self::assertSame([], $this->specs); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php index 3c749a5..f11d05a 100644 --- a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php +++ b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php @@ -62,4 +62,26 @@ final class NewDraftTest extends TestCase 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', + ]); + } }