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

@@ -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 = "<group>"; };
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
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 = "<group>"; };
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
@@ -95,6 +99,7 @@
children = (
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
07C21567B95F5069BA946252 /* ServerConfig.swift */,
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -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;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 }
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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()
}

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) {

View File

@@ -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
}
}
}
}

View File

@@ -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)