'object', 'properties' => [ 'post_id' => ['type' => 'integer', 'minimum' => 1], 'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'], 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'], 'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS], 'cursor' => ['type' => 'string'], ], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'reference' => ['type' => 'object'], 'matches' => ['type' => 'array', 'items' => ['type' => 'object']], 'scanned' => ['type' => 'integer'], 'next_cursor' => ['type' => ['string', 'null']], 'note' => ['type' => 'string'], ], 'required' => ['reference', 'matches', 'scanned', 'next_cursor'], ]; } public function annotations(): array { return Annotations::read(); } public function isDirect(): bool { return false; } /** * Scans Divi pages (PageRead) of the types that use the builder (Catalog), after the library right (Roles). A * library entry as reference is read through LibraryAccess (Roles), no Divi call beyond that. */ public function diviFeatures(array $input): array { return [DiviFeature::PageRead, DiviFeature::Catalog, DiviFeature::Roles]; } public function permission(array $input): bool|WP_Error { return $this->access->canList() ?? true; } public function execute(array $input): mixed { $refusal = $this->access->canList(); if ($refusal !== null) { return $refusal; } $reference = $this->reference($input); if ($reference instanceof WP_Error) { return $reference; } $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null); if ($offset === null) { return Cursor::invalid(); } $types = $this->postTypes($input['post_type'] ?? null); if ($types instanceof WP_Error) { return $types; } $limit = is_int($input['limit'] ?? null) ? max(1, min(self::MAX_POSTS, $input['limit'])) : self::DEFAULT_POSTS; $args = [ 'post_type' => $types, 'post_status' => self::LIVING, 'posts_per_page' => $limit + 1, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => false, ]; foreach ($types as $type) { $own = PostGuard::ownPostsOnly($type); if ($own !== null) { $args['author'] = $own; break; } } $ids = array_values(array_map('intval', (array) get_posts($args))); $scan = array_slice($ids, 0, $limit); $matches = []; $nodes = 0; $scanned = 0; foreach (array_chunk($scan, self::CONTENT_CHUNK) as $chunk) { $fresh = _get_non_cached_ids($chunk, 'posts'); _prime_post_caches($chunk, false, false); foreach ($chunk as $id) { if (self::full($matches, $nodes)) { break; } ++$scanned; $post = current_user_can('edit_post', $id) ? get_post($id) : null; if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) { $this->collect($post, $reference, $matches, $nodes); } } foreach ($fresh as $id) { wp_cache_delete((int) $id, 'posts'); } if (self::full($matches, $nodes)) { break; } } $stopped = $scanned < count($scan); $result = [ 'reference' => $reference['info'], 'matches' => $matches, 'scanned' => $scanned, 'next_cursor' => $stopped || count($ids) > $limit ? Cursor::encode($offset + $scanned) : null, ]; if (self::full($matches, $nodes)) { $result['note'] = sprintf( 'Suche nach %d Einträgen angehalten (höchstens %d Treffer bzw. %d verglichene Knoten je Aufruf); ' . 'der letzte Eintrag kann weitere Treffer enthalten. Weiter mit next_cursor, oder mit post_type ' . 'eingrenzen.', $scanned, self::MAX_MATCHES, self::MAX_NODES ); } return $result; } /** @param list> $matches */ private static function full(array $matches, int $nodes): bool { return count($matches) >= self::MAX_MATCHES || $nodes >= self::MAX_NODES; } /** * @param array $input * @return array{block: Block, info: array, post_id: ?int, id: ?string}|WP_Error */ private function reference(array $input): array|WP_Error { if (isset($input['library_id'])) { $item = $this->access->item($input['library_id'], false); if ($item === null) { return PostGuard::notFound((int) PostGuard::id($input['library_id'])); } if ($item instanceof WP_Error) { return $item; } try { $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); } catch (MalformedMarkup) { $roots = []; } if (count($roots) !== 1 || $roots[0]->isOpaque()) { return Errors::invalidInput( sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID), ['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul'] ); } return self::describe($roots[0], null, null, (int) $item->ID); } if (! isset($input['post_id'])) { return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [ 'library_id' => 'oder post_id und id', ]); } $page = $this->loader->load($input['post_id'], false); if ($page instanceof WP_Error) { return $page; } $id = is_string($input['id'] ?? null) ? $input['id'] : ''; $node = $page->index->find($id); if ($node === null) { return DiviErrors::unknownNode($id, $page->post); } if ($node->isOpaque()) { return Errors::invalidInput( 'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.', ['id' => 'lesbarer Knoten'] ); } return self::describe($node, (int) $page->post->ID, $id, null); } /** @return array{block: Block, info: array, post_id: ?int, id: ?string} */ private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array { $info = array_filter([ 'type' => $block->name(), 'structure' => Fingerprint::structure($block), 'content' => Fingerprint::content($block), 'post_id' => $postId, 'id' => $id, 'library_id' => $libraryId, ], static fn (mixed $value): bool => $value !== null); return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id]; } /** * Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them * (review M-3: the Theme Builder types from DiviThemeBuilder::postTypes()). * * @return list */ private static function notScanned(): array { return array_merge([DiviLibraryStore::POST_TYPE], DiviThemeBuilder::postTypes()); } /** @return list|WP_Error */ private function postTypes(mixed $postType): array|WP_Error { if ($postType !== null) { // Review M-7: an excluded type answers like any unavailable one, by its name. $type = in_array($postType, self::notScanned(), true) ? PostGuard::unavailableType((string) $postType) : PostGuard::postType($postType, $this->schema, 'edit_posts'); return $type instanceof WP_Error ? $type : [(string) $postType]; } $types = array_values(array_filter( array_diff($this->schema->postTypeNames(), self::notScanned()), fn (string $type): bool => $this->schema->diviEnabled($type) )); return $types !== [] ? $types : Errors::notFound( 'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.', 'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.' ); } /** * @param array{block: Block, info: array, post_id: ?int, id: ?string} $reference * @param list> $matches */ private function collect(WP_Post $post, array $reference, array &$matches, int &$nodes): void { $content = (string) $post->post_content; if (! str_contains($content, '