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:
2026-09-27 02:12:50 +02:00
co-authored by Claude Opus 5.5
parent 8fa285a265
commit 5035092805
14 changed files with 1112 additions and 21 deletions
@@ -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));
}
}