Files
WP-LIT-MCP/src/Integrations/WordPress/Content/NewDraft.php
T
janniclas.loosenandClaude Opus 5.5 5035092805 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>
2026-09-27 02:12:50 +02:00

140 lines
5.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The transaction of lit/content-create: inserting the draft and every way back out of it.
* The three exits side by side:
* - insert(): a throwing hook leaves the draft's existence unknown – partial write without id.
* - refuse(): a check that needs the draft failed – the draft is deleted and the refusal is
* 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". 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
{
public const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
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 ChangeLog $log,
private string $ability,
private string $postType,
private string $noun = 'Entwurf'
) {
}
/**
* @param array<string, mixed> $data wp_insert_post() data (unslashed).
* @return int|WP_Error The new draft's ID, or WordPress' refusal as invalid input.
* @throws WriteFailed When a hook threw during the insert.
*/
public function insert(array $data): int|WP_Error
{
try {
$postId = wp_insert_post(wp_slash($data), true);
} 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('', sprintf('Anlegen abgebrochen; %s möglicherweise vorhanden', $this->noun));
throw WriteFailed::partial(self::REASON_INSERT, $e);
}
if ($postId instanceof WP_Error) {
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
}
return (int) $postId;
}
/**
* Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
* against the new entry's own dynamic content options. A refusal removes the brand-new draft
* again and is reported like any invalid input.
*
* @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
* @throws WriteFailed When the draft cannot be deleted.
*/
public function refuse(int $postId, array|WP_Error $denied): WP_Error
{
try {
$deleted = (bool) wp_delete_post($postId, true);
} catch (Throwable $e) {
$deleted = false;
}
if (! $deleted) {
$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(
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
. implode(', ', array_keys($fields)) . '.',
detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId),
postId: $postId
);
}
return $denied instanceof WP_Error
? $denied
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
}
/**
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
* it cannot have been used anywhere yet). $reason is the client text of the error.
*
* @throws WriteFailed Always.
*/
public function discard(int $postId, Throwable $cause, string $reason): never
{
$problems = Rollback::attempt(
fn () => $this->writer?->deleteCreatedTerms(),
static fn () => self::deleteDraft($postId)
);
$detail = sprintf('Writing new post %d failed after the insert: %s', $postId, $cause->getMessage());
if ($problems === []) {
throw WriteFailed::rolledBack($reason, $cause, $detail);
}
$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);
}
/** @throws RuntimeException When the draft is not deleted. */
private static function deleteDraft(int $postId): void
{
try {
$deleted = wp_delete_post($postId, true);
} catch (Throwable $e) {
throw new RuntimeException(sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), 0, $e);
}
if (! $deleted) {
throw new RuntimeException(sprintf('draft %d could not be deleted', $postId));
}
}
private function logPartial(string $postId, string $summary): void
{
$this->log->recordPartial($this->ability, $this->postType, $postId, $summary);
}
}