feat(share): dup-aware interactive save card with AI tag suggestions
All checks were successful
CI / build-and-deploy (push) Successful in 20s

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>
This commit is contained in:
Krishna Kumar
2026-06-23 20:36:46 -05:00
parent a3059f25fc
commit e82e69c5f3
10 changed files with 454 additions and 88 deletions

View File

@@ -38,6 +38,7 @@ struct MainContainer: View {
@State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared
@Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config
@@ -64,6 +65,13 @@ struct MainContainer: View {
.task { applyPendingIntent() }
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.onChange(of: scenePhase) { old, new in
// Refresh when returning to the foreground (cold launch is already
// covered by BookmarksView's .task, so only react to a real re-entry).
if new == .active && old != .active {
Task { await viewModel.load() }
}
}
.sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
}

View File

@@ -61,6 +61,24 @@ struct BookmarkResponse: Codable, Sendable {
let results: [Bookmark]
}
/// Response of linkding's `GET /api/bookmarks/check/?url=` tells us whether the
/// URL is already saved, plus scraped metadata and suggested tags for new saves.
struct BookmarkCheck: Codable, Sendable {
let bookmark: Bookmark?
let metadata: Metadata?
let autoTags: [String]?
struct Metadata: Codable, Sendable {
let title: String?
let description: String?
}
enum CodingKeys: String, CodingKey {
case bookmark, metadata
case autoTags = "auto_tags"
}
}
struct BookmarkCreate: Codable, Sendable {
let url: String
let title: String
@@ -90,6 +108,14 @@ struct BookmarkUpdate: Codable, Sendable {
}
}
struct LinkdingTag: Codable, Sendable {
let name: String
}
struct TagResponse: Codable, Sendable {
let results: [LinkdingTag]
}
struct SmartCollection: Identifiable, Sendable {
let id = UUID()
let name: String

View File

@@ -25,6 +25,13 @@ extension ServerConfig {
return try? JSONDecoder().decode(ServerConfig.self, from: data)
}
/// Loads config from the shared app group used by extensions, which have no
/// access to the main app's `UserDefaults.standard`.
static func loadShared() -> ServerConfig? {
guard let data = UserDefaults(suiteName: appGroupId)?.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)

View File

@@ -78,6 +78,28 @@ actor LinkdingAPI {
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/")

View File

@@ -8,6 +8,15 @@ struct BookmarkRow: View {
@State private var podcastTapCount = 0
@State private var podcastCached = false
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 11) {
@@ -32,7 +41,7 @@ struct BookmarkRow: View {
HStack(spacing: 4) {
Text(bookmark.domain)
Text("·")
Text(bookmark.dateAdded, style: .relative)
Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date()))
}
.font(.footnote)
.foregroundStyle(.secondary)