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
@@ -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);
}