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));
}
}