Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
240 lines
9.2 KiB
PHP
240 lines
9.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Factory for the error objects abilities return.
|
||
*
|
||
* mcp-adapter 0.5 turns a WP_Error into a tool result with isError: true but
|
||
* forwards only the message text. The message therefore carries the whole
|
||
* structured error as JSON: {code, message, hint, fields?, usages?, …} (spec §8.2).
|
||
* clientMessage(), codeOf() and fieldsOf() read such an error back (batch results that list
|
||
* per-item errors, merged validation errors).
|
||
*
|
||
* How failures travel: pure helpers (e.g. the Divi Compose classes) throw
|
||
* InvalidArgumentException, which the caller collects into `fields`; abilities return a
|
||
* WP_Error from this factory; writes that may have left something behind throw WriteFailed
|
||
* (rolledBack()/partial()), which the AbilityRegistrar maps via toError(); internal steps
|
||
* throw RuntimeException, which is logged and becomes internal_error.
|
||
*/
|
||
final class Errors
|
||
{
|
||
/**
|
||
* @param array<string, mixed> $details Extra keys for the client, e.g. fields or usages.
|
||
*/
|
||
public static function make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error
|
||
{
|
||
$payload = array_merge(['code' => $code, 'message' => $message, 'hint' => $hint], $details);
|
||
$json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
||
return new WP_Error(
|
||
'lit_' . $code,
|
||
is_string($json) ? $json : $message,
|
||
array_merge(['status' => $status, 'hint' => $hint], $details)
|
||
);
|
||
}
|
||
|
||
public static function forbidden(
|
||
string $message,
|
||
string $hint = 'Mit den Rechten des verbundenen WordPress-Kontos nicht erlaubt; ggf. Administrator fragen.'
|
||
): WP_Error {
|
||
return self::make('forbidden', $message, $hint, 403);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, string> $fields Input path => problem.
|
||
*/
|
||
public static function invalidInput(
|
||
string $message,
|
||
array $fields = [],
|
||
string $hint = 'Die unter fields genannten Eingaben korrigieren und erneut senden.'
|
||
): WP_Error {
|
||
return self::make('validation_failed', $message, $hint, 400, $fields === [] ? [] : ['fields' => $fields]);
|
||
}
|
||
|
||
public static function notFound(
|
||
string $message,
|
||
string $hint = 'ID prüfen, z. B. über lit/content-query (direkt: lit-content-query) oder lit/media-search '
|
||
. '(direkt: lit-media-search) suchen.'
|
||
): WP_Error {
|
||
return self::make('not_found', $message, $hint, 404);
|
||
}
|
||
|
||
/** $message/$hint replace the post-oriented defaults (e.g. for the menu lock menu_rev). */
|
||
public static function revMismatch(?string $message = null, ?string $hint = null): WP_Error
|
||
{
|
||
return self::make(
|
||
'rev_mismatch',
|
||
$message ?? 'Der Inhalt wurde seit dem letzten Lesen geändert; nichts wurde geschrieben.',
|
||
$hint ?? 'Neu lesen (z. B. lit/content-get, direkt: lit-content-get) und die Änderung auf dem aktuellen '
|
||
. 'Stand wiederholen.',
|
||
409
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $usages
|
||
* @param string|null $hint Replaces the default hint (force: true, for lit/media-delete).
|
||
*/
|
||
public static function inUse(string $message, array $usages, ?string $hint = null): WP_Error
|
||
{
|
||
return self::make(
|
||
'in_use',
|
||
$message,
|
||
$hint ?? 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
|
||
409,
|
||
['usages' => $usages]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $details
|
||
*/
|
||
public static function confirmRequired(string $message, array $details = []): WP_Error
|
||
{
|
||
return self::make(
|
||
'confirm_required',
|
||
$message,
|
||
'Beim Nutzer nachfragen und dann mit confirm: true erneut aufrufen.',
|
||
409,
|
||
$details
|
||
);
|
||
}
|
||
|
||
public static function unsupported(string $message, string $hint): WP_Error
|
||
{
|
||
return self::make('unsupported', $message, $hint, 400);
|
||
}
|
||
|
||
/**
|
||
* Unknown Divi attribute paths (spec §5.4, §8.2). $fields names every problem of the call,
|
||
* the unknown paths with their closest known ones.
|
||
*
|
||
* @param array<string, string> $fields Input path => problem.
|
||
*/
|
||
public static function invalidAttrPath(array $fields): WP_Error
|
||
{
|
||
return self::make(
|
||
'invalid_attr_path',
|
||
'Unbekannte Attributpfade; nichts wurde gespeichert.',
|
||
'Vorschläge unter fields prüfen; alle gültigen Pfade eines Moduls liefert lit/divi-catalog '
|
||
. '(direkt: lit-divi-catalog) mit module.',
|
||
400,
|
||
['fields' => $fields]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A write failed and was rolled back completely.
|
||
*
|
||
* @param array<string, mixed> $details Extra error details (e.g. a backup that stays).
|
||
* @param string|null $hint Replaces the default hint (e.g. "re-read before retrying").
|
||
* @param string|null $state Replaces "Nichts wurde gespeichert." when the rollback left
|
||
* something behind on purpose (a backup or revision).
|
||
*/
|
||
public static function writeFailed(
|
||
string $reason,
|
||
array $details = [],
|
||
?string $hint = null,
|
||
?string $state = null
|
||
): WP_Error {
|
||
return self::make(
|
||
'write_failed',
|
||
trim('Speichern fehlgeschlagen: ' . $reason) . ' ' . ($state ?? 'Nichts wurde gespeichert.'),
|
||
$hint ?? 'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den '
|
||
. 'Administrator informieren (Details im PHP-Error-Log der Website).',
|
||
500,
|
||
$details
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The $state for writeFailed() when a clean rollback deliberately keeps something, e.g.
|
||
* "Revision 811" or "Sicherung 88": the change itself is not saved, but "Nichts wurde
|
||
* gespeichert" would be untrue.
|
||
*/
|
||
public static function notSavedKeeping(string $kept): string
|
||
{
|
||
return sprintf('Die Änderung wurde nicht gespeichert (%s bleibt erhalten).', $kept);
|
||
}
|
||
|
||
/**
|
||
* A write failed and could not be rolled back completely – something may have been saved.
|
||
* $state and $hint replace the post-oriented defaults for writes that are not about a post
|
||
* (e.g. menus); $extra is merged into the error details.
|
||
*
|
||
* @param array<string, mixed> $extra
|
||
*/
|
||
public static function partialWrite(
|
||
string $reason,
|
||
?int $postId = null,
|
||
?int $revisionId = null,
|
||
?string $state = null,
|
||
?string $hint = null,
|
||
array $extra = []
|
||
): WP_Error {
|
||
$state ??= $postId === null
|
||
? 'Möglicherweise wurde trotzdem ein Entwurf angelegt.'
|
||
: sprintf('Eintrag %d ist möglicherweise teilweise geändert bzw. angelegt worden', $postId)
|
||
. ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId));
|
||
$details = array_merge($extra, array_filter(
|
||
['post_id' => $postId, 'revision_id' => $revisionId],
|
||
static fn (?int $id): bool => $id !== null
|
||
));
|
||
|
||
return self::make(
|
||
'partial_write',
|
||
trim('Speichern fehlgeschlagen: ' . $reason) . ' Die Änderung konnte nicht vollständig zurückgenommen '
|
||
. 'werden. ' . $state,
|
||
$hint ?? 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand prüfen (lit/content-get, '
|
||
. 'direkt: lit-content-get; bei einem neuen Entwurf lit/content-query, direkt: lit-content-query) '
|
||
. 'und ggf. mit lit/revisions-list und lit/revision-restore (über mcp-adapter-execute-ability) den '
|
||
. 'vorherigen Stand wiederherstellen; dem Nutzer Bescheid geben.',
|
||
500,
|
||
$details
|
||
);
|
||
}
|
||
|
||
/** The client message of an error (its JSON "message"), without the JSON around it. */
|
||
public static function clientMessage(WP_Error $error): string
|
||
{
|
||
$data = json_decode($error->get_error_message(), true);
|
||
|
||
return is_array($data) && is_string($data['message'] ?? null) ? $data['message'] : $error->get_error_message();
|
||
}
|
||
|
||
/** The error code as the client sees it (without the "lit_" prefix of the WP_Error code). */
|
||
public static function codeOf(WP_Error $error): string
|
||
{
|
||
return (string) preg_replace('/^lit_/', '', (string) $error->get_error_code());
|
||
}
|
||
|
||
/**
|
||
* The `fields` of a validation error (input path => problem); [] when it has none.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
public static function fieldsOf(WP_Error $error): array
|
||
{
|
||
$data = $error->get_error_data();
|
||
|
||
return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : [];
|
||
}
|
||
|
||
public static function internal(): WP_Error
|
||
{
|
||
return self::make(
|
||
'internal_error',
|
||
'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.',
|
||
'Später erneut versuchen oder den Administrator der Website informieren.',
|
||
500
|
||
);
|
||
}
|
||
}
|