From d68e9b1f04c3e4b6d4ee39724e2b025e8386b661 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 22:39:43 +0200 Subject: [PATCH] feat: content-query and content-get tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Content/ContentGetAbility.php | 76 +++++++ .../WordPress/Content/ContentQueryAbility.php | 207 ++++++++++++++++++ .../WordPress/Content/ContentTypesAbility.php | 45 +++- .../WordPress/Content/PostPresenter.php | 166 ++++++++++++++ .../WordPress/WordPressIntegration.php | 7 + .../Content/ContentGetAbilityTest.php | 48 ++++ .../Content/ContentQueryAbilityTest.php | 101 +++++++++ .../Content/ContentTypesAbilityTest.php | 56 +++++ .../WordPress/Content/PostPresenterTest.php | 102 +++++++++ .../WordPress/WordPressIntegrationTest.php | 2 + 10 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 src/Integrations/WordPress/Content/ContentGetAbility.php create mode 100644 src/Integrations/WordPress/Content/ContentQueryAbility.php create mode 100644 src/Integrations/WordPress/Content/PostPresenter.php create mode 100644 tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php diff --git a/src/Integrations/WordPress/Content/ContentGetAbility.php b/src/Integrations/WordPress/Content/ContentGetAbility.php new file mode 100644 index 0000000..dae031f --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentGetAbility.php @@ -0,0 +1,76 @@ + 'object', + 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return PostPresenter::fullSchema(); + } + + 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 + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + if ($post instanceof WP_Error) { + return $post; + } + + return $this->presenter->full($post); + } +} diff --git a/src/Integrations/WordPress/Content/ContentQueryAbility.php b/src/Integrations/WordPress/Content/ContentQueryAbility.php new file mode 100644 index 0000000..48793d8 --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentQueryAbility.php @@ -0,0 +1,207 @@ + 'object', + 'properties' => array_merge([ + 'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, z. B. page oder post.'], + 'status' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::STATUSES], + 'description' => 'Standard: alle außer Papierkorb.', + ], + 'search' => ['type' => 'string'], + 'taxonomy' => [ + 'type' => 'object', + 'properties' => [ + 'taxonomy' => ['type' => 'string'], + 'terms' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => ['integer', 'string']]], + ], + 'required' => ['taxonomy', 'terms'], + 'additionalProperties' => false, + ], + 'field' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'], + 'value' => ['type' => ['string', 'number', 'boolean']], + 'compare' => ['type' => 'string', 'enum' => ['=', '!=', 'LIKE', 'EXISTS', 'NOT EXISTS']], + ], + 'required' => ['key'], + 'additionalProperties' => false, + ], + 'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'parent' => ['type' => 'integer', 'minimum' => 0], + 'orderby' => ['type' => 'string', 'enum' => ['date', 'modified', 'title', 'menu_order']], + 'order' => ['type' => 'string', 'enum' => ['asc', 'desc']], + ], Cursor::inputProperties()), + 'required' => ['post_type'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => PostPresenter::summarySchema()], + 'next_cursor' => ['type' => ['string', 'null']], + ], + 'required' => ['items', 'next_cursor'], + ]; + } + + 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 + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + 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); + $posts = get_posts(array_merge($this->queryArgs($input), [ + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + ])); + $page = Cursor::page(array_values($posts), $limit, $offset); + + $items = []; + foreach ($page['items'] as $post) { + if ($post instanceof WP_Post && current_user_can('edit_post', (int) $post->ID)) { + $items[] = $this->presenter->summary($post); + } + } + + return ['items' => $items, 'next_cursor' => $page['next_cursor']]; + } + + /** + * @param array $input + * @return array + */ + private function queryArgs(array $input): array + { + $args = [ + 'post_type' => (string) $input['post_type'], + 'post_status' => $input['status'] ?? self::STATUSES, + 'orderby' => (string) ($input['orderby'] ?? 'date'), + 'order' => strtoupper((string) ($input['order'] ?? 'desc')), + 'no_found_rows' => true, + 'ignore_sticky_posts' => true, + 'suppress_filters' => false, + ]; + + if (isset($input['search']) && $input['search'] !== '') { + $args['s'] = (string) $input['search']; + } + + if (isset($input['parent'])) { + $args['post_parent'] = (int) $input['parent']; + } + + if (isset($input['taxonomy']) && is_array($input['taxonomy'])) { + $args['tax_query'] = $this->taxQuery($input['taxonomy']); + } + + if (isset($input['field']) && is_array($input['field'])) { + $compare = (string) ($input['field']['compare'] ?? '='); + $clause = ['key' => (string) $input['field']['key'], 'compare' => $compare]; + if (! in_array($compare, ['EXISTS', 'NOT EXISTS'], true)) { + $clause['value'] = $input['field']['value'] ?? ''; + } + $args['meta_query'] = [$clause]; + } + + $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 $filter + * @return array + */ + private function taxQuery(array $filter): array + { + $taxonomy = (string) $filter['taxonomy']; + $terms = (array) $filter['terms']; + $ids = array_values(array_filter($terms, 'is_int')); + $slugs = array_values(array_filter($terms, 'is_string')); + + $clauses = []; + if ($ids !== []) { + $clauses[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids]; + } + if ($slugs !== []) { + $clauses[] = ['taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $slugs]; + } + + return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses; + } +} diff --git a/src/Integrations/WordPress/Content/ContentTypesAbility.php b/src/Integrations/WordPress/Content/ContentTypesAbility.php index d39c542..f8f9c69 100644 --- a/src/Integrations/WordPress/Content/ContentTypesAbility.php +++ b/src/Integrations/WordPress/Content/ContentTypesAbility.php @@ -69,16 +69,30 @@ final class ContentTypesAbility implements Ability public function permission(array $input): bool|WP_Error { - return current_user_can('edit_posts') - ? true - : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + if (! current_user_can('edit_posts')) { + return Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + } + + $postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : ''; + + return $this->forbiddenForType($postType) ?? true; } public function execute(array $input): mixed { $postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : ''; if ($postType === '') { - return ['post_types' => $this->schema->describePostTypes()]; + $types = array_values(array_filter( + $this->schema->describePostTypes(), + fn (array $description): bool => $this->canEdit((string) $description['name']) + )); + + return ['post_types' => $types]; + } + + $forbidden = $this->forbiddenForType($postType); + if ($forbidden !== null) { + return $forbidden; } $description = $this->schema->describe($postType, true); @@ -91,4 +105,27 @@ final class ContentTypesAbility implements Ability return ['post_types' => [$description]]; } + + /** + * Only post types the user can edit are listed or described (spec §4.5): requesting + * a managed but non-editable type is a forbidden error, not a silent omission. + */ + private function forbiddenForType(string $postType): ?WP_Error + { + if ($postType !== '' && $this->schema->isManaged($postType) && ! $this->canEdit($postType)) { + return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $postType)); + } + + return null; + } + + private function canEdit(string $postType): bool + { + $object = get_post_type_object($postType); + $capability = is_object($object) && isset($object->cap->edit_posts) + ? (string) $object->cap->edit_posts + : 'edit_posts'; + + return current_user_can($capability); + } } diff --git a/src/Integrations/WordPress/Content/PostPresenter.php b/src/Integrations/WordPress/Content/PostPresenter.php new file mode 100644 index 0000000..44cf1c3 --- /dev/null +++ b/src/Integrations/WordPress/Content/PostPresenter.php @@ -0,0 +1,166 @@ + */ + public function summary(WP_Post $post): array + { + return [ + 'id' => (int) $post->ID, + 'type' => (string) $post->post_type, + 'status' => (string) $post->post_status, + 'title' => (string) $post->post_title, + 'slug' => (string) $post->post_name, + 'parent' => (int) $post->post_parent, + 'date' => (string) $post->post_date, + 'modified' => (string) $post->post_modified, + 'url' => (string) get_permalink($post), + 'edit_url' => (string) get_edit_post_link($post->ID, 'raw'), + 'divi' => self::usesDivi($post), + ]; + } + + /** @return array */ + public function full(WP_Post $post): array + { + $divi = self::usesDivi($post); + + return array_merge($this->summary($post), [ + 'excerpt' => (string) $post->post_excerpt, + 'content' => $divi ? null : (string) $post->post_content, + 'menu_order' => (int) $post->menu_order, + 'author' => [ + 'id' => (int) $post->post_author, + 'name' => (string) get_the_author_meta('display_name', (int) $post->post_author), + ], + 'template' => (string) get_page_template_slug($post), + 'preview_url' => (string) get_preview_post_link($post), + 'terms' => JsonMap::of($this->terms($post)), + 'fields' => JsonMap::of($this->fields($post)), + 'featured_image' => $this->featuredImage($post), + 'rev' => Rev::of((string) $post->post_content), + 'hints' => $divi ? [self::DIVI_HINT] : [], + ]); + } + + public static function usesDivi(WP_Post $post): bool + { + return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on' + || str_contains((string) $post->post_content, '']); + + $full = (new PostPresenter($schema))->full($post); + + self::assertTrue($full['divi']); + self::assertNull($full['content']); + self::assertNull($full['featured_image']); + self::assertStringContainsString('lit-divi-outline', $full['hints'][0]); + self::assertInstanceOf(stdClass::class, $full['terms']); + self::assertInstanceOf(stdClass::class, $full['fields']); + } + + public function testDiviIsAlsoDetectedFromBlockMarkup(): void + { + Functions\when('get_post_meta')->justReturn(''); + + self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x y']))); + self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '']))); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 82a812a..ad76ae1 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -16,6 +16,8 @@ final class WordPressIntegrationTest extends TestCase /** Grows with every task that adds abilities – keep in the order of abilities(). */ private const EXPECTED = [ 'lit/content-types', + 'lit/content-query', + 'lit/content-get', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void