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>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Builds the content of a new entry for lit/content-create from a structured description
|
||||
* (spec §6.2: "optional Divi-Inhalt in Kurzform"). Implemented by the Divi integration, so the
|
||||
* WordPress integration does not depend on Divi.
|
||||
*
|
||||
* Two steps, because the entry does not exist before the insert: check() validates the whole
|
||||
* description before anything is created (all or nothing); write() stores the content into the
|
||||
* brand-new draft, which lit/content-create inserted with its normal WordPress fields only.
|
||||
*/
|
||||
interface ContentComposer
|
||||
{
|
||||
/** Whether composing is possible on this site right now (e.g. Divi 5 active). */
|
||||
public function available(): bool;
|
||||
|
||||
/**
|
||||
* Validates the description for a new entry of $postType. Nothing is written.
|
||||
*
|
||||
* @param list<mixed> $sections
|
||||
* @return WP_Error|null Every problem of the description; null = valid.
|
||||
*/
|
||||
public function check(string $postType, array $sections): ?WP_Error;
|
||||
|
||||
/**
|
||||
* Composes the description for the new draft $postId and stores it. A WP_Error means the
|
||||
* description is invalid for this entry (nothing was written) – like a throw, it makes
|
||||
* lit/content-create delete the draft again.
|
||||
*
|
||||
* @param list<mixed> $sections
|
||||
* @return array{warnings: list<string>, outline: ?string}|WP_Error
|
||||
* @throws Throwable When storing failed.
|
||||
*/
|
||||
public function write(int $postId, string $postType, array $sections): array|WP_Error;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -22,6 +23,11 @@ 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").
|
||||
*
|
||||
* 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
|
||||
{
|
||||
@@ -31,7 +37,8 @@ final class ContentCreateAbility implements Ability
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private PostWriter $writer,
|
||||
private ChangeLog $log
|
||||
private ChangeLog $log,
|
||||
private ?ContentComposer $divi = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -49,7 +56,9 @@ final class ContentCreateAbility implements Ability
|
||||
{
|
||||
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.';
|
||||
. '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
|
||||
@@ -59,6 +68,14 @@ final class ContentCreateAbility implements Ability
|
||||
'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,
|
||||
@@ -67,7 +84,7 @@ final class ContentCreateAbility implements Ability
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema();
|
||||
return ToolResult::schema(['tree_rev' => ['type' => 'string']]);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
@@ -95,9 +112,18 @@ final class ContentCreateAbility implements Ability
|
||||
}
|
||||
|
||||
$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);
|
||||
if ($prepared instanceof WP_Error) {
|
||||
return $prepared;
|
||||
$problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
|
||||
if ($problem !== null) {
|
||||
return $problem;
|
||||
}
|
||||
|
||||
$data = array_merge($this->writer->postData($input), [
|
||||
@@ -123,21 +149,89 @@ final class ContentCreateAbility implements Ability
|
||||
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);
|
||||
}
|
||||
|
||||
return $this->result($postId, $postType, $input, $prepared['warnings']);
|
||||
$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): array
|
||||
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))
|
||||
@@ -146,10 +240,16 @@ final class ContentCreateAbility implements Ability
|
||||
$title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title'];
|
||||
$change->log($postType, sprintf('Entwurf "%s" angelegt', $title));
|
||||
|
||||
return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
|
||||
$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'))
|
||||
->toArray();
|
||||
->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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,13 +293,14 @@ final class ContentCreateAbility implements Ability
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a
|
||||
* refused field removes the brand-new draft again and is reported like any invalid input.
|
||||
* 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> $denied fields.<key> => error
|
||||
* @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 $denied): WP_Error
|
||||
private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error
|
||||
{
|
||||
try {
|
||||
$deleted = (bool) wp_delete_post($postId, true);
|
||||
@@ -210,17 +311,21 @@ final class ContentCreateAbility implements Ability
|
||||
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(
|
||||
'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.',
|
||||
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
|
||||
. implode(', ', array_keys($fields)) . '.',
|
||||
false,
|
||||
$postId,
|
||||
null,
|
||||
null,
|
||||
sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId)
|
||||
sprintf('New post %d refused after the insert; draft could not be deleted', $postId)
|
||||
);
|
||||
}
|
||||
|
||||
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress;
|
||||
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
|
||||
@@ -45,8 +46,12 @@ use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
|
||||
*/
|
||||
final class WordPressIntegration implements Integration
|
||||
{
|
||||
public function __construct(private ContentSchema $schema, private ChangeLog $log)
|
||||
{
|
||||
/** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private ChangeLog $log,
|
||||
private ?ContentComposer $divi = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
@@ -70,7 +75,7 @@ final class WordPressIntegration implements Integration
|
||||
new ContentTypesAbility($this->schema),
|
||||
new ContentQueryAbility($this->schema, $presenter),
|
||||
new ContentGetAbility($this->schema, $presenter),
|
||||
new ContentCreateAbility($this->schema, $writer, $this->log),
|
||||
new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi),
|
||||
new ContentUpdateAbility($this->schema, $writer, $this->log),
|
||||
new PostPublishAbility($this->schema, $this->log),
|
||||
new PostUnpublishAbility($this->schema, $this->log),
|
||||
|
||||
Reference in New Issue
Block a user