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,168 @@
<?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\ChangeLog\ChangeLog;
use WP_Error;
/**
* lit/media-delete: deletes one attachment after confirmation. WordPress has no media trash by
* default (MEDIA_TRASH false): wp_delete_attachment($id, false) then deletes the attachment and
* all its files permanently. Media that is used – certainly or possibly – needs force: true.
* Trashed attachments are refused (MediaGuard): deleting them would always be permanent.
*/
final class MediaDeleteAbility implements Ability
{
/** @var callable(): bool Whether wp_delete_attachment($id, false) moves to the trash. */
private $mediaTrash;
public function __construct(
private MediaUsageFinder $finder,
private ChangeLog $log,
?callable $mediaTrash = null
) {
$this->mediaTrash = $mediaTrash ?? static fn (): bool => defined('MEDIA_TRASH') && MEDIA_TRASH
&& defined('EMPTY_TRASH_DAYS') && EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/media-delete';
}
public function label(): string
{
return 'Medium löschen';
}
public function description(): string
{
return 'Löscht ein Medium samt Datei und allen Bildgrößen – ohne aktivierten Medien-Papierkorb '
. '(WordPress-Standard) endgültig, das lässt sich nicht rückgängig machen. Ohne confirm: true kommt '
. 'eine Rückfrage (confirm_required) mit den gefundenen Verwendungen; erst nach Zustimmung des Nutzers '
. 'mit confirm: true erneut aufrufen. Wird das Medium verwendet (auch nur "möglicherweise"), lehnt das '
. 'Tool mit in_use ab, außer force: true. Medien im Papierkorb werden nicht gelöscht. '
. 'Die Verwendungsprüfung findet NICHT: Term-Meta, Benutzer-Meta, Pods-Tabellen (pods_*, podsrel), '
. 'ACF-Felder (außer als Bildfeld im Schema mit einer ID pro Meta-Eintrag), serialisierte oder '
. 'kommagetrennte ID-Listen in Meta-Feldern und Optionen anderer Plugins – im Zweifel den Nutzer fragen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => [
'type' => 'boolean',
'description' => 'Erst nach Rückfrage beim Nutzer true setzen.',
],
'force' => ['type' => 'boolean', 'description' => 'Trotz gefundener Verwendungen löschen.'],
],
'required' => ['id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(
['usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()]],
['usages']
);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post');
return $attachment instanceof WP_Error ? $attachment : true;
}
public function execute(array $input): mixed
{
$attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post');
if ($attachment instanceof WP_Error) {
return $attachment;
}
$id = (int) $attachment->ID;
$title = (string) $attachment->post_title;
$trash = ($this->mediaTrash)();
// Same search as MediaUsageFinder::isUsed(): certain and possible usages both count.
$usages = $this->finder->usages($id);
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired($this->question($id, $title, $trash, $usages), ['usages' => $usages]);
}
if ($usages !== [] && ($input['force'] ?? false) !== true) {
return Errors::inUse(
sprintf(
'Medium "%s" wird noch verwendet (%d Stelle(n)); nichts wurde gelöscht.',
$title,
count($usages)
),
$usages
);
}
if (! wp_delete_attachment($id, false)) {
return Errors::writeFailed(sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id));
}
$warnings = [];
$summary = sprintf('Medium "%s" %s', $title, $trash ? 'in den Papierkorb gelegt' : 'gelöscht');
$logId = FollowUp::run(
fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; das Medium ist trotzdem '
. ($trash ? 'im Papierkorb.' : 'gelöscht.'),
$warnings
);
return ToolResult::ok($summary . '.')
->changed((string) $id, 'attachment', $trash ? 'trashed' : 'deleted', $title)
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->with('usages', $usages)
->toArray();
}
/** @param list<array<string, mixed>> $usages */
private function question(int $id, string $title, bool $trash, array $usages): string
{
$message = sprintf(
'Medium "%s" (ID %d) %s?',
$title,
$id,
$trash
? 'in den Papierkorb legen'
: 'endgültig löschen – samt Datei und allen Bildgrößen, nicht rückgängig zu machen'
);
if ($usages === []) {
return $message . ' Keine Verwendung gefunden (die Suche deckt nicht alle Speicherorte ab).';
}
$used = ' Es wird an %d Stelle(n) verwendet (siehe usages); löschen nur mit force: true.';
return $message . sprintf($used, count($usages));
}
}
+3 -1
View File
@@ -11,7 +11,7 @@ use LIT\MCP\Limits;
/**
* Media library tools (spec §6.3). Always available. $log and $limits serve the write
* tools (upload now, update and delete to follow).
* tools (upload, update, delete).
*/
final class MediaIntegration implements Integration
{
@@ -43,6 +43,8 @@ final class MediaIntegration implements Integration
new MediaUploadFromUrlAbility($importer, $uploads),
new MediaUploadAbility($importer, $uploads, $this->limits),
new MediaDownloadAbility($this->limits),
new MediaUpdateAbility($this->log),
new MediaDeleteAbility($finder, $this->log),
];
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use RuntimeException;
use WP_Error;
use WP_Post;
/**
* Writes the editable metadata of an attachment (alt text, title, caption, description) and
* restores it from a snapshot taken before (lit/media-update rolls a batch back with it).
* Every failure is a RuntimeException with an internal message for the PHP error log.
*/
final class MediaMetaWriter
{
public const ALT_KEY = '_wp_attachment_image_alt';
/** Input key => posts column. */
public const COLUMNS = ['title' => 'post_title', 'caption' => 'post_excerpt', 'description' => 'post_content'];
/**
* The current values of everything $changes will write.
*
* @param array<string, string> $changes Sanitized values by input key (alt, title, caption, description).
* @return array{id: int, columns: array<string, string>, alt_changed: bool, alt: ?string}
* alt null = there was no alt text (it is removed again on restore).
*/
public function snapshot(WP_Post $attachment, array $changes): array
{
$id = (int) $attachment->ID;
$columns = [];
foreach (self::columns($changes) as $column => $value) {
$columns[$column] = (string) $attachment->{$column};
}
$altChanged = array_key_exists('alt', $changes);
$alt = $altChanged && metadata_exists('post', $id, self::ALT_KEY)
? (string) get_post_meta($id, self::ALT_KEY, true)
: null;
return ['id' => $id, 'columns' => $columns, 'alt_changed' => $altChanged, 'alt' => $alt];
}
/**
* @param array<string, string> $changes Sanitized values by input key.
* @param bool $touched Set to true as soon as something may have been written.
* @throws RuntimeException
*/
public function apply(int $id, array $changes, bool &$touched): void
{
$columns = self::columns($changes);
if ($columns !== []) {
// Set before the call: a throwing save_post hook runs after the row was written.
$touched = true;
$result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true);
if ($result instanceof WP_Error || ! $result) {
// wp_update_post() reports errors before it writes anything.
$touched = false;
throw new RuntimeException(sprintf(
'Attachment %d not updated: %s',
$id,
$result instanceof WP_Error ? $result->get_error_message() : 'unknown error'
));
}
}
if (array_key_exists('alt', $changes)) {
$touched = true;
$this->saveAlt($id, $changes['alt']);
}
}
/**
* @param array{id: int, columns: array<string, string>, alt_changed: bool, alt: ?string} $snapshot
* @throws RuntimeException
*/
public function restore(array $snapshot): void
{
$id = $snapshot['id'];
if ($snapshot['columns'] !== []) {
$result = wp_update_post(wp_slash(array_merge($snapshot['columns'], ['ID' => $id])), true);
if ($result instanceof WP_Error || ! $result) {
throw new RuntimeException(sprintf(
'Attachment %d not restored: %s',
$id,
$result instanceof WP_Error ? $result->get_error_message() : 'unknown error'
));
}
}
if (! $snapshot['alt_changed']) {
return;
}
if ($snapshot['alt'] !== null) {
$this->saveAlt($id, $snapshot['alt']);
} elseif (! delete_post_meta($id, self::ALT_KEY) && metadata_exists('post', $id, self::ALT_KEY)) {
throw new RuntimeException(sprintf('Alt text of attachment %d not removed again.', $id));
}
}
/**
* @param array<string, string> $changes
* @return array<string, string> Column => value.
*/
private static function columns(array $changes): array
{
$columns = [];
foreach (self::COLUMNS as $key => $column) {
if (array_key_exists($key, $changes)) {
$columns[$column] = $changes[$key];
}
}
return $columns;
}
/** @throws RuntimeException */
private function saveAlt(int $id, string $alt): void
{
// update_post_meta() is also false when the value is already stored.
$stored = update_post_meta($id, self::ALT_KEY, wp_slash($alt)) !== false
|| (string) get_post_meta($id, self::ALT_KEY, true) === $alt;
if (! $stored) {
throw new RuntimeException(sprintf('Alt text of attachment %d not saved.', $id));
}
}
}
@@ -29,6 +29,8 @@ class MediaPresenter
'height' => isset($meta['height']) ? (int) $meta['height'] : null,
'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null,
'date' => (string) $attachment->post_date,
// Optimistic lock for lit/media-update (post_modified_gmt, changes with every row write).
'modified' => (string) $attachment->post_modified_gmt,
];
}
@@ -77,6 +79,7 @@ class MediaPresenter
'height' => ['type' => ['integer', 'null']],
'filesize' => ['type' => ['integer', 'null']],
'date' => ['type' => 'string'],
'modified' => ['type' => 'string'],
],
'required' => ['id', 'title', 'mime_type', 'url', 'alt'],
];
@@ -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
);
}
}