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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
14
Marks/Models/String+Helpers.swift
Normal file
14
Marks/Models/String+Helpers.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
109
Marks/Views/EditBookmarkView.swift
Normal file
109
Marks/Views/EditBookmarkView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user