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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user