| 1 | import Foundation |
| 2 | |
| 3 | final class SearchService { |
| 4 | private let repository: ContentRepository |
| 5 | |
| 6 | init(repository: ContentRepository) { |
| 7 | self.repository = repository |
| 8 | } |
| 9 | |
| 10 | func results(for query: String) -> [GuideEntry] { |
| 11 | let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) |
| 12 | guard !trimmedQuery.isEmpty else { return [] } |
| 13 | |
| 14 | let needle = trimmedQuery.lowercased() |
| 15 | |
| 16 | let rankedEntries: [(entry: GuideEntry, score: Int)] = repository.entries.compactMap { |
| 17 | entry in |
| 18 | let score = score(for: entry, needle: needle) |
| 19 | guard score > 0 else { return nil } |
| 20 | return (entry: entry, score: score) |
| 21 | } |
| 22 | |
| 23 | return |
| 24 | rankedEntries |
| 25 | .sorted { lhs, rhs in |
| 26 | if lhs.score == rhs.score { |
| 27 | return lhs.entry.title < rhs.entry.title |
| 28 | } |
| 29 | return lhs.score > rhs.score |
| 30 | } |
| 31 | .map(\.entry) |
| 32 | } |
| 33 | |
| 34 | func suggestions(limit: Int = 6) -> [GuideEntry] { |
| 35 | Array(repository.featuredEntries.prefix(limit)) |
| 36 | } |
| 37 | |
| 38 | private func score(for entry: GuideEntry, needle: String) -> Int { |
| 39 | let title = entry.title.lowercased() |
| 40 | let subtitle = entry.subtitle.lowercased() |
| 41 | |
| 42 | if title == needle { return 120 } |
| 43 | if entry.aliases.map({ $0.lowercased() }).contains(needle) { return 100 } |
| 44 | if title.hasPrefix(needle) { return 90 } |
| 45 | if title.contains(needle) { return 80 } |
| 46 | if subtitle.contains(needle) { return 60 } |
| 47 | if entry.tags.contains(where: { $0.lowercased().contains(needle) }) { return 45 } |
| 48 | if entry.searchTokens.contains(where: { $0.lowercased().contains(needle) }) { return 30 } |
| 49 | |
| 50 | return 0 |
| 51 | } |
| 52 | } |