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, snapshot: array{id: int, * columns: array, 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 $item * @param array $seen IDs of the items before. * @return array{attachment: WP_Post, changes: array, snapshot: array{id: int, * columns: array, 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, snapshot: array{id: int, * columns: array, 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, snapshot: array{id: int, * columns: array, 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, snapshot: array{id: int, * columns: array, alt_changed: bool, alt: ?string}}> $plan * @return array */ 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 $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 $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 ); } }