Files
WP-LIT-MCP/src/Integrations/Media/MediaUpdateAbility.php
T
janniclas.loosenandClaude Opus 5.5 8e5ba9eac4 refactor: WriteFailed named constructors, Rollback runner, Errors read-back helpers (H6)
- 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>
2026-09-26 17:40:32 +02:00

414 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
);
}
}