Files
WP-LIT-MCP/src/Integrations/WordPress/Content/ContentCreateAbility.php
T
janniclas.loosenandClaude Opus 5.5 e04a5304f7 refactor: annotation presets (L5)
The 41 abilities return Annotations::read(), ::write(idempotent) or
::destructive(idempotent) instead of hand-written arrays (same arrays, same
key order).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 18:33:14 +02:00

238 lines
9.0 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\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
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").
* 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
* (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
{
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 Annotations::write(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(),
]);
$draft = new NewDraft($this->writer, $this->log, $this->name(), $postType);
$postId = $draft->insert($data);
if ($postId instanceof WP_Error) {
return $postId;
}
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
if ($denied !== []) {
return $draft->refuse($postId, $denied);
}
$layout = null;
try {
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
} catch (Throwable $e) {
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
}
if ($layout instanceof WP_Error) {
return $draft->refuse($postId, $layout);
}
try {
$this->writer->writeExtras($postId, $postType, $prepared, $input);
} catch (Throwable $e) {
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
}
$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(Errors::fieldsOf($prepared), Errors::fieldsOf($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);
}
/**
* @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 PostSaveFollowUp($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();
}
}