- Edit bookmark: full PATCH via swipe action or long-press context menu; EditBookmarkView covers URL, title, description, tags, and unread toggle - Offline cache: bookmarks persisted to ApplicationSupport JSON and shown instantly on launch; cache is per-server, skipped when unread filter is on - Unread filter: toolbar toggle sends ?unread=true to the API; title updates to "Unread"; no stale-cache flash when toggling - Extract normalizedURL and parsedTags into String+Helpers, removing three copies of URL normalization and two of tag parsing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
125 lines
5.5 KiB
Swift
125 lines
5.5 KiB
Swift
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, unread: Bool = false) 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)) }
|
|
if unread { items.append(.init(name: "unread", value: "true")) }
|
|
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, update: BookmarkUpdate) async throws -> Bookmark {
|
|
let body = try JSONEncoder().encode(update)
|
|
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)
|
|
}
|
|
}
|
|
}
|