From 1b40f50aabdc71f420760b4b6855e739b0ced070 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Thu, 21 May 2026 11:51:54 -0500 Subject: [PATCH] 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 --- Marks.xcodeproj/project.pbxproj | 8 ++ Marks/MarksApp.swift | 2 +- Marks/Models/Bookmark.swift | 14 ++++ Marks/Models/String+Helpers.swift | 14 ++++ Marks/Services/LinkdingAPI.swift | 7 +- Marks/Views/AddBookmarkView.swift | 7 +- Marks/Views/BookmarkRow.swift | 8 ++ Marks/Views/BookmarksView.swift | 42 ++++++++++- Marks/Views/BookmarksViewModel.swift | 73 ++++++++++++++++-- Marks/Views/EditBookmarkView.swift | 109 +++++++++++++++++++++++++++ Marks/Views/OnboardingView.swift | 5 +- 11 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 Marks/Models/String+Helpers.swift create mode 100644 Marks/Views/EditBookmarkView.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index b18a6dc..7c1d6e5 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -22,7 +22,9 @@ A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; }; B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; + C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; /* End PBXBuildFile section */ @@ -59,6 +61,7 @@ 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; + 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -70,6 +73,7 @@ BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; + D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = ""; }; @@ -95,6 +99,7 @@ children = ( 8CA428181B35885F7D9F4D55 /* Bookmark.swift */, 07C21567B95F5069BA946252 /* ServerConfig.swift */, + 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */, ); path = Models; sourceTree = ""; @@ -126,6 +131,7 @@ CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, FE19F7619214271A8C12EEEB /* CollectionsView.swift */, + D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */, 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */, BCC3BB2525F0F63445D419B9 /* SearchView.swift */, 5C29CB878BC334639E6194E2 /* SettingsView.swift */, @@ -262,12 +268,14 @@ 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, + C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, + B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 04b3777..ffe18e5 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -33,7 +33,7 @@ struct MainContainer: View { self.onDisconnect = onDisconnect let api = LinkdingAPI(config: config) let claude = ClaudeService.load() - _viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude)) + _viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude, cacheKey: config.host)) } var body: some View { diff --git a/Marks/Models/Bookmark.swift b/Marks/Models/Bookmark.swift index fdd8295..725ba1a 100644 --- a/Marks/Models/Bookmark.swift +++ b/Marks/Models/Bookmark.swift @@ -65,6 +65,20 @@ struct BookmarkCreate: Codable, Sendable { } } +struct BookmarkUpdate: Codable, Sendable { + var url: String + var title: String + var description: String + var tagNames: [String] + var unread: Bool + var shared: Bool + + enum CodingKeys: String, CodingKey { + case url, title, description, unread, shared + case tagNames = "tag_names" + } +} + struct SmartCollection: Identifiable, Sendable { let id = UUID() let name: String diff --git a/Marks/Models/String+Helpers.swift b/Marks/Models/String+Helpers.swift new file mode 100644 index 0000000..78f335f --- /dev/null +++ b/Marks/Models/String+Helpers.swift @@ -0,0 +1,14 @@ +import Foundation + +extension String { + var normalizedURL: String { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) ? trimmed : "https://" + trimmed + } + + var parsedTags: [String] { + split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} diff --git a/Marks/Services/LinkdingAPI.swift b/Marks/Services/LinkdingAPI.swift index 28ba94e..7eb468d 100644 --- a/Marks/Services/LinkdingAPI.swift +++ b/Marks/Services/LinkdingAPI.swift @@ -57,12 +57,13 @@ actor LinkdingAPI { return req } - func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50) async throws -> BookmarkResponse { + func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50, unread: Bool = false) async throws -> BookmarkResponse { var items: [URLQueryItem] = [ .init(name: "limit", value: "\(limit)"), .init(name: "offset", value: "\(offset)") ] if !search.isEmpty { items.append(.init(name: "q", value: search)) } + if unread { items.append(.init(name: "unread", value: "true")) } let url = try makeUrl(path: "/api/bookmarks/", queryItems: items) let (data, response) = try await session.data(for: authorizedRequest(url: url)) guard (response as? HTTPURLResponse)?.statusCode == 200 else { @@ -87,8 +88,8 @@ actor LinkdingAPI { return try decoder.decode(Bookmark.self, from: data) } - func updateBookmark(id: Int, tagNames: [String]) async throws -> Bookmark { - let body = try JSONEncoder().encode(["tag_names": tagNames]) + func updateBookmark(id: Int, update: BookmarkUpdate) async throws -> Bookmark { + let body = try JSONEncoder().encode(update) let url = try makeUrl(path: "/api/bookmarks/\(id)/") let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "PATCH", body: body)) guard (response as? HTTPURLResponse)?.statusCode == 200 else { diff --git a/Marks/Views/AddBookmarkView.swift b/Marks/Views/AddBookmarkView.swift index 7d4cb97..8b65a12 100644 --- a/Marks/Views/AddBookmarkView.swift +++ b/Marks/Views/AddBookmarkView.swift @@ -64,15 +64,12 @@ struct AddBookmarkView: View { } private func save() { - var urlString = url.trimmingCharacters(in: .whitespaces) - if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") { - urlString = "https://" + urlString - } + let urlString = url.normalizedURL guard URL(string: urlString) != nil else { error = "Invalid URL" return } - let tags = tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + let tags = tagsText.parsedTags isSaving = true error = nil Task { diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift index 2e7f999..330922d 100644 --- a/Marks/Views/BookmarkRow.swift +++ b/Marks/Views/BookmarkRow.swift @@ -7,6 +7,14 @@ struct BookmarkRow: View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 11) { FaviconView(url: bookmark.faviconUrl) + .overlay(alignment: .topLeading) { + if bookmark.unread { + Circle() + .fill(.blue) + .frame(width: 8, height: 8) + .offset(x: -3, y: -3) + } + } .padding(.top, 1) VStack(alignment: .leading, spacing: 4) { diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 96d1b83..eb4a24b 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -8,6 +8,7 @@ struct BookmarksView: View { @State private var showSettings = false @State private var showCollections = false @State private var showAddBookmark = false + @State private var editingBookmark: Bookmark? var body: some View { NavigationStack { @@ -34,6 +35,35 @@ struct BookmarksView: View { Label("Archive", systemImage: "archivebox") } .tint(.orange) + Button { + editingBookmark = bookmark + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + .contextMenu { + Button { + editingBookmark = bookmark + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + if let url = URL(string: bookmark.url) { openURL(url) } + } label: { + Label("Open", systemImage: "safari") + } + Divider() + Button { + Task { await viewModel.archive(bookmark) } + } label: { + Label("Archive", systemImage: "archivebox") + } + Button(role: .destructive) { + Task { await viewModel.delete(bookmark) } + } label: { + Label("Delete", systemImage: "trash") + } } } @@ -43,7 +73,7 @@ struct BookmarksView: View { } } .listStyle(.plain) - .navigationTitle("Bookmarks") + .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -70,6 +100,13 @@ struct BookmarksView: View { Button { showAddBookmark = true } label: { Image(systemName: "plus") } + Button { + Task { await viewModel.toggleUnreadFilter() } + } label: { + Image(systemName: viewModel.unreadFilter + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } Button { showSettings = true } label: { Image(systemName: "gearshape") } @@ -99,6 +136,9 @@ struct BookmarksView: View { .sheet(isPresented: $showAddBookmark) { AddBookmarkView(viewModel: viewModel) } + .sheet(item: $editingBookmark) { bookmark in + EditBookmarkView(viewModel: viewModel, bookmark: bookmark) + } .refreshable { await viewModel.load() } diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 4803c63..5caa8f3 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -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? + 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) { diff --git a/Marks/Views/EditBookmarkView.swift b/Marks/Views/EditBookmarkView.swift new file mode 100644 index 0000000..9ae6404 --- /dev/null +++ b/Marks/Views/EditBookmarkView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct EditBookmarkView: View { + @Bindable var viewModel: BookmarksViewModel + let bookmark: Bookmark + @Environment(\.dismiss) private var dismiss + + @State private var url: String + @State private var title: String + @State private var description: String + @State private var tagsText: String + @State private var unread: Bool + @State private var isSaving = false + @State private var error: String? + + init(viewModel: BookmarksViewModel, bookmark: Bookmark) { + self.viewModel = viewModel + self.bookmark = bookmark + _url = State(initialValue: bookmark.url) + _title = State(initialValue: bookmark.title) + _description = State(initialValue: bookmark.description) + _tagsText = State(initialValue: bookmark.tagNames.joined(separator: ", ")) + _unread = State(initialValue: bookmark.unread) + } + + var body: some View { + NavigationStack { + Form { + Section("URL") { + TextField("https://", text: $url) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section("Title") { + TextField("Optional", text: $title) + } + + Section("Description") { + TextField("Optional", text: $description, axis: .vertical) + .lineLimit(3...6) + } + + Section { + TextField("comma separated", text: $tagsText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Tags") + } footer: { + Text("Separate tags with commas") + } + + Section { + Toggle("Mark as unread", isOn: $unread) + } + + if let error { + Section { + Text(error) + .foregroundStyle(.red) + .font(.footnote) + } + } + } + .navigationTitle("Edit Bookmark") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) + .overlay { + if isSaving { ProgressView().scaleEffect(0.7) } + } + } + } + } + } + + private func save() { + let urlString = url.normalizedURL + guard URL(string: urlString) != nil else { + error = "Invalid URL" + return + } + let tags = tagsText.parsedTags + var updated = bookmark + updated.url = urlString + updated.title = title.trimmingCharacters(in: .whitespaces) + updated.description = description.trimmingCharacters(in: .whitespaces) + updated.tagNames = tags + updated.unread = unread + isSaving = true + error = nil + Task { + do { + try await viewModel.updateBookmark(updated) + dismiss() + } catch { + self.error = error.localizedDescription + isSaving = false + } + } + } +} diff --git a/Marks/Views/OnboardingView.swift b/Marks/Views/OnboardingView.swift index 2b19ff0..47688d5 100644 --- a/Marks/Views/OnboardingView.swift +++ b/Marks/Views/OnboardingView.swift @@ -125,10 +125,7 @@ struct OnboardingView: View { } private func parseConfig() throws -> ServerConfig { - var raw = urlText.trimmingCharacters(in: .whitespacesAndNewlines) - if !raw.hasPrefix("http://") && !raw.hasPrefix("https://") { - raw = "https://" + raw - } + let raw = urlText.normalizedURL guard let comps = URLComponents(string: raw), let host = comps.host, !host.isEmpty else { throw URLError(.badURL)