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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user