import Foundation import Observation @Observable @MainActor final class BookmarksViewModel { var bookmarks: [Bookmark] = [] var isLoading = false var isLoadingMore = false var error: String? var searchQuery = "" var nextPageUrl: String? var smartCollections: [SmartCollection] = [] var isGeneratingCollections = false var enrichmentProgress: Double = 0 var unreadFilter = false private let api: LinkdingAPI let claude = ClaudeService() let podcastPlayer = PodcastPlayerViewModel() private var enrichTask: Task? private let cacheFileUrl: URL init(api: LinkdingAPI, cacheKey: String = "") { self.api = api let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_") let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)" self.cacheFileUrl = dir.appendingPathComponent("\(name).json") } func load(useCache: Bool = true) async { if useCache { loadFromCache() } isLoading = true error = nil do { let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter) bookmarks = response.results nextPageUrl = response.next restoreAIData() saveToCache(bookmarks) } catch { self.error = error.localizedDescription } isLoading = false startEnrichment() } func loadMore() async { guard let next = nextPageUrl, !isLoadingMore else { return } isLoadingMore = true do { let response = try await api.fetchBookmarksFromUrl(next) bookmarks.append(contentsOf: response.results) nextPageUrl = response.next restoreAIData() } catch { self.error = error.localizedDescription } isLoadingMore = false } func search() async { isLoading = true do { let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter) bookmarks = response.results nextPageUrl = response.next restoreAIData() } catch { self.error = error.localizedDescription } isLoading = false } func toggleUnreadFilter() async { unreadFilter.toggle() await load(useCache: false) } func semanticSearch() async { guard !searchQuery.isEmpty else { return } isLoading = true do { let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter) var all = allResponse.results // Restore AI data so summaries are available for ranking restoreAIData(into: &all) bookmarks = try await claude.semanticSearch(query: searchQuery, in: all) nextPageUrl = nil } catch { self.error = error.localizedDescription } isLoading = false } func addBookmark(url: String, title: String, tags: [String]) async throws { let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false) let bookmark = try await api.createBookmark(create) bookmarks.insert(bookmark, at: 0) print("[AI] addBookmark: saved id=\(bookmark.id)") Task { print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)") do { let (summary, aiTags) = try await claude.enrich(bookmark: bookmark) print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)") guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich") return } bookmarks[i].aiSummary = summary bookmarks[i].aiTags = aiTags saveAIData(for: bookmarks[i]) } catch { print("[AI] addBookmark: enrich FAILED \(error)") self.error = "AI enrichment failed: \(error.localizedDescription)" } } } func delete(_ bookmark: Bookmark) async { do { try await api.deleteBookmark(id: bookmark.id) bookmarks.removeAll { $0.id == bookmark.id } } catch { self.error = error.localizedDescription } } func archive(_ bookmark: Bookmark) async { do { try await api.archiveBookmark(id: bookmark.id) bookmarks.removeAll { $0.id == bookmark.id } } catch { self.error = error.localizedDescription } } func updateBookmark(_ bookmark: Bookmark) async throws { let update = BookmarkUpdate( url: bookmark.url, title: bookmark.title, description: bookmark.description, tagNames: bookmark.tagNames, unread: bookmark.unread, shared: bookmark.shared ) var updated = try await api.updateBookmark(id: bookmark.id, update: update) if let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) { updated.aiSummary = bookmarks[i].aiSummary updated.aiTags = bookmarks[i].aiTags bookmarks[i] = updated } } func generateSmartCollections() async { guard !bookmarks.isEmpty else { return } isGeneratingCollections = true do { let allResponse = try await api.fetchBookmarks(limit: 200) smartCollections = try await claude.generateCollections(from: allResponse.results) } catch { self.error = error.localizedDescription } isGeneratingCollections = false } func enrichAll() async { let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil } guard !toEnrich.isEmpty else { return } enrichmentProgress = 0 for (done, i) in toEnrich.enumerated() { do { let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i]) bookmarks[i].aiSummary = summary bookmarks[i].aiTags = tags saveAIData(for: bookmarks[i]) enrichmentProgress = Double(done + 1) / Double(toEnrich.count) } catch { break } } enrichmentProgress = 0 } // Silently enrich new bookmarks that lack summaries (background, non-blocking) private func startEnrichment() { enrichTask?.cancel() enrichTask = Task { [weak self] in guard let self else { return } let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) } print("[AI] startEnrichment: \(indices.count) bookmarks to enrich") for i in indices { guard !Task.isCancelled else { return } let claude = await MainActor.run(body: { self.claude }) do { let bm = await MainActor.run { bookmarks[i] } print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)") let (summary, tags) = try await claude.enrich(bookmark: bm) print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))") await MainActor.run { guard i < bookmarks.count else { return } bookmarks[i].aiSummary = summary bookmarks[i].aiTags = tags saveAIData(for: bookmarks[i]) } } catch { print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)") break } } } } // MARK: Disk cache private static let cacheEncoder: JSONEncoder = { let e = JSONEncoder() e.dateEncodingStrategy = .iso8601 return e }() private static let cacheDecoder: JSONDecoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() private func saveToCache(_ list: [Bookmark]) { guard !unreadFilter else { return } // don't overwrite full cache with filtered results do { let data = try Self.cacheEncoder.encode(list) try data.write(to: cacheFileUrl, options: .atomic) } catch { print("[Cache] saveToCache failed: \(error)") } } private func loadFromCache() { guard bookmarks.isEmpty, let data = try? Data(contentsOf: cacheFileUrl), var cached = try? Self.cacheDecoder.decode([Bookmark].self, from: data) else { return } restoreAIData(into: &cached) bookmarks = cached } // MARK: AI persistence private func saveAIData(for bookmark: Bookmark) { var store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:] store["\(bookmark.id)"] = [ "summary": bookmark.aiSummary ?? "", "tags": bookmark.aiTags ?? [] ] UserDefaults.standard.set(store, forKey: "aiData") } private func restoreAIData() { restoreAIData(into: &bookmarks) } private func restoreAIData(into list: inout [Bookmark]) { let store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:] for i in list.indices { if let d = store["\(list[i].id)"] { list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 } list[i].aiTags = d["tags"] as? [String] } } } }