Files
linkding-ios/Marks/Services/LinkdingAPI.swift
Krishna Kumar e82e69c5f3
All checks were successful
CI / build-and-deploy (push) Successful in 20s
feat(share): dup-aware interactive save card with AI tag suggestions
Replace the fire-and-forget share toast with an interactive SwiftUI card.
On open it checks linkding's /api/bookmarks/check/ so an already-saved URL
routes to an Update (no more silent duplicates) and a new URL shows a save
form with title, tags, notes, and a read-later toggle.

Tag suggestions are hybrid: fetch the user's existing tag vocabulary
(/api/tags/) and AI tag ideas (/v1/marks/enrich via anonymous device auth),
auto-fill known-vocabulary matches into the field and surface net-new ideas
as tappable chips. Best-effort and non-blocking — failures never block save.

Also:
- Refresh bookmarks when the app returns to the foreground (scenePhase).
- Render bookmark dates as a static relative label instead of the live
  ticking RelativeDateTime timer.
- Share LinkdingAPI/Bookmark/ServerConfig/MarksAuth into the ShareExtension
  target via project.yml (durable across xcodegen regen) and regenerate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:36:46 -05:00

147 lines
6.7 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)
}
/// Checks whether `url` is already bookmarked. Returns the existing bookmark
/// (or nil), scraped metadata, and suggested tags for a fresh save.
func checkBookmark(url urlString: String) async throws -> BookmarkCheck {
let url = try makeUrl(path: "/api/bookmarks/check/", queryItems: [.init(name: "url", value: urlString)])
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(BookmarkCheck.self, from: data)
}
/// All tag names the user already uses the existing vocabulary we bias
/// AI tag suggestions toward.
func fetchTags(limit: Int = 1000) async throws -> [String] {
let url = try makeUrl(path: "/api/tags/", queryItems: [.init(name: "limit", value: "\(limit)")])
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(TagResponse.self, from: data).results.map(\.name)
}
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)
}
}
}