- WriteFailed::rolledBack()/partial() replace the 11-parameter constructor (now private); the hint/state of either error are plain $hint/$state. - Rollback::attempt(...steps) runs every undo step and collects failure messages; used by ContentCreate::discard, ContentUpdate::rollBack, MediaUpdate::rollBack and DiviSaver::restore. - Errors::clientMessage/codeOf/fieldsOf replace DiviReplaceTextAbility::reason, the JSON digging in MediaUpdateAbility::failure and ContentCreateAbility::fields. - Errors docblock documents the exception/WP_Error split. - ContentCreateAbility::discard takes the client reason as a parameter (both callers still pass REASON_EXTRAS; changing the Divi text needs a ruling). Error payloads unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
414 lines
16 KiB
PHP
414 lines
16 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Media;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\FollowUp;
|
||
use LIT\MCP\Abilities\Rollback;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* lit/media-update: metadata of up to 50 attachments in one call, all or nothing.
|
||
* Every item is validated first (attachment, edit_post, lengths, optional `modified` lock);
|
||
* one invalid item refuses the whole batch. If a write fails midway, the items already
|
||
* changed are restored (write_failed) – or, if that fails too, partial_write names them.
|
||
*
|
||
* The `modified` lock compares post_modified_gmt, which WordPress bumps on every write of the
|
||
* posts row (title, caption, description) but not on a pure alt-text (post meta) change. Its
|
||
* resolution is one second: a parallel write within the same second as the read goes unnoticed.
|
||
*/
|
||
final class MediaUpdateAbility implements Ability
|
||
{
|
||
public const MAX_ITEMS = 50;
|
||
|
||
/** Max. characters per field (before sanitizing). */
|
||
private const MAX_LENGTH = ['alt' => 500, 'title' => 255, 'caption' => 2000, 'description' => 20000];
|
||
|
||
private const REASON = 'WordPress hat die Metadaten eines Mediums nicht gespeichert.';
|
||
|
||
private const REV_HINT = 'Die genannten Medien neu lesen (lit/media-get oder lit/media-search, direkt: '
|
||
. 'lit-media-search) und mit dem aktuellen modified erneut senden.';
|
||
|
||
public function __construct(private ChangeLog $log, private MediaMetaWriter $writer = new MediaMetaWriter())
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/media-update';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Medien-Metadaten ändern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Ändert Alt-Text, Titel, Bildunterschrift und Beschreibung von bis zu 50 Medien in einem Aufruf – '
|
||
. 'alles oder nichts: Ist ein Eintrag ungültig (unbekannte ID, keine Berechtigung, Papierkorb, zu lang, '
|
||
. 'nichts zu ändern, veraltetes modified), wird nichts gespeichert und failures nennt jeden fehlerhaften '
|
||
. 'Eintrag. Optional pro Eintrag modified (Wert aus lit/media-get oder lit-media-search) mitsenden: '
|
||
. 'Wurde das Medium seitdem geändert, lehnt das Tool mit rev_mismatch ab. modified ändert sich bei '
|
||
. 'Titel, Bildunterschrift und Beschreibung, nicht bei einer reinen Alt-Text-Änderung. '
|
||
. 'Das Ergebnis meldet jeden Eintrag (results, mit neuem modified).';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
$max = self::MAX_LENGTH;
|
||
$text = static fn (string $description, string $key): array => [
|
||
'type' => 'string',
|
||
'maxLength' => $max[$key],
|
||
'description' => $description,
|
||
];
|
||
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'items' => [
|
||
'type' => 'array',
|
||
'minItems' => 1,
|
||
'maxItems' => self::MAX_ITEMS,
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer', 'minimum' => 1],
|
||
'alt' => $text('Alt-Text (reiner Text).', 'alt'),
|
||
'title' => $text('Titel in der Mediathek (reiner Text).', 'title'),
|
||
'caption' => $text('Bildunterschrift (einfaches HTML erlaubt).', 'caption'),
|
||
'description' => $text('Beschreibung (einfaches HTML erlaubt).', 'description'),
|
||
'modified' => [
|
||
'type' => 'string',
|
||
'description' => 'Optional: modified aus lit/media-get bzw. lit-media-search als '
|
||
. 'Schutz vor parallelen Änderungen.',
|
||
],
|
||
],
|
||
'required' => ['id'],
|
||
'additionalProperties' => false,
|
||
],
|
||
],
|
||
],
|
||
'required' => ['items'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'results' => [
|
||
'type' => 'array',
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'ok' => ['type' => 'boolean'],
|
||
'error' => ['type' => ['string', 'null']],
|
||
'log_id' => ['type' => ['integer', 'null']],
|
||
'modified' => ['type' => ['string', 'null']],
|
||
],
|
||
'required' => ['id', 'ok', 'error', 'log_id', 'modified'],
|
||
],
|
||
],
|
||
], ['results']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
/** edit_post per attachment is checked in execute() for every item. */
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('upload_files')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$plan = $this->plan($input['items'] ?? null);
|
||
if ($plan instanceof WP_Error) {
|
||
return $plan;
|
||
}
|
||
|
||
$this->write($plan);
|
||
|
||
return $this->result($plan);
|
||
}
|
||
|
||
/**
|
||
* Validates every item; nothing is written yet.
|
||
*
|
||
* @return list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
|
||
* columns: array<string, string>, alt_changed: bool, alt: ?string}}>|WP_Error
|
||
*/
|
||
private function plan(mixed $items): array|WP_Error
|
||
{
|
||
if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) {
|
||
return Errors::invalidInput(
|
||
sprintf('items muss eine Liste mit 1 bis %d Einträgen sein.', self::MAX_ITEMS),
|
||
['items' => sprintf('1–%d Einträge', self::MAX_ITEMS)]
|
||
);
|
||
}
|
||
|
||
$plan = [];
|
||
$failures = [];
|
||
$seen = [];
|
||
foreach ($items as $index => $item) {
|
||
$entry = $this->validate(is_array($item) ? $item : [], $seen);
|
||
if ($entry instanceof WP_Error) {
|
||
$failures[] = self::failure($index, is_array($item) ? $item : [], $entry);
|
||
continue;
|
||
}
|
||
|
||
$plan[] = $entry;
|
||
}
|
||
|
||
return $failures === [] ? $plan : self::refusal($failures, count($items));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $item
|
||
* @param array<int, true> $seen IDs of the items before.
|
||
* @return array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
|
||
* columns: array<string, string>, alt_changed: bool, alt: ?string}}|WP_Error
|
||
*/
|
||
private function validate(array $item, array &$seen): array|WP_Error
|
||
{
|
||
$attachment = MediaGuard::attachment($item['id'] ?? null);
|
||
if ($attachment instanceof WP_Error) {
|
||
return $attachment;
|
||
}
|
||
|
||
$id = (int) $attachment->ID;
|
||
$duplicate = isset($seen[$id]);
|
||
$seen[$id] = true;
|
||
|
||
$changes = [];
|
||
foreach (self::MAX_LENGTH as $key => $max) {
|
||
if (! array_key_exists($key, $item)) {
|
||
continue;
|
||
}
|
||
if (! is_string($item[$key]) || mb_strlen($item[$key]) > $max) {
|
||
return Errors::invalidInput(sprintf('%s: Text mit höchstens %d Zeichen erwartet.', $key, $max));
|
||
}
|
||
$changes[$key] = in_array($key, ['alt', 'title'], true)
|
||
? sanitize_text_field($item[$key])
|
||
: wp_kses_post($item[$key]);
|
||
}
|
||
|
||
if ($changes === []) {
|
||
return Errors::invalidInput('Nichts zu ändern: alt, title, caption oder description angeben.');
|
||
}
|
||
|
||
if ($duplicate) {
|
||
return Errors::invalidInput(sprintf('Medium %d steht mehrfach in items.', $id));
|
||
}
|
||
|
||
if (array_key_exists('modified', $item) && $item['modified'] !== (string) $attachment->post_modified_gmt) {
|
||
return self::revMismatch($id);
|
||
}
|
||
|
||
return [
|
||
'attachment' => $attachment,
|
||
'changes' => $changes,
|
||
'snapshot' => $this->writer->snapshot($attachment, $changes),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
|
||
* columns: array<string, string>, alt_changed: bool, alt: ?string}}> $plan
|
||
* @throws WriteFailed When an item could not be saved.
|
||
*/
|
||
private function write(array $plan): void
|
||
{
|
||
$written = [];
|
||
foreach ($plan as $entry) {
|
||
$touched = false;
|
||
try {
|
||
$this->writer->apply((int) $entry['attachment']->ID, $entry['changes'], $touched);
|
||
} catch (Throwable $e) {
|
||
if ($touched) {
|
||
$written[] = $entry;
|
||
}
|
||
$this->rollBack($written, $e);
|
||
}
|
||
$written[] = $entry;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Restores the items written so far, newest first.
|
||
*
|
||
* @param list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
|
||
* columns: array<string, string>, alt_changed: bool, alt: ?string}}> $written
|
||
* @throws WriteFailed Always – rolledBack tells whether everything was restored.
|
||
*/
|
||
private function rollBack(array $written, Throwable $cause): never
|
||
{
|
||
$unrestored = [];
|
||
$failures = [];
|
||
foreach (array_reverse($written) as $entry) {
|
||
$failed = Rollback::attempt(fn () => $this->writer->restore($entry['snapshot']));
|
||
if ($failed !== []) {
|
||
$unrestored[] = $entry['attachment'];
|
||
$failures = array_merge($failures, $failed);
|
||
}
|
||
}
|
||
|
||
$detail = 'Media update failed: ' . $cause->getMessage();
|
||
if ($unrestored === []) {
|
||
throw WriteFailed::rolledBack(
|
||
self::REASON,
|
||
$cause,
|
||
$detail,
|
||
hint: 'Das Zurücksetzen hat modified der Medien geändert: vor einem neuen Versuch die Medien '
|
||
. 'neu lesen (lit/media-get oder lit/media-search, direkt: lit-media-search) und den Stapel mit '
|
||
. 'dem aktuellen modified senden; tritt der Fehler wieder auf, den Administrator informieren '
|
||
. '(Details im PHP-Error-Log).'
|
||
);
|
||
}
|
||
|
||
$ids = [];
|
||
foreach (array_reverse($unrestored) as $attachment) {
|
||
$ids[] = (int) $attachment->ID;
|
||
$this->log->recordPartial(
|
||
$this->name(),
|
||
'attachment',
|
||
(string) $attachment->ID,
|
||
sprintf('Metadaten von "%s": Änderung nur teilweise zurückgenommen', $attachment->post_title)
|
||
);
|
||
}
|
||
|
||
throw WriteFailed::partial(
|
||
self::REASON,
|
||
$cause,
|
||
$detail . '; rollback incomplete: ' . implode('; ', $failures),
|
||
state: sprintf('Möglicherweise geändert: Medien %s (siehe changed_ids).', implode(', ', $ids)),
|
||
hint: 'Den Aufruf nicht einfach wiederholen. Die unter changed_ids genannten Medien mit lit/media-get '
|
||
. 'prüfen, die Metadaten ggf. korrigieren und dem Nutzer Bescheid geben.',
|
||
details: ['changed_ids' => $ids]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Everything is saved – change log and reading back are follow-up steps (warnings only).
|
||
*
|
||
* @param list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
|
||
* columns: array<string, string>, alt_changed: bool, alt: ?string}}> $plan
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function result(array $plan): array
|
||
{
|
||
$warnings = [];
|
||
$results = [];
|
||
$result = ToolResult::ok(sprintf('%1$d von %1$d Medien aktualisiert.', count($plan)));
|
||
$lastLogId = null;
|
||
|
||
foreach ($plan as $entry) {
|
||
$attachment = $entry['attachment'];
|
||
$id = (int) $attachment->ID;
|
||
$summary = sprintf(
|
||
'Metadaten von "%s" geändert: %s',
|
||
$attachment->post_title,
|
||
implode(', ', array_keys($entry['changes']))
|
||
);
|
||
$logId = FollowUp::run(
|
||
fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary),
|
||
$this->name() . ' change log',
|
||
sprintf(
|
||
'Änderungsprotokoll für Medium %d konnte nicht geschrieben werden; die Änderung ist gespeichert.',
|
||
$id
|
||
),
|
||
$warnings
|
||
);
|
||
$logId = is_int($logId) ? $logId : null;
|
||
$lastLogId = $logId ?? $lastLogId;
|
||
$current = get_post($id);
|
||
|
||
$results[] = [
|
||
'id' => $id,
|
||
'ok' => true,
|
||
'error' => null,
|
||
'log_id' => $logId,
|
||
'modified' => $current instanceof WP_Post ? (string) $current->post_modified_gmt : null,
|
||
];
|
||
$result->changed(
|
||
(string) $id,
|
||
'attachment',
|
||
'updated',
|
||
$entry['changes']['title'] ?? (string) $attachment->post_title
|
||
);
|
||
}
|
||
|
||
return $result->logId($lastLogId)->warnings($warnings)->with('results', $results)->toArray();
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $item
|
||
* @return array{index: int, id: ?int, code: string, error: string}
|
||
*/
|
||
private static function failure(int $index, array $item, WP_Error $error): array
|
||
{
|
||
return [
|
||
'index' => $index,
|
||
'id' => PostGuard::id($item['id'] ?? null),
|
||
'code' => Errors::codeOf($error),
|
||
'error' => Errors::clientMessage($error),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* One error for the whole batch: the common code of all failures, else validation_failed.
|
||
*
|
||
* @param non-empty-list<array{index: int, id: ?int, code: string, error: string}> $failures
|
||
*/
|
||
private static function refusal(array $failures, int $total): WP_Error
|
||
{
|
||
$codes = array_unique(array_column($failures, 'code'));
|
||
$message = sprintf(
|
||
'%d von %d Einträgen fehlerhaft (siehe failures); nichts wurde gespeichert.',
|
||
count($failures),
|
||
$total
|
||
);
|
||
|
||
return match (count($codes) === 1 ? $codes[0] : 'mixed') {
|
||
'forbidden' => Errors::make('forbidden', $message, 'Nur Medien ändern, die das verbundene Konto '
|
||
. 'bearbeiten darf; die übrigen Einträge erneut senden.', 403, ['failures' => $failures]),
|
||
'not_found' => Errors::make('not_found', $message, 'IDs prüfen (lit/media-search, direkt: '
|
||
. 'lit-media-search) und die Einträge korrigiert erneut senden.', 404, ['failures' => $failures]),
|
||
'rev_mismatch' => Errors::make('rev_mismatch', $message, self::REV_HINT, 409, ['failures' => $failures]),
|
||
default => Errors::make('validation_failed', $message, 'Die unter failures genannten Einträge '
|
||
. 'korrigieren und den ganzen Stapel erneut senden.', 400, ['failures' => $failures]),
|
||
};
|
||
}
|
||
|
||
private static function revMismatch(int $id): WP_Error
|
||
{
|
||
return Errors::make(
|
||
'rev_mismatch',
|
||
sprintf('Medium %d wurde seit dem letzten Lesen geändert.', $id),
|
||
self::REV_HINT,
|
||
409
|
||
);
|
||
}
|
||
}
|