refactor: extract the lit/content-create draft transaction into NewDraft (M7)
NewDraft owns insert (partial write on a throwing hook), refuse and discard (via Rollback::attempt and WriteFailed::*) and the partial logging; ContentCreateAbility keeps checkDivi/prepare/combined (Errors::fieldsOf) and the result. Both discard callers still pass REASON_EXTRAS. Behaviour unchanged; NewDraftTest adds the throwing-delete branch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,14 +7,11 @@ namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Abilities\Rollback;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\PostChangeResult;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
@@ -25,6 +22,7 @@ use WP_Post;
|
||||
* afterwards, created terms and the brand-new draft are deleted again and a WriteFailed
|
||||
* is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup
|
||||
* failed or the insert itself threw; partial writes are logged with result "partial").
|
||||
* The insert and its rollbacks live in NewDraft; this class checks, orders and reports.
|
||||
*
|
||||
* Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole
|
||||
* description before the insert; the draft is inserted with its normal WordPress fields only
|
||||
@@ -33,9 +31,6 @@ use WP_Post;
|
||||
*/
|
||||
final class ContentCreateAbility implements Ability
|
||||
{
|
||||
private const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
|
||||
private const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
||||
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private PostWriter $writer,
|
||||
@@ -133,38 +128,31 @@ final class ContentCreateAbility implements Ability
|
||||
'post_status' => 'draft',
|
||||
'post_author' => get_current_user_id(),
|
||||
]);
|
||||
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($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
|
||||
throw WriteFailed::partial(self::REASON_INSERT, $e);
|
||||
}
|
||||
$draft = new NewDraft($this->writer, $this->log, $this->name(), $postType);
|
||||
$postId = $draft->insert($data);
|
||||
if ($postId instanceof WP_Error) {
|
||||
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
|
||||
return $postId;
|
||||
}
|
||||
|
||||
$postId = (int) $postId;
|
||||
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
|
||||
if ($denied !== []) {
|
||||
return $this->refuse($postId, $postType, $denied);
|
||||
return $draft->refuse($postId, $denied);
|
||||
}
|
||||
|
||||
$layout = null;
|
||||
try {
|
||||
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
|
||||
} catch (Throwable $e) {
|
||||
$this->discard($postId, $postType, $e, self::REASON_EXTRAS);
|
||||
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
|
||||
}
|
||||
if ($layout instanceof WP_Error) {
|
||||
return $this->refuse($postId, $postType, $layout);
|
||||
return $draft->refuse($postId, $layout);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
||||
} catch (Throwable $e) {
|
||||
$this->discard($postId, $postType, $e, self::REASON_EXTRAS);
|
||||
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
|
||||
}
|
||||
|
||||
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
|
||||
@@ -245,78 +233,4 @@ final class ContentCreateAbility implements Ability
|
||||
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private function discard(int $postId, string $postType, Throwable $cause, string $reason): never
|
||||
{
|
||||
$problems = Rollback::attempt(
|
||||
fn () => $this->writer->deleteCreatedTerms(),
|
||||
static function () use ($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));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
|
||||
if ($problems === []) {
|
||||
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
||||
}
|
||||
|
||||
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
|
||||
$this->logPartial($postType, (string) $postId, $summary);
|
||||
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $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.
|
||||
*/
|
||||
private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error
|
||||
{
|
||||
try {
|
||||
$deleted = (bool) wp_delete_post($postId, true);
|
||||
} catch (Throwable $e) {
|
||||
$deleted = false;
|
||||
}
|
||||
|
||||
if (! $deleted) {
|
||||
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
|
||||
$this->logPartial($postType, (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);
|
||||
}
|
||||
|
||||
private function logPartial(string $postType, string $postId, string $summary): void
|
||||
{
|
||||
$this->log->recordPartial($this->name(), $postType, $postId, $summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
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 function __construct(
|
||||
private PostWriter $writer,
|
||||
private ChangeLog $log,
|
||||
private string $ability,
|
||||
private string $postType
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
|
||||
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('Entwurf %d: Anlegen nicht zurückgenommen', $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('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
|
||||
if ($problems === []) {
|
||||
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
||||
}
|
||||
|
||||
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user