import Foundation import CoreSpotlight /// A bookmark retrieved from the Spotlight index, reduced to the fields the /// on-device model needs to ground an answer. struct RetrievedBookmark: Sendable { let title: String let host: String let description: String let url: String } /// Retrieval half of on-device RAG: queries the Spotlight index that /// `SpotlightIndexer` populated and returns the best-matching bookmarks. The /// search runs entirely on-device against the app's own indexed entities. enum SpotlightBookmarkSearch { static func run(query rawQuery: String, limit: Int) async -> [RetrievedBookmark] { guard CSSearchableIndex.isIndexingAvailable() else { Log.spotlight.notice("Search skipped: indexing unavailable") return [] } let queryString = makeQueryString(from: rawQuery) guard !queryString.isEmpty else { return [] } Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") let context = CSSearchQueryContext() context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] let query = CSSearchQuery(queryString: queryString, queryContext: context) var out: [RetrievedBookmark] = [] do { for try await result in query.results { let a = result.item.attributeSet out.append(RetrievedBookmark( title: a.title ?? "Untitled", host: a.url?.host() ?? "", description: a.contentDescription ?? "", url: a.url?.absoluteString ?? "" )) if out.count >= limit { break } } Log.spotlight.info("Search \"\(rawQuery, privacy: .public)\" → \(out.count, privacy: .public) hits") } catch { Log.spotlight.error("Search failed: \(error.localizedDescription, privacy: .public)") } query.cancel() return out } /// Build a CoreSpotlight query string that OR-matches each significant token /// across title / description / keywords, case- and diacritic-insensitive. private static func makeQueryString(from raw: String) -> String { let tokens = raw .components(separatedBy: CharacterSet.alphanumerics.inverted) .map { $0.lowercased() } .filter { $0.count >= 3 } let terms = (tokens.isEmpty ? [raw] : tokens).map(sanitize).filter { !$0.isEmpty } let fields = ["title", "contentDescription", "keywords"] let clauses = terms.flatMap { term in fields.map { "\($0) == \"*\(term)*\"cd" } } guard !clauses.isEmpty else { return "" } return "(" + clauses.joined(separator: " || ") + ")" } /// Keep only alphanumerics so a token can't break the query string syntax. private static func sanitize(_ s: String) -> String { String(String.UnicodeScalarView(s.unicodeScalars.filter(CharacterSet.alphanumerics.contains))) } }