'updated_at', 'createdAt' => 'created_at', 'commentCount' => 'comment_count', 'view_count' => 'view_count', ]; protected bool $matchSentences; protected bool $matchWords; protected ?Searcher $discussionSearcher; protected ?Searcher $postSearcher; public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings) { $this->matchSentences = true; $this->matchWords = true; $searchers = collect($container->tagged('blomstra.search.searchers')); $this->discussionSearcher = $searchers->first(fn ($s) => $s instanceof DiscussionSearcher); $this->postSearcher = $searchers->first(fn ($s) => $s instanceof CommentPostSearcher); } protected function data(ServerRequestInterface $request, Document $document) { $actor = RequestUtil::getActor($request); $filters = $this->extractFilter($request); $search = $this->getSearch($filters); $limit = $this->extractLimit($request); $offset = $this->extractOffset($request); $include = array_merge($this->extractInclude($request), ['state']); // Autocomplete path: fast prefix match on title.autocomplete only. if (!empty($filters['autocomplete']) && !empty($search)) { return $this->handleAutocomplete($search, $actor, $limit, $offset, $include, $document, $request); } $knownSortFields = array_merge(array_values($this->translateSort), ['rawId']); $logger = resolve(LoggerInterface::class); $phpSortField = null; $phpSortDir = 'desc'; $needsScoring = true; $sorts = []; foreach ($this->extractSort($request) as $field => $direction) { $translated = $this->translateSort[$field] ?? $field; if (!in_array($translated, $knownSortFields)) { $logger->warning("blomstra/search: unknown sort field \"{$field}\", ignoring."); continue; } $sorts[] = new Sort($translated, $direction); $needsScoring = false; if ($phpSortField === null && $translated !== 'rawId') { $phpSortField = $translated; $phpSortDir = $direction; } } // Default to latest when no explicit sort is requested. This lets has_child use // score_mode:none, which skips child scoring entirely and lets ES short-circuit // early on large corpora. Relevance is still available via sort=relevant if added. if (empty($sorts)) { $needsScoring = false; $phpSortField = 'updated_at'; $phpSortDir = 'desc'; $sorts[] = new Sort('updated_at', 'desc'); } $query = BoolQuery::create() // Always restrict to discussion documents; posts are only searched via has_child. ->add(TermQuery::create('join_field', 'discussion'), 'filter'); if (!empty($search)) { $query->add($this->buildTextQuery($search, $actor, $needsScoring)); } $this->addFilters($query, $actor, $filters); $builder = (new Builder($this->elastic)) ->index(resolve('blomstra.search.elastic_index')) ->addQuery($query); foreach ($sorts as $sort) { $builder->addSort($sort); } // track_total_hits: false lets ES stop counting once it has collected // enough results in sort order, avoiding a full-index count on every query. $payload = $builder->getPayload(); $payload['track_total_hits'] = false; $response = $this->elastic->search([ 'index' => resolve('blomstra.search.elastic_index'), 'size' => $limit + 1, 'from' => $offset, 'body' => $payload, ]); Discussion::setStateUser($actor); if (in_array('mostRelevantPost.user', $include)) { $include[] = 'mostRelevantPost.user.groups'; if (!in_array('mostRelevantPost', $include)) { $include[] = 'mostRelevantPost'; } } // All hits are discussion documents. Extract the best-matching post ID from // inner_hits when a has_child clause matched (i.e. the match came from a post). $results = Collection::make(Arr::get($response, 'hits.hits')) ->map(function ($hit) { // _id is "discussions:123" — parse the numeric part directly. $discussionId = Str::after($hit['_id'], 'discussions:'); // rawId on the inner hit gives us the integer post ID. $bestPostId = Arr::get($hit, 'inner_hits.best_post.hits.hits.0._source.rawId'); return [ 'discussion_id' => $discussionId, 'most_relevant_post_id' => $bestPostId, 'weight' => Arr::get($hit, 'sort.0', Arr::get($hit, '_score', 0)), ]; }); $document->addPaginationLinks( $this->uri->to('api')->route('blomstra.search', ['type' => 'discussions']), $request->getQueryParams(), $offset, $limit, $results->count() > $limit ? null : 0 ); $results = $results->take($limit); $discussions = Discussion::query() ->when( $actor->isGuest() || !$actor->hasPermission('discussion.hide'), fn ($q) => $q->whereNull('hidden_at') ) ->whereIn('id', $results->pluck('discussion_id')->filter()) ->get() ->each(function (Discussion $discussion) use ($results) { $result = $results->firstWhere('discussion_id', $discussion->id); $discussion->most_relevant_post_id = $result['most_relevant_post_id'] ?? $discussion->first_post_id; $discussion->weight = $result['weight'] ?? 0; }) ->keyBy('id') ->when( $phpSortField, fn ($c) => $phpSortDir === 'desc' ? $c->sortByDesc($phpSortField) : $c->sortBy($phpSortField), fn ($c) => $c->sortByDesc('weight') ) ->unique(); $this->loadRelations($discussions, $include); if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) { foreach ($discussions as $discussion) { foreach ($relations as $relation) { if ($discussion->$relation) { $discussion->$relation->discussion = $discussion; } } } } return $discussions; } /** * Fast autocomplete path: match prefix n-grams on title.autocomplete only. * No has_child, no phrase scoring — a single MatchQuery suffices. */ protected function handleAutocomplete( string $search, User $actor, int $limit, int $offset, array $include, Document $document, ServerRequestInterface $request ): mixed { $query = BoolQuery::create() ->add(TermQuery::create('join_field', 'discussion'), 'filter'); $query->add($this->buildAutocompleteQuery($search), 'must'); $this->addFilters($query, $actor, []); $payload = (new Builder($this->elastic)) ->index(resolve('blomstra.search.elastic_index')) ->addQuery($query) ->addSort(new Sort('updated_at', 'desc')) ->getPayload(); $response = $this->elastic->search([ 'index' => resolve('blomstra.search.elastic_index'), 'size' => $limit + 1, 'from' => $offset, 'body' => $payload, ]); Discussion::setStateUser($actor); $results = Collection::make(Arr::get($response, 'hits.hits')) ->map(fn ($hit) => [ 'discussion_id' => Str::after($hit['_id'], 'discussions:'), 'most_relevant_post_id' => null, 'weight' => Arr::get($hit, 'sort.0', 0), ]); $document->addPaginationLinks( $this->uri->to('api')->route('blomstra.search', ['type' => 'discussions']), $request->getQueryParams(), $offset, $limit, $results->count() > $limit ? null : 0 ); $results = $results->take($limit); $discussions = Discussion::query() ->when( $actor->isGuest() || !$actor->hasPermission('discussion.hide'), fn ($q) => $q->whereNull('hidden_at') ) ->whereIn('id', $results->pluck('discussion_id')->filter()) ->get() ->each(function (Discussion $discussion) use ($results) { $result = $results->firstWhere('discussion_id', $discussion->id); $discussion->most_relevant_post_id = $result['most_relevant_post_id'] ?? $discussion->first_post_id; $discussion->weight = $result['weight'] ?? 0; }) ->sortByDesc('updated_at') ->unique(); $this->loadRelations($discussions, $include); return $discussions; } protected function buildAutocompleteQuery(string $search): MatchQuery { return (new MatchQuery('title.autocomplete', $search)) ->operator('and') ->boost(1.0); } /** * Build the text-matching portion of the query. * * Discussion titles are matched directly (filtered to join_field=discussion). * Post bodies are matched via has_child. When $needsScoring is true (no explicit * sort, i.e. relevance ordering), score_mode=sum accumulates child scores onto the * parent so that discussions with many strongly-matching posts rank higher. When * false (any explicit field sort), score_mode=none skips scoring entirely — ES only * checks whether a matching child exists, which is significantly cheaper on large * corpora. * inner_hits returns the best-matching post for use as mostRelevantPost. * Hidden posts are only included in matching for users with post.hide permission. */ protected function buildTextQuery(string $search, User $actor, bool $needsScoring = false): BoolQuery { $textQuery = BoolQuery::create(); if ($this->discussionSearcher?->enabled()) { $textQuery->add($this->buildShouldClauses($search, $this->discussionSearcher->boost()), 'should'); } if ($this->postSearcher?->enabled()) { $postQuery = $this->buildShouldClauses($search, $this->postSearcher->boost()); // Guests and non-moderators may not see hidden posts; exclude them from child matching. if ($actor->isGuest() || !$actor->hasPermission('post.hide')) { $postQuery->add(TermQuery::create('is_hidden', 'false'), 'filter'); } // Without minimum_should_match, ES default MSM is 0 when a filter clause is present, // causing has_child to score every non-hidden post instead of only matching ones. $postQuery->minimumShouldMatch(1); $textQuery->add( HasChildQuery::create('post', $postQuery, $needsScoring ? 'sum' : 'none')->withInnerHits(), 'should' ); } return $textQuery; } protected function buildShouldClauses(string $search, float $boost): BoolQuery { $should = BoolQuery::create(); if ($this->matchSentences) { $should->add((new MatchPhraseQuery('content', $search))->boost(2 * $boost), 'should'); } if ($this->matchWords) { $should->add((new MatchQuery('content', $search))->operator('and')->boost(1.8 * $boost), 'should'); } return $should; } protected function extensionEnabled(string $extension): bool { return resolve(ExtensionManager::class)->isEnabled($extension); } protected function addFilters(BoolQuery $query, User $actor, array $filters = []): void { $groups = $this->getGroups($actor); $onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private'); $subQuery = BoolQuery::create() ->add(TermQuery::create('is_private', 'false')) ->add(TermsQuery::create('groups', $groups->toArray())); if ($this->extensionEnabled('flarum-tags') && !empty($filters['tag'])) { $slugs = is_array($filters['tag']) ? $filters['tag'] : explode(',', $filters['tag']); $tagIds = Tag::query()->whereIn('slug', $slugs)->pluck('id')->toArray(); if (!empty($tagIds)) { $query->add(TermsQuery::create('tags', $tagIds), 'filter'); } } if ($this->extensionEnabled('fof-byobu') && $actor->exists) { $byobuQuery = BoolQuery::create() ->add(TermQuery::create('is_private', 'true')) ->add( BoolQuery::create() ->add(TermsQuery::create('recipient_groups', $groups->toArray()), 'should') ->add(TermsQuery::create('recipient_users', [$actor->id]), 'should'), ); if ($onlyPrivate) { $subQuery = $byobuQuery; } else { $subQuery = BoolQuery::create() ->add($subQuery, 'should') ->add($byobuQuery, 'should'); } } $query->add($subQuery, 'filter'); } protected function getGroups(User $actor): Collection { $groups = $actor->groups->pluck('id'); $groups->add(Group::GUEST_ID); if ($actor->is_email_confirmed) { $groups->add(Group::MEMBER_ID); } return $groups; } protected function getSearch(array $filters): ?string { $search = Arr::get($filters, 'q'); if ($search) { // Strip Flarum gambit operators (tag:foo, author:bar, is:private, etc.) // before passing to ES. These are structural filters handled separately; // leaving them in causes operator:and to require the gambit tokens to // appear literally in post content, producing zero results. $q = collect(explode(' ', $search)) ->filter(fn (string $part) => !preg_match('/^\w+:/', $part)) ->filter() ->join(' '); return empty($q) ? null : $q; } return null; } }