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 private let api: LinkdingAPI private(set) var claude: ClaudeService? private var enrichTask: Task? init(api: LinkdingAPI, claude: ClaudeService?) { self.api = api self.claude = claude } func updateClaude(_ service: ClaudeService?) { claude = service } func load() async { isLoading = true error = nil do { let response = try await api.fetchBookmarks(search: searchQuery) bookmarks = response.results nextPageUrl = response.next restoreAIData() } 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) bookmarks = response.results nextPageUrl = response.next restoreAIData() } catch { self.error = error.localizedDescription } isLoading = false } func semanticSearch() async { guard let claude, !searchQuery.isEmpty else { return } isLoading = true do { // Search over all loaded bookmarks semantically let allResponse = try await api.fetchBookmarks(limit: 200) 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) claude=\(claude != nil ? "present" : "nil")") guard let claude else { return } 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 generateSmartCollections() async { guard let claude, !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 { guard let claude else { return } 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() { guard claude != nil else { print("[AI] startEnrichment: skipped (no claude)") return } 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 } guard let claude = await MainActor.run(body: { self.claude }) else { return } 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: 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] } } } }