feat: media-search with missing_alt and unused filters
Ruling Q8 caps the unused scan at 20 candidates per call (not the brief's 500/50), and each result (search and unused alike) is filtered to attachments the current user may edit; overview.md lists media-search as a direct tool (ruling Q2). 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:
@@ -18,7 +18,7 @@ des verbundenen WordPress-Benutzers.
|
|||||||
## Werkzeuge
|
## Werkzeuge
|
||||||
|
|
||||||
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
|
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
|
||||||
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`.
|
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`.
|
||||||
|
|
||||||
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
|
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
|
||||||
Hinweisen immer so geschrieben):
|
Hinweisen immer so geschrieben):
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ final class MediaIntegration implements Integration
|
|||||||
$finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys());
|
$finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys());
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
new MediaSearchAbility($presenter, $finder),
|
||||||
new MediaGetAbility($presenter, $finder),
|
new MediaGetAbility($presenter, $finder),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Cursor;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media library search (spec §6.3). Direct tool. `unused` only ever checks a small,
|
||||||
|
* bounded number of candidates per call via MediaUsageFinder::isUsed() (each call does one
|
||||||
|
* full content scan – expensive): it stops after MAX_SCAN candidates and reports the
|
||||||
|
* interruption as a warning together with a next_cursor to continue.
|
||||||
|
*/
|
||||||
|
final class MediaSearchAbility implements Ability
|
||||||
|
{
|
||||||
|
/** Max. attachments checked with isUsed() per call – keeps the tool cheap (spec/ruling Q8). */
|
||||||
|
private const MAX_SCAN = 20;
|
||||||
|
|
||||||
|
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
|
||||||
|
|
||||||
|
public function __construct(private MediaPresenter $presenter, private MediaUsageFinder $finder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/media-search';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Medien suchen';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Sucht in der Mediathek nach Text (Titel, Dateiname, Beschreibung), MIME-Typ (z. B. image oder '
|
||||||
|
. 'application/pdf) und Zeitraum. Nur Medien, die der Nutzer bearbeiten darf; Papierkorb ausgeschlossen. '
|
||||||
|
. 'missing_alt: Bilder ohne Alt-Text. unused: Medien ohne gefundene Verwendung – "keine Verwendung '
|
||||||
|
. 'gefunden" ist keine Garantie (Details je Medium über lit/media-get); als "möglicherweise verwendet" '
|
||||||
|
. 'erkannte Medien zählen bereits als verwendet und werden nicht gelistet. Prüft dafür je Aufruf '
|
||||||
|
. 'höchstens ' . self::MAX_SCAN . ' Medien; wird die Prüfung dadurch unterbrochen, folgt eine Warnung '
|
||||||
|
. 'und mit next_cursor kann fortgesetzt werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => array_merge([
|
||||||
|
'search' => ['type' => 'string', 'maxLength' => 200],
|
||||||
|
'mime' => ['type' => 'string', 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'],
|
||||||
|
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
|
||||||
|
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
|
||||||
|
'missing_alt' => ['type' => 'boolean'],
|
||||||
|
'unused' => ['type' => 'boolean'],
|
||||||
|
], Cursor::inputProperties()),
|
||||||
|
'additionalProperties' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputSchema(): ?array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'items' => ['type' => 'array', 'items' => MediaPresenter::summarySchema()],
|
||||||
|
'next_cursor' => ['type' => ['string', 'null']],
|
||||||
|
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
|
],
|
||||||
|
'required' => ['items', 'next_cursor', 'warnings'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annotations(): array
|
||||||
|
{
|
||||||
|
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDirect(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
|
||||||
|
if ($offset === null) {
|
||||||
|
return Cursor::invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = Cursor::limit($input['limit'] ?? null);
|
||||||
|
$args = $this->queryArgs($input);
|
||||||
|
|
||||||
|
$page = ($input['unused'] ?? false) === true
|
||||||
|
? $this->unusedPage($args, $limit, $offset)
|
||||||
|
: $this->searchPage($args, $limit, $offset);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => array_map(
|
||||||
|
fn (WP_Post $attachment): array => $this->presenter->summary($attachment),
|
||||||
|
$page['items']
|
||||||
|
),
|
||||||
|
'next_cursor' => $page['next_cursor'],
|
||||||
|
'warnings' => $page['warnings'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function queryArgs(array $input): array
|
||||||
|
{
|
||||||
|
$args = [
|
||||||
|
'post_type' => 'attachment',
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'no_found_rows' => true,
|
||||||
|
'suppress_filters' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($input['search']) && $input['search'] !== '') {
|
||||||
|
$args['s'] = (string) $input['search'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($input['mime']) && $input['mime'] !== '') {
|
||||||
|
$args['post_mime_type'] = (string) $input['mime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($input['missing_alt'] ?? false) === true) {
|
||||||
|
$args['post_mime_type'] ??= 'image';
|
||||||
|
$args['meta_query'] = [
|
||||||
|
'relation' => 'OR',
|
||||||
|
['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
|
||||||
|
['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = [];
|
||||||
|
if (isset($input['date_after'])) {
|
||||||
|
$date['after'] = (string) $input['date_after'];
|
||||||
|
}
|
||||||
|
if (isset($input['date_before'])) {
|
||||||
|
$date['before'] = (string) $input['date_before'];
|
||||||
|
}
|
||||||
|
if ($date !== []) {
|
||||||
|
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $args
|
||||||
|
* @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
|
||||||
|
*/
|
||||||
|
private function searchPage(array $args, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$rows = get_posts(array_merge($args, ['posts_per_page' => $limit + 1, 'offset' => $offset]));
|
||||||
|
$page = Cursor::page(array_values($rows), $limit, $offset);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => array_values(array_filter($page['items'], [self::class, 'editable'])),
|
||||||
|
'next_cursor' => $page['next_cursor'],
|
||||||
|
'warnings' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches at most MAX_SCAN + 1 candidates (the extra one only tells us whether more exist,
|
||||||
|
* it is never passed to isUsed()) so at most MAX_SCAN expensive isUsed() checks happen here.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $args
|
||||||
|
* @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
|
||||||
|
*/
|
||||||
|
private function unusedPage(array $args, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$candidates = array_values(get_posts(array_merge($args, [
|
||||||
|
'posts_per_page' => self::MAX_SCAN + 1,
|
||||||
|
'offset' => $offset,
|
||||||
|
])));
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$position = $offset;
|
||||||
|
$examined = 0;
|
||||||
|
|
||||||
|
foreach ($candidates as $attachment) {
|
||||||
|
if ($examined >= self::MAX_SCAN) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$position++;
|
||||||
|
$examined++;
|
||||||
|
|
||||||
|
if (self::editable($attachment) && ! $this->finder->isUsed((int) $attachment->ID)) {
|
||||||
|
$items[] = $attachment;
|
||||||
|
if (count($items) === $limit) {
|
||||||
|
return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($candidates) <= self::MAX_SCAN) {
|
||||||
|
return ['items' => $items, 'next_cursor' => null, 'warnings' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => $items,
|
||||||
|
'next_cursor' => Cursor::encode($position),
|
||||||
|
'warnings' => [sprintf(
|
||||||
|
'Suche nach unbenutzten Medien nach %d geprüften Medien unterbrochen; mit next_cursor fortsetzen.',
|
||||||
|
self::MAX_SCAN
|
||||||
|
)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only attachments the current user may edit are ever listed; trashed ones never reach here. */
|
||||||
|
private static function editable(WP_Post $attachment): bool
|
||||||
|
{
|
||||||
|
return current_user_can('edit_post', (int) $attachment->ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ final class MediaIntegrationTest extends TestCase
|
|||||||
{
|
{
|
||||||
/** Grows with Tasks 2–5 – keep in the order of abilities(). */
|
/** Grows with Tasks 2–5 – keep in the order of abilities(). */
|
||||||
private const EXPECTED = [
|
private const EXPECTED = [
|
||||||
|
'lit/media-search',
|
||||||
'lit/media-get',
|
'lit/media-get',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Cursor;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaPresenter;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaSearchAbility;
|
||||||
|
use LIT\MCP\Integrations\Media\MediaUsageFinder;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class MediaSearchAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private function presenter(): MediaPresenter
|
||||||
|
{
|
||||||
|
$presenter = Mockery::mock(MediaPresenter::class);
|
||||||
|
$presenter->allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]);
|
||||||
|
|
||||||
|
return $presenter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<WP_Post> */
|
||||||
|
private function attachments(int ...$ids): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingAltLimitsToImagesWithoutAltText(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool =>
|
||||||
|
$args['post_type'] === 'attachment'
|
||||||
|
&& $args['post_mime_type'] === 'image'
|
||||||
|
&& $args['s'] === 'team'
|
||||||
|
&& $args['meta_query'] === [
|
||||||
|
'relation' => 'OR',
|
||||||
|
['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
|
||||||
|
['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
|
||||||
|
]
|
||||||
|
&& $args['posts_per_page'] === 21
|
||||||
|
&& $args['offset'] === 0))->andReturn($this->attachments(1, 2));
|
||||||
|
|
||||||
|
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
|
||||||
|
->execute(['search' => 'team', 'missing_alt' => true]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 1], ['id' => 2]], $result['items']);
|
||||||
|
self::assertNull($result['next_cursor']);
|
||||||
|
self::assertSame([], $result['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExcludesAttachmentsTheUserCannotEdit(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn($this->attachments(1, 2));
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2);
|
||||||
|
|
||||||
|
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
|
||||||
|
->execute([]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 1]], $result['items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnusedFiltersThroughTheUsageFinder(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3));
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$finder = Mockery::mock(MediaUsageFinder::class);
|
||||||
|
$finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2);
|
||||||
|
|
||||||
|
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 1], ['id' => 3]], $result['items']);
|
||||||
|
self::assertNull($result['next_cursor']);
|
||||||
|
self::assertSame([], $result['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnusedStopsAtTheLimitWithACursor(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3));
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$finder = Mockery::mock(MediaUsageFinder::class);
|
||||||
|
$finder->allows('isUsed')->andReturn(false);
|
||||||
|
|
||||||
|
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 1]], $result['items']);
|
||||||
|
self::assertSame(1, Cursor::decode($result['next_cursor']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnusedSkipsAttachmentsTheUserCannotEdit(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3));
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2);
|
||||||
|
$finder = Mockery::mock(MediaUsageFinder::class);
|
||||||
|
$finder->allows('isUsed')->andReturn(false);
|
||||||
|
|
||||||
|
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 1], ['id' => 3]], $result['items']);
|
||||||
|
self::assertNull($result['next_cursor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnusedWarnsAndPagesWhenTheScanCapIsReached(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn($this->attachments(...range(1, 21)));
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$finder = Mockery::mock(MediaUsageFinder::class);
|
||||||
|
$finder->allows('isUsed')->andReturn(true);
|
||||||
|
|
||||||
|
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]);
|
||||||
|
|
||||||
|
self::assertSame([], $result['items']);
|
||||||
|
self::assertSame(20, Cursor::decode($result['next_cursor']));
|
||||||
|
self::assertNotSame([], $result['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequiresUploadRights(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
$ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class));
|
||||||
|
|
||||||
|
self::assertSame('lit_forbidden', $ability->permission([])->get_error_code());
|
||||||
|
self::assertTrue($ability->isDirect());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user