Files
linkding-ios/ShareExtension/ShareView.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

308 lines
11 KiB
Swift

import SwiftUI
/// Interactive save card shown by the share extension. On open it checks whether
/// the URL is already bookmarked, then lets the user set/edit tags, notes, and a
/// read-later flag before saving (new) or updating (existing) never a duplicate.
struct ShareView: View {
let url: String
/// Called when the extension should dismiss. `saved` is true after a successful
/// write, false on cancel.
let onClose: (_ saved: Bool) -> Void
@State private var phase: Phase = .loading
@State private var existing: Bookmark?
@State private var title = ""
@State private var tagText = ""
@State private var notes = ""
@State private var readLater = false
@State private var suggestedTags: [String] = [] // linkding auto_tags
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
@State private var suggesting = false
@State private var errorMessage = ""
@FocusState private var tagsFocused: Bool
private let api: LinkdingAPI?
init(url: String, onClose: @escaping (_ saved: Bool) -> Void) {
self.url = url
self.onClose = onClose
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
}
private enum Phase { case loading, editing, saving, done, failed }
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
VStack {
Spacer()
card
.frame(maxWidth: 460)
.padding(.horizontal, 20)
Spacer()
}
.task { await check() }
}
@ViewBuilder
private var card: some View {
VStack(alignment: .leading, spacing: 16) {
switch phase {
case .loading:
HStack(spacing: 12) {
ProgressView()
Text("Checking…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
case .editing, .saving:
header
editor
actions
case .done:
statusCard(icon: "checkmark.circle.fill", tint: .green,
text: existing != nil ? "Updated" : "Saved")
case .failed:
statusCard(icon: "xmark.circle.fill", tint: .red,
text: errorMessage.isEmpty ? "Something went wrong" : errorMessage)
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
}
// MARK: - Sections
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
if let existing {
Label {
Text(existing.dateAdded == .distantPast
? "Already saved"
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
.font(.subheadline.weight(.semibold))
} else {
Text("Save bookmark").font(.headline)
}
Text(title.isEmpty ? url : title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
Text(domain)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var editor: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
TextField("Tags (space separated)", text: $tagText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($tagsFocused)
.textFieldStyle(.roundedBorder)
if suggesting && aiSuggestions.isEmpty {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Suggesting tags…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !suggestionChips.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(suggestionChips, id: \.tag) { item in
Button { addTag(item.tag) } label: {
Text(item.isAI ? "\(item.tag)" : "+ \(item.tag)")
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
.foregroundStyle(item.isAI ? Color.purple : .blue)
}
.buttonStyle(.plain)
}
}
}
}
}
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(1...3)
.textFieldStyle(.roundedBorder)
Toggle("Read later", isOn: $readLater)
.font(.subheadline)
}
}
private var actions: some View {
HStack(spacing: 12) {
Button("Cancel") { onClose(false) }
.buttonStyle(.bordered)
.frame(maxWidth: .infinity)
Button {
Task { await commit() }
} label: {
Group {
if phase == .saving { ProgressView().tint(.white) }
else { Text(existing != nil ? "Update" : "Save") }
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(phase == .saving)
}
.padding(.top, 2)
}
private func statusCard(icon: String, tint: Color, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title3).foregroundStyle(tint)
Text(text).font(.headline)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
}
// MARK: - Derived
private var domain: String { URL(string: url)?.host ?? url }
/// Tappable suggestion chips: net-new AI ideas () followed by linkding's
/// auto_tags (+), excluding anything already in the field, de-duplicated.
private var suggestionChips: [(tag: String, isAI: Bool)] {
let current = Set(parsedTags.map { $0.lowercased() })
var seen = Set<String>()
var out: [(tag: String, isAI: Bool)] = []
for tag in aiSuggestions + suggestedTags {
let key = tag.lowercased()
let isAI = aiSuggestions.contains { $0.lowercased() == key }
if !current.contains(key) && seen.insert(key).inserted {
out.append((tag, isAI))
}
}
return out
}
private var parsedTags: [String] {
tagText.split(whereSeparator: { $0 == " " || $0 == "," })
.map { String($0) }
.filter { !$0.isEmpty }
}
private func addTag(_ tag: String) {
let trimmed = tagText.trimmingCharacters(in: .whitespaces)
tagText = trimmed.isEmpty ? tag : trimmed + " " + tag
tagText += " "
}
// MARK: - Networking
private func check() async {
guard let api else {
errorMessage = "Open Marks first"
phase = .failed
return
}
do {
let result = try await api.checkBookmark(url: url)
if let bookmark = result.bookmark {
existing = bookmark
title = bookmark.displayTitle
tagText = bookmark.tagNames.joined(separator: " ")
notes = bookmark.description
readLater = bookmark.unread
} else {
title = result.metadata?.title ?? ""
suggestedTags = result.autoTags ?? []
}
phase = .editing
await loadSuggestions()
} catch {
errorMessage = error.localizedDescription
phase = .failed
}
}
/// Fetches the user's existing tag vocabulary and AI tag ideas in parallel,
/// then applies the hybrid behavior: auto-fill suggestions that match the
/// existing vocabulary (new bookmarks only), surface the rest as chips.
/// Best-effort never throws, never blocks saving.
private func loadSuggestions() async {
guard let api else { return }
suggesting = true
defer { suggesting = false }
async let vocabTask = api.fetchTags()
let ai = await TagSuggester.suggest(url: url, title: title)
let vocab = (try? await vocabTask) ?? []
knownTags = Set(vocab.map { $0.lowercased() })
if existing == nil {
// Auto-fill AI tags the user already uses; high confidence, low surprise.
let current = Set(parsedTags.map { $0.lowercased() })
for tag in ai where knownTags.contains(tag.lowercased()) && !current.contains(tag.lowercased()) {
addTag(tag)
}
}
// Everything not in the vocabulary becomes a tappable chip.
aiSuggestions = ai.filter { !knownTags.contains($0.lowercased()) }
}
private func commit() async {
guard let api else { return }
phase = .saving
do {
if let existing {
let update = BookmarkUpdate(
url: existing.url,
title: existing.title,
description: notes,
tagNames: parsedTags,
unread: readLater,
shared: existing.shared
)
_ = try await api.updateBookmark(id: existing.id, update: update)
} else {
let create = BookmarkCreate(
url: url,
title: "",
tagNames: parsedTags,
isArchived: false,
unread: readLater,
shared: false
)
_ = try await api.createBookmark(create)
}
phase = .done
try? await Task.sleep(nanoseconds: 700_000_000)
onClose(true)
} catch {
errorMessage = error.localizedDescription
phase = .failed
try? await Task.sleep(nanoseconds: 1_600_000_000)
onClose(false)
}
}
}