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:
Krishna Kumar
2026-05-21 08:33:01 -05:00
parent cd85f9c67f
commit b7ef5c2215
306 changed files with 2337 additions and 17949 deletions

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

View 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)
}
}
}