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() let podcastGenerator = PodcastGenerationManager() 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") } /// True while the player is showing an episode (playing or generating one). var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty } /// Handle a headphone tap without ever interrupting current playback. /// If the player is idle, generate-and-play in the foreground and return /// `true` (caller should present the full player). If the player is busy, /// generate in the background — the finished episode lands in the library — /// and return `false`. @discardableResult func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool { if isPlayerBusy { podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl) return false } podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl) return true } /// Drain podcast requests queued by the share extension and start generating /// them in the background. Safe to call repeatedly (the queue is cleared). func processPendingPodcastRequests() { for req in PodcastRequests.drain() { podcastGenerator.enqueue(articleUrl: req.url, title: req.title) } } 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) WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map { WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl) }) Task { await SpotlightIndexer.index(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() let added = response.results Task { await SpotlightIndexer.index(added) } } 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 Analytics.track("search.semantic", ["query": searchQuery, "resultCount": bookmarks.count]) } 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) Task { await SpotlightIndexer.index([bookmark]) } Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)") Task { Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)") do { let (summary, aiTags) = try await claude.enrich(bookmark: bookmark) Log.ai.info("addBookmark enrich ok id=\(bookmark.id, privacy: .public) tags=\(aiTags.count, privacy: .public)") guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { Log.ai.notice("addBookmark id=\(bookmark.id, privacy: .public) gone before enrich applied") return } bookmarks[i].aiSummary = summary bookmarks[i].aiTags = aiTags saveAIData(for: bookmarks[i]) let enriched = bookmarks[i] Task { await SpotlightIndexer.index([enriched]) } } catch { Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)") 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 } Task { await SpotlightIndexer.remove(ids: [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 } Task { await SpotlightIndexer.remove(ids: [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) Analytics.track("collections.generated", ["collectionCount": smartCollections.count]) } 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]) let enriched = bookmarks[i] Task { await SpotlightIndexer.index([enriched]) } enrichmentProgress = Double(done + 1) / Double(toEnrich.count) } catch { break } } let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) } 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) } Log.ai.info("startEnrichment: \(indices.count, privacy: .public) 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] } Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)") let (summary, tags) = try await claude.enrich(bookmark: bm) Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)") await MainActor.run { guard i < bookmarks.count else { return } bookmarks[i].aiSummary = summary bookmarks[i].aiTags = tags saveAIData(for: bookmarks[i]) let enriched = bookmarks[i] Task { await SpotlightIndexer.index([enriched]) } } } catch { Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)") 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 { Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)") } } 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] } } } }