Replace Flutter app with native SwiftUI rewrite (iOS 26)
Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart codebase entirely in favour of a lean native app with no third-party dependencies. Features shipped: - Bookmark list with pagination, pull-to-refresh, swipe delete/archive - Add bookmark form + iOS share extension (zero-tap save from any app) - Tags tab — all tags sorted by count, tap to browse filtered bookmarks - Native search tab (Tab role: .search) with instant client-side filtering - AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary and tag generation, semantic search, smart collections - Settings: linkding server config, OpenRouter API key - App Groups for credential sharing between main app and share extension - Swift 6 strict concurrency throughout (@Observable, @MainActor) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
220
Marks/Views/BookmarksViewModel.swift
Normal file
220
Marks/Views/BookmarksViewModel.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class BookmarksViewModel {
|
||||
var bookmarks: [Bookmark] = []
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var error: String?
|
||||
var searchQuery = ""
|
||||
var nextPageUrl: String?
|
||||
var smartCollections: [SmartCollection] = []
|
||||
var isGeneratingCollections = false
|
||||
var enrichmentProgress: Double = 0
|
||||
|
||||
private let api: LinkdingAPI
|
||||
private(set) var claude: ClaudeService?
|
||||
private var enrichTask: Task<Void, Never>?
|
||||
|
||||
init(api: LinkdingAPI, claude: ClaudeService?) {
|
||||
self.api = api
|
||||
self.claude = claude
|
||||
}
|
||||
|
||||
func updateClaude(_ service: ClaudeService?) {
|
||||
claude = service
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await api.fetchBookmarks(search: searchQuery)
|
||||
bookmarks = response.results
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
startEnrichment()
|
||||
}
|
||||
|
||||
func loadMore() async {
|
||||
guard let next = nextPageUrl, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
do {
|
||||
let response = try await api.fetchBookmarksFromUrl(next)
|
||||
bookmarks.append(contentsOf: response.results)
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
func search() async {
|
||||
isLoading = true
|
||||
do {
|
||||
let response = try await api.fetchBookmarks(search: searchQuery)
|
||||
bookmarks = response.results
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = 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)
|
||||
var all = allResponse.results
|
||||
// Restore AI data so summaries are available for ranking
|
||||
restoreAIData(into: &all)
|
||||
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
|
||||
nextPageUrl = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func addBookmark(url: String, title: String, tags: [String]) async throws {
|
||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
bookmarks.insert(bookmark, at: 0)
|
||||
print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")")
|
||||
guard let claude else { return }
|
||||
Task {
|
||||
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
|
||||
do {
|
||||
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
|
||||
print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)")
|
||||
guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else {
|
||||
print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich")
|
||||
return
|
||||
}
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = aiTags
|
||||
saveAIData(for: bookmarks[i])
|
||||
} catch {
|
||||
print("[AI] addBookmark: enrich FAILED \(error)")
|
||||
self.error = "AI enrichment failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ bookmark: Bookmark) async {
|
||||
do {
|
||||
try await api.deleteBookmark(id: bookmark.id)
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func archive(_ bookmark: Bookmark) async {
|
||||
do {
|
||||
try await api.archiveBookmark(id: bookmark.id)
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func generateSmartCollections() async {
|
||||
guard let claude, !bookmarks.isEmpty else { return }
|
||||
isGeneratingCollections = true
|
||||
do {
|
||||
let allResponse = try await api.fetchBookmarks(limit: 200)
|
||||
smartCollections = try await claude.generateCollections(from: allResponse.results)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isGeneratingCollections = false
|
||||
}
|
||||
|
||||
func enrichAll() async {
|
||||
guard let claude else { return }
|
||||
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
||||
guard !toEnrich.isEmpty else { return }
|
||||
enrichmentProgress = 0
|
||||
for (done, i) in toEnrich.enumerated() {
|
||||
do {
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i])
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
enrichmentProgress = 0
|
||||
}
|
||||
|
||||
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
||||
private func startEnrichment() {
|
||||
guard claude != nil else {
|
||||
print("[AI] startEnrichment: skipped (no claude)")
|
||||
return
|
||||
}
|
||||
enrichTask?.cancel()
|
||||
enrichTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
|
||||
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
|
||||
for i in indices {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let claude = await MainActor.run(body: { self.claude }) else { return }
|
||||
do {
|
||||
let bm = await MainActor.run { bookmarks[i] }
|
||||
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))")
|
||||
await MainActor.run {
|
||||
guard i < bookmarks.count else { return }
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
}
|
||||
} catch {
|
||||
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: AI persistence
|
||||
|
||||
private func saveAIData(for bookmark: Bookmark) {
|
||||
var store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
|
||||
store["\(bookmark.id)"] = [
|
||||
"summary": bookmark.aiSummary ?? "",
|
||||
"tags": bookmark.aiTags ?? []
|
||||
]
|
||||
UserDefaults.standard.set(store, forKey: "aiData")
|
||||
}
|
||||
|
||||
private func restoreAIData() {
|
||||
restoreAIData(into: &bookmarks)
|
||||
}
|
||||
|
||||
private func restoreAIData(into list: inout [Bookmark]) {
|
||||
let store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
|
||||
for i in list.indices {
|
||||
if let d = store["\(list[i].id)"] {
|
||||
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
list[i].aiTags = d["tags"] as? [String]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user