Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
208 lines
7.5 KiB
PHP
208 lines
7.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Media;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\FollowUp;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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 Annotations::destructive(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
|
||
);
|
||
}
|
||
|
||
$file = (string) get_post_meta($id, '_wp_attached_file', true);
|
||
if (! wp_delete_attachment($id, false)) {
|
||
return $this->refused($id, $title, $file);
|
||
}
|
||
|
||
$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();
|
||
}
|
||
|
||
/**
|
||
* wp_delete_attachment() returned false/null. It deletes meta, terms and the row before the
|
||
* files, so a failure can leave half a deletion: only an attachment that is still there with
|
||
* its file reference intact counts as "nothing deleted".
|
||
*/
|
||
private function refused(int $id, string $title, string $file): WP_Error
|
||
{
|
||
$reason = sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id);
|
||
$current = get_post($id);
|
||
$intact = $current instanceof WP_Post
|
||
&& $current->post_type === 'attachment'
|
||
&& (string) get_post_meta($id, '_wp_attached_file', true) === $file;
|
||
if ($intact) {
|
||
return Errors::writeFailed($reason);
|
||
}
|
||
|
||
$this->log->recordPartial(
|
||
$this->name(),
|
||
'attachment',
|
||
(string) $id,
|
||
sprintf('Medium "%s": Löschen nur teilweise ausgeführt', $title)
|
||
);
|
||
|
||
return Errors::partialWrite(
|
||
$reason,
|
||
null,
|
||
null,
|
||
sprintf('Medium %d ist möglicherweise teilweise gelöscht (Daten oder Dateien fehlen).', $id),
|
||
'Den Aufruf nicht einfach wiederholen. Das Medium mit lit/media-get bzw. in der WordPress-Mediathek '
|
||
. 'prüfen und dem Nutzer Bescheid geben.',
|
||
['attachment_id' => $id]
|
||
);
|
||
}
|
||
|
||
/** @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));
|
||
}
|
||
}
|