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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user