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
238 lines
9.0 KiB
PHP
238 lines
9.0 KiB
PHP
<?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();
|
||
}
|
||
}
|