feat(share): dup-aware interactive save card with AI tag suggestions
All checks were successful
CI / build-and-deploy (push) Successful in 20s
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:
307
ShareExtension/ShareView.swift
Normal file
307
ShareExtension/ShareView.swift
Normal file
@@ -0,0 +1,307 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user