Files
WP-LIT-MCP/src/Integrations/WordPress/Content/ContentCreateAbility.php
T
janniclas.loosenandClaude Opus 5.5 5965e32acb feat: lit/content-create with Divi short form
New input divi (1-30 sections, short form like lit-divi-insert) builds a Divi 5
layout in one divi/placeholder. The whole description is checked before the
insert (Divi's map for "no entry yet"); the draft is inserted with its normal
WordPress fields only, then DiviContentComposer composes again bound to the new
entry and stores it via DiviSaver::saveNew (same kses window as save(), strict
DiviFlags). A refusal or failure deletes the draft. Result adds tree_rev and the
outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:46:50 +02:00

336 lines
13 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\Ability;
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\WordPress\PostChangeResult;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use Throwable;
use WP_Error;
use WP_Post;
/**
* lit/content-create: new entry, always a draft. All or nothing (spec §8.2): input is
* validated before the insert; if fields, terms or the featured image cannot be written
* 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").
*
* 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
* (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec
* D7) – a refusal or failure there deletes the draft again.
*/
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,
private ChangeLog $log,
private ?ContentComposer $divi = null
) {
}
public function name(): string
{
return 'lit/content-create';
}
public function label(): string
{
return 'Inhalt anlegen';
}
public function description(): string
{
return 'Legt einen neuen Eintrag an – immer als Entwurf (veröffentlichen nur über lit-post-publish). '
. 'Kerndaten, Begriffe, Felder (gegen das Schema aus lit-content-types geprüft) und Beitragsbild '
. 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste '
. 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content; das Ergebnis enthält '
. 'dann tree_rev für die lit-divi-* Werkzeuge.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'post_type' => ['type' => 'string'],
'title' => ['type' => 'string', 'minLength' => 1],
'divi' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => 30,
'items' => ['type' => 'object'],
'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit '
. 'content). Nur wenn Divi 5 aktiv und der Divi-Builder für den Inhaltstyp eingeschaltet ist.',
],
], PostWriter::inputProperties()),
'required' => ['post_type', 'title'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']]);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
return $type instanceof WP_Error ? $type : true;
}
public function execute(array $input): mixed
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
if ($type instanceof WP_Error) {
return $type;
}
$postType = (string) $input['post_type'];
$withDivi = array_key_exists('divi', $input);
if ($withDivi && ($this->divi === null || ! $this->divi->available())) {
return Errors::unsupported(
'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.',
'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.'
);
}
$prepared = $this->writer->prepare($postType, $input, true);
$problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
if ($problem !== null) {
return $problem;
}
$data = array_merge($this->writer->postData($input), [
'post_type' => $postType,
'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 new WriteFailed(self::REASON_INSERT, false, null, null, $e);
}
if ($postId instanceof WP_Error) {
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
}
$postId = (int) $postId;
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
if ($denied !== []) {
return $this->refuse($postId, $postType, $denied);
}
$layout = null;
try {
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
} catch (Throwable $e) {
$this->discard($postId, $postType, $e);
}
if ($layout instanceof WP_Error) {
return $this->refuse($postId, $postType, $layout);
}
try {
$this->writer->writeExtras($postId, $postType, $prepared, $input);
} catch (Throwable $e) {
$this->discard($postId, $postType, $e);
}
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
return $this->result($postId, $postType, $input, $warnings, $layout['outline'] ?? null);
}
/**
* The Divi description, checked completely before anything is created (spec §6.2).
*
* @param array<string, mixed> $input
*/
private function checkDivi(string $postType, array $input): ?WP_Error
{
if (array_key_exists('content', $input)) {
return Errors::invalidInput('content und divi schließen sich aus; nichts wurde angelegt.', [
'content' => 'bei Divi-Inhalt weglassen',
]);
}
return $this->divi?->check($postType, self::sections($input));
}
/**
* @param array<string, mixed> $input
* @return list<mixed>
*/
private static function sections(array $input): array
{
return is_array($input['divi'] ?? null) ? array_values($input['divi']) : [];
}
/**
* One error with the fields of both checks (all or nothing: every problem comes back at once);
* invalid_attr_path wins, so its suggestions stay visible. Null when both passed.
*
* @param array<string, mixed>|WP_Error $prepared
*/
private static function combined(array|WP_Error $prepared, ?WP_Error $divi): ?WP_Error
{
if (! $prepared instanceof WP_Error || $divi === null) {
return $prepared instanceof WP_Error ? $prepared : $divi;
}
$fields = array_merge(self::fields($prepared), self::fields($divi));
if ($fields === []) {
return $prepared;
}
return $divi->get_error_code() === 'lit_invalid_attr_path'
? Errors::invalidAttrPath($fields)
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde angelegt.', $fields);
}
/** @return array<string, string> */
private static function fields(WP_Error $error): array
{
$data = $error->get_error_data();
return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : [];
}
/**
* @param array<string, mixed> $input
* @param list<string> $warnings
* @param string|null $outline Divi outline of the new layout (with divi only).
* @return array<string, mixed>
*/
private function result(int $postId, string $postType, array $input, array $warnings, ?string $outline): array
{
// The draft is saved – failures from here on are logged and reported as warnings only.
$change = (new PostChangeResult($this->name(), $this->log, $postId, 'der Entwurf', $warnings))
->afterSave($input);
$post = $change->reread();
$title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title'];
$change->log($postType, sprintf('Entwurf "%s" angelegt', $title));
$result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
->changed((string) $postId, $postType, 'created', $title)
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'));
if (array_key_exists('divi', $input)) {
// The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away.
$result->outline($outline)
->with('tree_rev', Rev::of($post instanceof WP_Post ? (string) $post->post_content : ''));
}
return $result->toArray();
}
/**
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
* it cannot have been used anywhere yet).
*
* @throws WriteFailed Always.
*/
private function discard(int $postId, string $postType, Throwable $cause): never
{
$problems = [];
try {
$this->writer->deleteCreatedTerms();
} catch (Throwable $e) {
$problems[] = $e->getMessage();
}
try {
if (! wp_delete_post($postId, true)) {
$problems[] = sprintf('draft %d could not be deleted', $postId);
}
} catch (Throwable $e) {
$problems[] = sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage());
}
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
if ($problems === []) {
throw new WriteFailed(self::REASON_EXTRAS, true, null, null, $cause, $detail);
}
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
$this->logPartial($postType, (string) $postId, $summary);
throw new WriteFailed(
self::REASON_EXTRAS,
false,
$postId,
null,
$cause,
$detail . '; rollback: ' . implode('; ', $problems)
);
}
/**
* 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 ? self::fields($denied) : $denied;
throw new WriteFailed(
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
. implode(', ', array_keys($fields)) . '.',
false,
$postId,
null,
null,
sprintf('New post %d refused after the insert; draft could not be deleted', $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);
}
}