diff --git a/docs/guides/overview.md b/docs/guides/overview.md index de7fe76..7e421c8 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -18,7 +18,7 @@ des verbundenen WordPress-Benutzers. ## Werkzeuge 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 Hinweisen immer so geschrieben): diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index 0632ea9..ed73209 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -35,6 +35,7 @@ final class MediaIntegration implements Integration $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys()); return [ + new MediaSearchAbility($presenter, $finder), new MediaGetAbility($presenter, $finder), ]; } diff --git a/src/Integrations/Media/MediaSearchAbility.php b/src/Integrations/Media/MediaSearchAbility.php new file mode 100644 index 0000000..b71081f --- /dev/null +++ b/src/Integrations/Media/MediaSearchAbility.php @@ -0,0 +1,236 @@ + '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 $input + * @return array + */ + 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 $args + * @return array{items: list, next_cursor: ?string, warnings: list} + */ + 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 $args + * @return array{items: list, next_cursor: ?string, warnings: list} + */ + 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); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index eeb70b5..76a8cd4 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -16,6 +16,7 @@ final class MediaIntegrationTest extends TestCase { /** Grows with Tasks 2–5 – keep in the order of abilities(). */ private const EXPECTED = [ + 'lit/media-search', 'lit/media-get', ]; diff --git a/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php new file mode 100644 index 0000000..ee667b4 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php @@ -0,0 +1,128 @@ +allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]); + + return $presenter; + } + + /** @return list */ + 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()); + } +}