feat: batch media metadata updates and delete with usage check

lit/media-update validates the whole batch first (edit_post, lengths, optional
modified lock) and restores already written items when a write fails midway.
lit/media-delete requires confirm, refuses used media without force and
reports refused deletes as write_failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 02:49:41 +02:00
co-authored by Claude Opus 5.5
parent 56f561c436
commit 931ddeb137
9 changed files with 1145 additions and 1 deletions
@@ -0,0 +1,409 @@
<?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\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
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.
*/
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) 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) {
try {
$this->writer->restore($entry['snapshot']);
} catch (Throwable $e) {
$unrestored[] = $entry['attachment'];
$failures[] = $e->getMessage();
}
}
$detail = 'Media update failed: ' . $cause->getMessage();
if ($unrestored === []) {
throw new WriteFailed(self::REASON, true, null, null, $cause, $detail);
}
$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 new WriteFailed(
self::REASON,
false,
null,
null,
$cause,
$detail . '; rollback incomplete: ' . implode('; ', $failures),
sprintf('Möglicherweise geändert: Medien %s (siehe changed_ids).', implode(', ', $ids)),
'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.',
['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
{
$payload = json_decode($error->get_error_message(), true);
$id = $item['id'] ?? null;
return [
'index' => $index,
'id' => is_int($id) ? $id : null,
'code' => (string) preg_replace('/^lit_/', '', (string) $error->get_error_code()),
'error' => is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(),
];
}
/**
* 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) 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
);
}
}