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:
BIN
Marks/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Marks/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
14
Marks/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
14
Marks/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Marks/Assets.xcassets/Contents.json
Normal file
6
Marks/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
28
Marks/Info.plist
Normal file
28
Marks/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Marks</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
10
Marks/Marks.entitlements
Normal file
10
Marks/Marks.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.magicive.marks</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
52
Marks/MarksApp.swift
Normal file
52
Marks/MarksApp.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MarksApp: App {
|
||||
@State private var serverConfig: ServerConfig? = ServerConfig.load()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if let config = serverConfig {
|
||||
MainContainer(config: config) {
|
||||
config.delete()
|
||||
serverConfig = nil
|
||||
}
|
||||
} else {
|
||||
OnboardingView { newConfig in
|
||||
newConfig.save()
|
||||
serverConfig = newConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Holds the API + ViewModel so they survive re-renders
|
||||
struct MainContainer: View {
|
||||
let config: ServerConfig
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
|
||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||
self.config = config
|
||||
self.onDisconnect = onDisconnect
|
||||
let api = LinkdingAPI(config: config)
|
||||
let claude = ClaudeService.load()
|
||||
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Bookmarks", systemImage: "bookmark") {
|
||||
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
}
|
||||
Tab("Tags", systemImage: "tag") {
|
||||
TagsView(viewModel: viewModel)
|
||||
}
|
||||
Tab(role: .search) {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
Marks/Models/Bookmark.swift
Normal file
73
Marks/Models/Bookmark.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
struct Bookmark: Codable, Identifiable, Sendable, Hashable {
|
||||
let id: Int
|
||||
var url: String
|
||||
var title: String
|
||||
var description: String
|
||||
var tagNames: [String]
|
||||
var dateAdded: Date
|
||||
var dateModified: Date
|
||||
var isArchived: Bool
|
||||
var unread: Bool
|
||||
var shared: Bool
|
||||
var websiteTitle: String?
|
||||
var websiteDescription: String?
|
||||
var faviconUrl: String?
|
||||
var previewImageUrl: String?
|
||||
|
||||
// AI-enriched fields stored locally
|
||||
var aiSummary: String?
|
||||
var aiTags: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, url, title, description, unread, shared
|
||||
case tagNames = "tag_names"
|
||||
case dateAdded = "date_added"
|
||||
case dateModified = "date_modified"
|
||||
case isArchived = "is_archived"
|
||||
case websiteTitle = "website_title"
|
||||
case websiteDescription = "website_description"
|
||||
case faviconUrl = "favicon_url"
|
||||
case previewImageUrl = "preview_image_url"
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
if !title.isEmpty { return title }
|
||||
if let t = websiteTitle, !t.isEmpty { return t }
|
||||
return url
|
||||
}
|
||||
|
||||
var domain: String {
|
||||
URL(string: url)?.host ?? url
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkResponse: Codable, Sendable {
|
||||
let count: Int
|
||||
let next: String?
|
||||
let previous: String?
|
||||
let results: [Bookmark]
|
||||
}
|
||||
|
||||
struct BookmarkCreate: Codable, Sendable {
|
||||
let url: String
|
||||
let title: String
|
||||
let tagNames: [String]
|
||||
let isArchived: Bool
|
||||
let unread: Bool
|
||||
let shared: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url, title, unread, shared
|
||||
case tagNames = "tag_names"
|
||||
case isArchived = "is_archived"
|
||||
}
|
||||
}
|
||||
|
||||
struct SmartCollection: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let description: String
|
||||
let bookmarkIds: [Int]
|
||||
}
|
||||
38
Marks/Models/ServerConfig.swift
Normal file
38
Marks/Models/ServerConfig.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
struct ServerConfig: Codable, Sendable {
|
||||
var host: String
|
||||
var port: Int?
|
||||
var path: String
|
||||
var token: String
|
||||
var useHttps: Bool
|
||||
|
||||
var baseUrl: String {
|
||||
var url = useHttps ? "https://" : "http://"
|
||||
url += host
|
||||
if let port { url += ":\(port)" }
|
||||
if !path.isEmpty { url += path }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerConfig {
|
||||
private static let key = "serverConfig"
|
||||
private static let appGroupId = "group.com.magicive.marks"
|
||||
|
||||
static func load() -> ServerConfig? {
|
||||
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
|
||||
return try? JSONDecoder().decode(ServerConfig.self, from: data)
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return }
|
||||
UserDefaults.standard.set(data, forKey: ServerConfig.key)
|
||||
UserDefaults(suiteName: ServerConfig.appGroupId)?.set(data, forKey: ServerConfig.key)
|
||||
}
|
||||
|
||||
func delete() {
|
||||
UserDefaults.standard.removeObject(forKey: ServerConfig.key)
|
||||
UserDefaults(suiteName: ServerConfig.appGroupId)?.removeObject(forKey: ServerConfig.key)
|
||||
}
|
||||
}
|
||||
147
Marks/Services/ClaudeService.swift
Normal file
147
Marks/Services/ClaudeService.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
|
||||
struct ClaudeService: Sendable {
|
||||
let apiKey: String
|
||||
static let defaultsKey = "openRouterApiKey"
|
||||
// Cheap, fast model — change to any OpenRouter model slug
|
||||
private static let model = "google/gemini-2.0-flash-lite-001"
|
||||
|
||||
static func load() -> ClaudeService? {
|
||||
guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil }
|
||||
return ClaudeService(apiKey: key)
|
||||
}
|
||||
|
||||
static func save(apiKey: String) {
|
||||
UserDefaults.standard.set(apiKey, forKey: defaultsKey)
|
||||
}
|
||||
|
||||
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
|
||||
let prompt = """
|
||||
Analyze this bookmark:
|
||||
URL: \(bookmark.url)
|
||||
Title: \(bookmark.displayTitle)
|
||||
Existing tags: \(bookmark.tagNames.joined(separator: ", "))
|
||||
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
{"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]}
|
||||
|
||||
Rules: summary under 120 chars, 3-5 tags, lowercase, no # symbol, no existing tags duplicated.
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
||||
return parseEnrichResponse(text)
|
||||
}
|
||||
|
||||
func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] {
|
||||
guard !bookmarks.isEmpty else { return [] }
|
||||
let list = bookmarks.enumerated().map { i, b in
|
||||
"[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")"
|
||||
}.joined(separator: "\n")
|
||||
let prompt = """
|
||||
Search query: "\(query)"
|
||||
|
||||
Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7]
|
||||
Return [] if nothing matches.
|
||||
|
||||
Bookmarks:
|
||||
\(list)
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
||||
let indices = parseIntArray(text)
|
||||
return indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
||||
}
|
||||
|
||||
func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] {
|
||||
guard !bookmarks.isEmpty else { return [] }
|
||||
let list = bookmarks.prefix(150).enumerated().map { i, b in
|
||||
"[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]"
|
||||
}.joined(separator: "\n")
|
||||
let prompt = """
|
||||
Group these bookmarks into 3-6 meaningful themed collections.
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
[{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}]
|
||||
|
||||
Bookmarks:
|
||||
\(list)
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 1024)
|
||||
return parseCollections(text, bookmarks: Array(bookmarks.prefix(150)))
|
||||
}
|
||||
|
||||
private func callModel(prompt: String, maxTokens: Int) async throws -> String {
|
||||
let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("https://marks.app", forHTTPHeaderField: "HTTP-Referer")
|
||||
request.setValue("Marks", forHTTPHeaderField: "X-Title")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": Self.model,
|
||||
"max_tokens": maxTokens,
|
||||
"messages": [["role": "user", "content": prompt]]
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
print("[AI] callModel: status=\(status) bytes=\(data.count)")
|
||||
guard status == 200 else {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
print("[AI] callModel: error body=\(body.prefix(200))")
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
|
||||
struct Choice: Codable {
|
||||
struct Message: Codable { let content: String }
|
||||
let message: Message
|
||||
}
|
||||
struct Response: Codable { let choices: [Choice] }
|
||||
let content = (try JSONDecoder().decode(Response.self, from: data)).choices.first?.message.content ?? ""
|
||||
print("[AI] callModel: response=\(content.prefix(100))")
|
||||
return content
|
||||
}
|
||||
|
||||
private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let summary = json["summary"] as? String,
|
||||
let tags = json["tags"] as? [String] else { return (text, []) }
|
||||
return (summary, tags)
|
||||
}
|
||||
|
||||
private func parseIntArray(_ text: String) -> [Int] {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return [] }
|
||||
return arr
|
||||
}
|
||||
|
||||
private func parseCollections(_ text: String, bookmarks: [Bookmark]) -> [SmartCollection] {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
||||
return arr.compactMap { dict in
|
||||
guard let name = dict["name"] as? String,
|
||||
let indices = dict["indices"] as? [Int] else { return nil }
|
||||
let desc = dict["description"] as? String ?? ""
|
||||
let ids = indices.compactMap { i -> Int? in i < bookmarks.count ? bookmarks[i].id : nil }
|
||||
return SmartCollection(name: name, description: desc, bookmarkIds: ids)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractJSON(from text: String) -> String {
|
||||
if let start = text.range(of: "```json\n"), let end = text.range(of: "\n```") {
|
||||
return String(text[start.upperBound..<end.lowerBound])
|
||||
}
|
||||
if let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") {
|
||||
return String(text[start...end])
|
||||
}
|
||||
if let start = text.firstIndex(of: "["), let end = text.lastIndex(of: "]") {
|
||||
return String(text[start...end])
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
123
Marks/Services/LinkdingAPI.swift
Normal file
123
Marks/Services/LinkdingAPI.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: Error, LocalizedError {
|
||||
case badStatus(Int)
|
||||
case invalidUrl
|
||||
case noData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code): "Server returned \(code)"
|
||||
case .invalidUrl: "Invalid server URL"
|
||||
case .noData: "No data received"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor LinkdingAPI {
|
||||
private let config: ServerConfig
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(config: ServerConfig) {
|
||||
self.config = config
|
||||
self.session = URLSession.shared
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .custom { decoder in
|
||||
let s = try decoder.singleValueContainer().decode(String.self)
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = f.date(from: s) { return date }
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
if let date = f.date(from: s) { return date }
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(s)"))
|
||||
}
|
||||
self.decoder = d
|
||||
}
|
||||
|
||||
private func makeUrl(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = config.useHttps ? "https" : "http"
|
||||
components.host = config.host
|
||||
components.port = config.port
|
||||
components.path = config.path.isEmpty ? path : config.path + path
|
||||
if !queryItems.isEmpty { components.queryItems = queryItems }
|
||||
guard let url = components.url else { throw APIError.invalidUrl }
|
||||
return url
|
||||
}
|
||||
|
||||
private func authorizedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("Token \(config.token)", forHTTPHeaderField: "Authorization")
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50) 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)) }
|
||||
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 {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
return try decoder.decode(BookmarkResponse.self, from: data)
|
||||
}
|
||||
|
||||
func fetchBookmarksFromUrl(_ urlString: String) async throws -> BookmarkResponse {
|
||||
guard let url = URL(string: urlString) else { throw APIError.invalidUrl }
|
||||
let (data, _) = try await session.data(for: authorizedRequest(url: url))
|
||||
return try decoder.decode(BookmarkResponse.self, from: data)
|
||||
}
|
||||
|
||||
func createBookmark(_ create: BookmarkCreate) async throws -> Bookmark {
|
||||
let body = try JSONEncoder().encode(create)
|
||||
let url = try makeUrl(path: "/api/bookmarks/")
|
||||
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "POST", body: body))
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 201 else {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
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])
|
||||
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 {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
return try decoder.decode(Bookmark.self, from: data)
|
||||
}
|
||||
|
||||
func deleteBookmark(id: Int) async throws {
|
||||
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
||||
let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "DELETE"))
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 204 else {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
func archiveBookmark(id: Int) async throws {
|
||||
let url = try makeUrl(path: "/api/bookmarks/\(id)/archive/")
|
||||
let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "POST"))
|
||||
guard let code = (response as? HTTPURLResponse)?.statusCode, code == 200 || code == 204 else {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyConnection() async throws {
|
||||
let url = try makeUrl(path: "/api/bookmarks/", queryItems: [.init(name: "limit", value: "1")])
|
||||
let (_, response) = try await session.data(for: authorizedRequest(url: url))
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
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