isMeilisearchAvailable()) { return self::BACKEND_DATABASE; } } // Validate Scout is available if configured if ($configured === self::BACKEND_SCOUT_DATABASE) { if (! $this->isScoutAvailable()) { return self::BACKEND_DATABASE; } } return $configured; } /** * Check if Laravel Scout is available. */ public function isScoutAvailable(): bool { return class_exists(\Laravel\Scout\Searchable::class); } /** * Check if Meilisearch is available and configured. */ public function isMeilisearchAvailable(): bool { if (! class_exists(\Meilisearch\Client::class)) { return false; } $host = config('scout.meilisearch.host'); return ! empty($host); } /** * Search content items. * * @param string $query Search query * @param array{ * workspace_id?: int, * type?: string, * status?: string|array, * category?: string, * tag?: string, * content_type?: string, * date_from?: string|Carbon, * date_to?: string|Carbon, * per_page?: int, * page?: int, * } $filters * @return LengthAwarePaginator */ public function search(string $query, array $filters = []): LengthAwarePaginator { $query = trim($query); $perPage = min($filters['per_page'] ?? $this->defaultPerPage, $this->maxPerPage); $page = max($filters['page'] ?? 1, 1); // For very short queries, use database search if (strlen($query) < $this->minQueryLength) { return $this->emptyPaginatedResult($perPage, $page); } $backend = $this->getBackend(); return match ($backend) { self::BACKEND_MEILISEARCH, self::BACKEND_SCOUT_DATABASE => $this->searchWithScout($query, $filters, $perPage, $page), default => $this->searchWithDatabase($query, $filters, $perPage, $page), }; } /** * Search using database LIKE queries with relevance scoring. * * @return LengthAwarePaginator */ protected function searchWithDatabase(string $query, array $filters, int $perPage, int $page): LengthAwarePaginator { $baseQuery = $this->buildBaseQuery($filters); $searchTerms = $this->tokeniseQuery($query); // Build search conditions $baseQuery->where(function (Builder $q) use ($query, $searchTerms) { // Exact phrase match in title $q->where('title', 'like', "%{$query}%"); // Individual term matches foreach ($searchTerms as $term) { if (strlen($term) >= $this->minQueryLength) { $q->orWhere('title', 'like', "%{$term}%") ->orWhere('excerpt', 'like', "%{$term}%") ->orWhere('content_html', 'like', "%{$term}%") ->orWhere('content_markdown', 'like', "%{$term}%") ->orWhere('slug', 'like', "%{$term}%"); } } }); // Get all matching results for scoring $allResults = $baseQuery->get(); // Calculate relevance scores and sort $scored = $this->scoreResults($allResults, $query, $searchTerms); // Manual pagination of scored results $total = $scored->count(); $offset = ($page - 1) * $perPage; $items = $scored->slice($offset, $perPage)->values(); // Convert to paginator return new \Illuminate\Pagination\LengthAwarePaginator( $items, $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()] ); } /** * Search using Laravel Scout. * * @return LengthAwarePaginator */ protected function searchWithScout(string $query, array $filters, int $perPage, int $page): LengthAwarePaginator { // Check if ContentItem uses Searchable trait if (! in_array(\Laravel\Scout\Searchable::class, class_uses_recursive(ContentItem::class))) { // Fall back to database search return $this->searchWithDatabase($query, $filters, $perPage, $page); } $searchBuilder = ContentItem::search($query); // Apply workspace filter if (isset($filters['workspace_id'])) { $searchBuilder->where('workspace_id', $filters['workspace_id']); } // Apply content type filter (native types) $searchBuilder->where('content_type', 'native'); // Apply filters via query callback for Scout database driver $searchBuilder->query(function (Builder $builder) use ($filters) { $this->applyFilters($builder, $filters); }); return $searchBuilder->paginate($perPage, 'page', $page); } /** * Get search suggestions based on partial query. * * @return Collection */ public function suggest(string $query, int $workspaceId, int $limit = 10): Collection { $query = trim($query); if (strlen($query) < $this->minQueryLength) { return collect(); } return ContentItem::forWorkspace($workspaceId) ->native() ->where(function (Builder $q) use ($query) { $q->where('title', 'like', "{$query}%") ->orWhere('title', 'like', "% {$query}%") ->orWhere('slug', 'like', "{$query}%"); }) ->select(['id', 'title', 'slug', 'type', 'status']) ->orderByRaw('CASE WHEN title LIKE ? THEN 0 ELSE 1 END', ["{$query}%"]) ->orderBy('updated_at', 'desc') ->limit($limit) ->get() ->map(fn (ContentItem $item) => [ 'id' => $item->id, 'title' => $item->title, 'slug' => $item->slug, 'type' => $item->type, 'status' => $item->status, ]); } /** * Build the base query with workspace and content type scope. */ protected function buildBaseQuery(array $filters): Builder { $query = ContentItem::query()->with(['author', 'taxonomies']); // Always scope to native content types $query->native(); // Apply all filters $this->applyFilters($query, $filters); return $query; } /** * Apply filters to a query builder. */ protected function applyFilters(Builder $query, array $filters): void { // Workspace filter if (isset($filters['workspace_id'])) { $query->forWorkspace($filters['workspace_id']); } // Content type (post/page) if (! empty($filters['type'])) { $query->where('type', $filters['type']); } // Status filter if (! empty($filters['status'])) { if (is_array($filters['status'])) { $query->whereIn('status', $filters['status']); } else { $query->where('status', $filters['status']); } } // Category filter if (! empty($filters['category'])) { $query->whereHas('categories', function (Builder $q) use ($filters) { $q->where('slug', $filters['category']); }); } // Tag filter if (! empty($filters['tag'])) { $query->whereHas('tags', function (Builder $q) use ($filters) { $q->where('slug', $filters['tag']); }); } // Content source type filter if (! empty($filters['content_type'])) { $query->where('content_type', $filters['content_type']); } // Date range filters if (! empty($filters['date_from'])) { $dateFrom = $filters['date_from'] instanceof Carbon ? $filters['date_from'] : Carbon::parse($filters['date_from']); $query->where('created_at', '>=', $dateFrom->startOfDay()); } if (! empty($filters['date_to'])) { $dateTo = $filters['date_to'] instanceof Carbon ? $filters['date_to'] : Carbon::parse($filters['date_to']); $query->where('created_at', '<=', $dateTo->endOfDay()); } } /** * Tokenise a search query into individual terms. * * @return array */ protected function tokeniseQuery(string $query): array { // Split on whitespace and filter empty/short terms return array_values(array_filter( preg_split('/\s+/', $query) ?: [], fn ($term) => strlen($term) >= $this->minQueryLength )); } /** * Calculate relevance scores for search results. * * @param Collection $items * @param array $searchTerms * @return Collection */ protected function scoreResults(Collection $items, string $query, array $searchTerms): Collection { $queryLower = strtolower($query); return $items ->map(function (ContentItem $item) use ($queryLower, $searchTerms) { $score = $this->calculateRelevanceScore($item, $queryLower, $searchTerms); $item->setAttribute('relevance_score', $score); return $item; }) ->sortByDesc('relevance_score'); } /** * Calculate relevance score for a single content item. */ protected function calculateRelevanceScore(ContentItem $item, string $queryLower, array $searchTerms): int { $score = 0; $titleLower = strtolower($item->title ?? ''); $slugLower = strtolower($item->slug ?? ''); $excerptLower = strtolower($item->excerpt ?? ''); $contentLower = strtolower(strip_tags($item->content_html ?? $item->content_markdown ?? '')); // Exact phrase matches (highest weight) if ($titleLower === $queryLower) { $score += 200; // Exact title match } elseif (str_starts_with($titleLower, $queryLower)) { $score += 150; // Title starts with query } elseif (str_contains($titleLower, $queryLower)) { $score += 100; // Title contains query } if (str_contains($slugLower, $queryLower)) { $score += 50; // Slug contains query } // Individual term matches foreach ($searchTerms as $term) { $termLower = strtolower($term); if (str_contains($titleLower, $termLower)) { $score += 30; } if (str_contains($slugLower, $termLower)) { $score += 20; } if (str_contains($excerptLower, $termLower)) { $score += 15; } if (str_contains($contentLower, $termLower)) { $score += 5; } } // Status boost (published content should rank higher) if ($item->status === 'publish') { $score += 10; } // Recency boost (content updated within 30 days) if ($item->updated_at && $item->updated_at->diffInDays(now()) < 30) { $score += 5; } return $score; } /** * Create an empty paginated result. * * @return LengthAwarePaginator */ protected function emptyPaginatedResult(int $perPage, int $page): LengthAwarePaginator { return new \Illuminate\Pagination\LengthAwarePaginator( collect(), 0, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()] ); } /** * Re-index all content items for Scout. * * Only applicable when using Scout backend. */ public function reindex(?Workspace $workspace = null): int { if (! $this->isScoutAvailable()) { return 0; } if (! in_array(\Laravel\Scout\Searchable::class, class_uses_recursive(ContentItem::class))) { return 0; } $query = ContentItem::native(); if ($workspace) { $query->forWorkspace($workspace->id); } $count = 0; $query->chunk(100, function ($items) use (&$count) { $items->searchable(); $count += $items->count(); }); return $count; } /** * Format search results for API response. * * @param LengthAwarePaginator $results */ public function formatForApi(LengthAwarePaginator $results): array { return [ 'data' => $results->map(fn (ContentItem $item) => [ 'id' => $item->id, 'slug' => $item->slug, 'title' => $item->title, 'type' => $item->type, 'status' => $item->status, 'content_type' => $item->content_type?->value, 'excerpt' => Str::limit($item->excerpt ?? strip_tags($item->content_html ?? ''), 200), 'author' => $item->author?->name, 'categories' => $item->categories->pluck('name')->all(), 'tags' => $item->tags->pluck('name')->all(), 'relevance_score' => $item->getAttribute('relevance_score'), 'created_at' => $item->created_at?->toIso8601String(), 'updated_at' => $item->updated_at?->toIso8601String(), ])->all(), 'meta' => [ 'current_page' => $results->currentPage(), 'last_page' => $results->lastPage(), 'per_page' => $results->perPage(), 'total' => $results->total(), 'backend' => $this->getBackend(), ], ]; } }