Files
linkding-ios/Marks/Views/BookmarksViewModel.swift
Krishna Kumar 1b40f50aab Add edit bookmark, offline cache, and unread filter
- Edit bookmark: full PATCH via swipe action or long-press context menu;
  EditBookmarkView covers URL, title, description, tags, and unread toggle
- Offline cache: bookmarks persisted to ApplicationSupport JSON and shown
  instantly on launch; cache is per-server, skipped when unread filter is on
- Unread filter: toolbar toggle sends ?unread=true to the API; title updates
  to "Unread"; no stale-cache flash when toggling
- Extract normalizedURL and parsedTags into String+Helpers, removing three
  copies of URL normalization and two of tag parsing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:51:54 -05:00

282 lines
10 KiB
Swift

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
private(set) var claude: ClaudeService?
private var enrichTask: Task<Void, Never>?
private let cacheFileUrl: URL
init(api: LinkdingAPI, claude: ClaudeService?, cacheKey: String = "") {
self.api = api
self.claude = claude
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 updateClaude(_ service: ClaudeService?) {
claude = service
}
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 let claude, !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) 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 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 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: 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]
}
}
}
}