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
@@ -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<WP_Term> */
private function terms(int $id, string $taxonomy): array
{