Files

249 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class SearchViewModel: ObservableObject {
enum State: Equatable { case idle, searching, finished, empty, unavailable }
@Published private(set) var state: State = .idle
@Published private(set) var books: [SearchBook] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var isLoadingMore = false
@Published private(set) var hasMore = false
@Published private(set) var isPaused = false
@Published private(set) var searchKeywords: [SearchKeyword] = []
@Published private(set) var completedSourceCount = 0
@Published private(set) var totalSourceCount = 0
@Published private(set) var submittedKeyword = ""
@Published private(set) var isShowingCachedResults = false
private let service: BookSearchService
private let keywordRepository: any SearchKeywordRepository
private let searchBookRepository: any SearchBookRepository
private var searchTask: Task<Void, Never>?
private var currentKeyword = ""
private var pagedSources: [BookSource] = []
private var currentPrecision = false
private var page = 1
private var activePage = 1
private var searchGeneration = 0
private var pendingSources: [BookSource] = []
private var pendingSourceIDs = Set<String>()
private var sourceNames: [String: String] = [:]
private var accumulator = BookSearchAccumulator()
private var cachedIdentities = Set<String>()
private var liveIdentities = Set<String>()
private var collectedDiagnostics: [RuleDiagnostic] = []
private var completedCount = 0
private var foundMoreOnActivePage = false
init(service: BookSearchService = .init(),
keywordRepository: any SearchKeywordRepository = SearchKeywordRepositoryFactory.live(),
searchBookRepository: any SearchBookRepository = SearchBookRepositoryFactory.live()) {
self.service = service
self.keywordRepository = keywordRepository
self.searchBookRepository = searchBookRepository
}
deinit { searchTask?.cancel() }
func search(keyword: String, sources: [BookSource], precision: Bool = false) {
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else { cancel(reset: true); return }
searchTask?.cancel()
searchGeneration &+= 1
currentKeyword = keyword
submittedKeyword = keyword
currentPrecision = precision
page = 1
activePage = 1
pendingSources = eligible(sources)
pendingSourceIDs = Set(pendingSources.map(\.id))
pagedSources = pendingSources.filter {
let url = $0.searchUrl ?? ""
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<")
}
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
accumulator = .init()
cachedIdentities = []
liveIdentities = []
collectedDiagnostics = []
completedCount = 0
foundMoreOnActivePage = false
isShowingCachedResults = false
hasMore = true
isPaused = false
isLoadingMore = false
books = []
diagnostics = []
completedSourceCount = 0
totalSourceCount = pendingSources.count
guard totalSourceCount > 0 else { hasMore = false; state = .unavailable; return }
state = .searching
Task { await recordKeyword(keyword) }
startWorker(loadCache: true)
}
private func eligible(_ sources: [BookSource]) -> [BookSource] {
var seen = Set<String>()
return sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted }
}
private func startWorker(loadCache: Bool = false) {
let generation = searchGeneration
let requestedPage = activePage
let keyword = currentKeyword
let sources = pendingSources.filter { pendingSourceIDs.contains($0.id) }
searchTask = Task { [service, searchBookRepository] in
var preferred = Set<String>()
if loadCache {
let cached = (try? await searchBookRepository.cachedMatches(keyword: keyword)) ?? []
guard !Task.isCancelled, generation == searchGeneration else { return }
let enabled = Dictionary(sources.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let now = Date().timeIntervalSince1970 * 1_000
let usable = cached.filter { book in
guard let source = enabled[book.origin] else { return false }
let age = now - Double(book.time)
return age >= 0 && age < 86_400_000 && source.lastUpdateTime <= book.time
}
accumulator.add(usable)
cachedIdentities = Set(usable.map { BookSearchService.mergeIdentity(for: $0) })
preferred = Set(usable.flatMap(\.origins)).intersection(Set(enabled.keys))
publishResults()
}
var lastVisibleUpdate = ProcessInfo.processInfo.systemUptime - 1
for await outcome in service.searchOutcomes(keyword: keyword, page: requestedPage, sources: sources, preferredSourceIDs: preferred) {
guard !Task.isCancelled, generation == searchGeneration else { return }
pendingSourceIDs.remove(outcome.sourceID)
completedCount += 1
collectedDiagnostics += namedDiagnostics(outcome)
for book in outcome.books {
let identity = BookSearchService.mergeIdentity(for: book)
if requestedPage > 1 && !liveIdentities.contains(identity) { foundMoreOnActivePage = true }
liveIdentities.insert(identity)
accumulator.add([book], replacingExisting: cachedIdentities.remove(identity) != nil)
}
let now = ProcessInfo.processInfo.systemUptime
if books.isEmpty || now - lastVisibleUpdate >= 0.25 {
publishResults()
lastVisibleUpdate = now
}
if !outcome.books.isEmpty { try? await searchBookRepository.put(outcome.books) }
}
guard !Task.isCancelled, generation == searchGeneration else { return }
publishResults()
page = requestedPage
hasMore = requestedPage == 1 ? !books.isEmpty && !pagedSources.isEmpty : foundMoreOnActivePage
isLoadingMore = false
state = books.isEmpty ? .empty : .finished
}
}
private func publishResults() {
let values = accumulator.results(keyword: currentKeyword)
let visible = currentPrecision ? values.filter {
$0.name.localizedCaseInsensitiveContains(currentKeyword)
|| $0.author.localizedCaseInsensitiveContains(currentKeyword)
|| $0.kind?.localizedCaseInsensitiveContains(currentKeyword) == true
} : values
if books != visible { books = visible }
if diagnostics != collectedDiagnostics { diagnostics = collectedDiagnostics }
if completedSourceCount != completedCount { completedSourceCount = completedCount }
let cachedOnly = !visible.isEmpty && liveIdentities.isEmpty
if isShowingCachedResults != cachedOnly { isShowingCachedResults = cachedOnly }
}
func loadMore() {
guard state == .finished, hasMore, !isLoadingMore, !isPaused, !currentKeyword.isEmpty else { return }
isLoadingMore = true
searchGeneration &+= 1
activePage = page + 1
pendingSources = eligible(pagedSources)
pendingSourceIDs = Set(pendingSources.map(\.id))
foundMoreOnActivePage = false
startWorker()
}
func cancel(reset: Bool = false) {
searchTask?.cancel()
searchTask = nil
searchGeneration &+= 1
isLoadingMore = false
if reset {
accumulator = .init()
cachedIdentities = []
liveIdentities = []
collectedDiagnostics = []
pendingSources = []
pendingSourceIDs = []
isShowingCachedResults = false
isPaused = false
state = .idle
books = []
diagnostics = []
hasMore = false
completedSourceCount = 0
totalSourceCount = 0
submittedKeyword = ""
} else {
publishResults()
isPaused = true
state = books.isEmpty ? .empty : .finished
}
}
func resume() {
guard isPaused else { return }
isPaused = false
guard !pendingSourceIDs.isEmpty else { loadMore(); return }
if activePage == 1 { state = .searching }
else { isLoadingMore = true }
startWorker()
}
private func namedDiagnostics(_ outcome: SourceSearchOutcome) -> [RuleDiagnostic] {
guard let name = sourceNames[outcome.sourceID] else { return outcome.diagnostics }
return outcome.diagnostics.map {
var value = $0
value.message = "\(name):\(value.message)"
return value
}
}
private func addDiagnostic(_ diagnostic: RuleDiagnostic) {
collectedDiagnostics.append(diagnostic)
diagnostics.append(diagnostic)
}
func loadSearchKeywords() async {
do { searchKeywords = try await keywordRepository.load(order: .lastUseTime) }
catch { addDiagnostic(.init(severity: .warning, code: "search.history.load", message: error.localizedDescription, rule: "SearchKeywordDao.flowByTime")) }
}
func clearSearchKeywords() async {
do {
try await keywordRepository.deleteAll()
searchKeywords = []
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.clear", message: error.localizedDescription, rule: "SearchKeywordDao.deleteAll")) }
}
func deleteSearchKeyword(_ word: String) async {
do {
try await keywordRepository.delete(word)
searchKeywords.removeAll { $0.word == word }
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.delete", message: error.localizedDescription, rule: "SearchKeywordDao.delete")) }
}
private func recordKeyword(_ word: String) async {
do {
_ = try await keywordRepository.record(word, at: Int64(Date().timeIntervalSince1970 * 1_000))
searchKeywords = try await keywordRepository.load(order: .lastUseTime)
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.record", message: error.localizedDescription, rule: "SearchKeywordDao.insert/update")) }
}
}