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:
88
Marks/Views/AddBookmarkView.swift
Normal file
88
Marks/Views/AddBookmarkView.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddBookmarkView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var url = ""
|
||||
@State private var title = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var isSaving = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("URL")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $title)
|
||||
} header: {
|
||||
Text("Title")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add 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() {
|
||||
var urlString = url.trimmingCharacters(in: .whitespaces)
|
||||
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
|
||||
urlString = "https://" + urlString
|
||||
}
|
||||
guard URL(string: urlString) != nil else {
|
||||
error = "Invalid URL"
|
||||
return
|
||||
}
|
||||
let tags = tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
isSaving = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
Marks/Views/BookmarkRow.swift
Normal file
93
Marks/Views/BookmarkRow.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarkRow: View {
|
||||
let bookmark: Bookmark
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
FaviconView(url: bookmark.faviconUrl)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.displayTitle)
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(bookmark.domain)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = bookmark.aiSummary {
|
||||
Text(summary)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
|
||||
if !effectiveTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(effectiveTags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var effectiveTags: [String] {
|
||||
let base = bookmark.tagNames
|
||||
let ai = bookmark.aiTags ?? []
|
||||
let extra = ai.filter { !base.contains($0) }.prefix(3)
|
||||
return (base + extra).prefix(6).map { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct FaviconView: View {
|
||||
let url: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let urlString = url, let faviconUrl = URL(string: urlString) {
|
||||
AsyncImage(url: faviconUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFit()
|
||||
default:
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay {
|
||||
Image(systemName: "bookmark")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(Color(.systemGray2))
|
||||
}
|
||||
}
|
||||
}
|
||||
142
Marks/Views/BookmarksView.swift
Normal file
142
Marks/Views/BookmarksView.swift
Normal file
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarksView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showSettings = false
|
||||
@State private var showCollections = false
|
||||
@State private var showAddBookmark = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if viewModel.claude != nil {
|
||||
Menu {
|
||||
Button {
|
||||
Task { await viewModel.generateSmartCollections() }
|
||||
showCollections = true
|
||||
} label: {
|
||||
Label("Smart Collections", systemImage: "sparkles")
|
||||
}
|
||||
Button {
|
||||
Task { await viewModel.enrichAll() }
|
||||
} label: {
|
||||
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
Button { showAddBookmark = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ProgressView()
|
||||
}
|
||||
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if viewModel.enrichmentProgress > 0 {
|
||||
enrichmentBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
}
|
||||
.sheet(isPresented: $showCollections) {
|
||||
CollectionsView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $showAddBookmark) {
|
||||
AddBookmarkView(viewModel: viewModel)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.task {
|
||||
if viewModel.bookmarks.isEmpty {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { viewModel.error != nil },
|
||||
set: { if !$0 { viewModel.error = nil } }
|
||||
)) {
|
||||
Button("OK") { viewModel.error = nil }
|
||||
} message: {
|
||||
Text(viewModel.error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var enrichmentBanner: some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView(value: viewModel.enrichmentProgress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.primary)
|
||||
Text("Adding AI summaries…")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||
Task { await viewModel.loadMore() }
|
||||
}
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
Marks/Views/CollectionsView.swift
Normal file
66
Marks/Views/CollectionsView.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CollectionsView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isGeneratingCollections {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Building smart collections…")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.smartCollections.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Collections Yet",
|
||||
systemImage: "sparkles",
|
||||
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.smartCollections) { collection in
|
||||
Section {
|
||||
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
|
||||
ForEach(items) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
}
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(collection.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !collection.description.isEmpty {
|
||||
Text(collection.description)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Smart Collections")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
Task { await viewModel.generateSmartCollections() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.disabled(viewModel.isGeneratingCollections)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
144
Marks/Views/OnboardingView.swift
Normal file
144
Marks/Views/OnboardingView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
let onConnect: (ServerConfig) -> Void
|
||||
|
||||
@State private var urlText = ""
|
||||
@State private var token = ""
|
||||
@State private var isConnecting = false
|
||||
@State private var errorMessage: String?
|
||||
@FocusState private var focused: Field?
|
||||
|
||||
enum Field { case url, token }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Marks")
|
||||
.font(.system(size: 42, weight: .semibold, design: .rounded))
|
||||
Text("Your bookmarks, beautifully.")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 48)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Server URL")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
TextField("https://links.example.com", text: $urlText)
|
||||
.textContentType(.URL)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: .url)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focused = .token }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("API Token")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
SecureField("Paste your token", text: $token)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: .token)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { Task { await connect() } }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
Text("Find your token at Settings → API Token in Linkding.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if let err = errorMessage {
|
||||
Text(err)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.red)
|
||||
.padding(.top, 12)
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await connect() }
|
||||
} label: {
|
||||
Group {
|
||||
if isConnecting {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Connect")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(canConnect ? Color.primary : Color(.systemGray4))
|
||||
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!canConnect || isConnecting)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var canConnect: Bool { !urlText.isEmpty && !token.isEmpty }
|
||||
|
||||
private func connect() async {
|
||||
focused = nil
|
||||
isConnecting = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let config = try parseConfig()
|
||||
let api = LinkdingAPI(config: config)
|
||||
try await api.verifyConnection()
|
||||
onConnect(config)
|
||||
} catch APIError.badStatus(401) {
|
||||
errorMessage = "Invalid token. Check your API token in Linkding settings."
|
||||
} catch APIError.badStatus(let code) {
|
||||
errorMessage = "Server returned \(code). Check the URL."
|
||||
} catch {
|
||||
errorMessage = "Could not connect. Check the URL and try again."
|
||||
}
|
||||
|
||||
isConnecting = false
|
||||
}
|
||||
|
||||
private func parseConfig() throws -> ServerConfig {
|
||||
var raw = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !raw.hasPrefix("http://") && !raw.hasPrefix("https://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
guard let comps = URLComponents(string: raw),
|
||||
let host = comps.host, !host.isEmpty else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
return ServerConfig(
|
||||
host: host,
|
||||
port: comps.port,
|
||||
path: comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path,
|
||||
token: token.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
useHttps: comps.scheme == "https"
|
||||
)
|
||||
}
|
||||
}
|
||||
87
Marks/Views/SearchView.swift
Normal file
87
Marks/Views/SearchView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var useSemanticSearch = false
|
||||
@State private var semanticResults: [Bookmark]? = nil
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(results) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Search")
|
||||
.toolbar {
|
||||
if viewModel.claude != nil {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Toggle(isOn: $useSemanticSearch) {
|
||||
Label("Semantic", systemImage: "sparkles")
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if searchText.isEmpty {
|
||||
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
||||
} else if results.isEmpty && !viewModel.isLoading {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
||||
.onChange(of: searchText) { _, _ in scheduleSearch() }
|
||||
}
|
||||
|
||||
private var results: [Bookmark] {
|
||||
if let semantic = semanticResults { return semantic }
|
||||
guard !searchText.isEmpty else { return [] }
|
||||
let q = searchText.lowercased()
|
||||
return viewModel.bookmarks.filter { b in
|
||||
b.displayTitle.lowercased().contains(q) ||
|
||||
b.url.lowercased().contains(q) ||
|
||||
b.tagNames.contains { $0.lowercased().contains(q) } ||
|
||||
(b.aiTags ?? []).contains { $0.lowercased().contains(q) } ||
|
||||
(b.aiSummary ?? "").lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSearch() {
|
||||
semanticResults = nil
|
||||
searchTask?.cancel()
|
||||
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return }
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
let q = searchText
|
||||
let all = viewModel.bookmarks
|
||||
do {
|
||||
let ranked = try await claude.semanticSearch(query: q, in: all)
|
||||
await MainActor.run { semanticResults = ranked }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Marks/Views/SettingsView.swift
Normal file
58
Marks/Views/SettingsView.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.defaultsKey) ?? ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("AI Features") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("OpenRouter API Key")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField("sk-or-…", text: $claudeKey)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.onChange(of: claudeKey) { _, new in
|
||||
ClaudeService.save(apiKey: new)
|
||||
viewModel.updateClaude(new.isEmpty ? nil : ClaudeService(apiKey: new))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if viewModel.claude != nil {
|
||||
Label("AI enabled — semantic search, auto-tagging, and smart collections active.", systemImage: "sparkles")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Add an OpenRouter API key to enable AI features. Uses Gemini 2.0 Flash Lite — very cheap.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
onDisconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Marks/Views/TagsView.swift
Normal file
94
Marks/Views/TagsView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TagsView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(allTags, id: \.tag) { entry in
|
||||
NavigationLink {
|
||||
TagBookmarksView(tag: entry.tag, viewModel: viewModel)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(entry.tag)
|
||||
.font(.system(size: 17))
|
||||
Spacer()
|
||||
Text("\(entry.count)")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Tags")
|
||||
.overlay {
|
||||
if viewModel.bookmarks.isEmpty {
|
||||
ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var allTags: [(tag: String, count: Int)] {
|
||||
var counts: [String: Int] = [:]
|
||||
for bookmark in viewModel.bookmarks {
|
||||
for tag in bookmark.tagNames {
|
||||
counts[tag, default: 0] += 1
|
||||
}
|
||||
for tag in bookmark.aiTags ?? [] where !bookmark.tagNames.contains(tag) {
|
||||
counts[tag, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts.map { (tag: $0.key, count: $0.value) }
|
||||
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag }
|
||||
}
|
||||
}
|
||||
|
||||
struct TagBookmarksView: View {
|
||||
let tag: String
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(filteredBookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle(tag)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.overlay {
|
||||
if filteredBookmarks.isEmpty {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "tag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredBookmarks: [Bookmark] {
|
||||
viewModel.bookmarks.filter {
|
||||
$0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user