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>
This commit is contained in:
Krishna Kumar
2026-05-21 11:51:54 -05:00
parent b7ef5c2215
commit 1b40f50aab
11 changed files with 269 additions and 20 deletions

View File

@@ -13,28 +13,36 @@ final class BookmarksViewModel {
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?) {
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() async {
func load(useCache: Bool = true) async {
if useCache { loadFromCache() }
isLoading = true
error = nil
do {
let response = try await api.fetchBookmarks(search: searchQuery)
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
bookmarks = response.results
nextPageUrl = response.next
restoreAIData()
saveToCache(bookmarks)
} catch {
self.error = error.localizedDescription
}
@@ -59,7 +67,7 @@ final class BookmarksViewModel {
func search() async {
isLoading = true
do {
let response = try await api.fetchBookmarks(search: searchQuery)
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
bookmarks = response.results
nextPageUrl = response.next
restoreAIData()
@@ -69,12 +77,16 @@ final class BookmarksViewModel {
isLoading = false
}
func toggleUnreadFilter() async {
unreadFilter.toggle()
await load(useCache: 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)
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)
@@ -129,6 +141,23 @@ final class BookmarksViewModel {
}
}
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
@@ -193,6 +222,38 @@ final class BookmarksViewModel {
}
}
// 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) {