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 let initialTitle: String? let initialNotes: 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 createPodcast = false @State private var suggestedTags: [String] = [] // linkding auto_tags @State private var knownTags: Set = [] // 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, initialTitle: String? = nil, initialNotes: String = "", onClose: @escaping (_ saved: Bool) -> Void) { self.url = url self.initialTitle = initialTitle self.initialNotes = initialNotes self.onClose = onClose self.api = ServerConfig.loadShared().map(LinkdingAPI.init) _title = State(initialValue: initialTitle ?? "") _notes = State(initialValue: initialNotes) } 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) Toggle(isOn: $createPodcast) { Label("Create podcast", systemImage: "headphones") } .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() 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 parsed = URL(string: url), let scheme = parsed.scheme?.lowercased(), scheme == "http" || scheme == "https", parsed.host?.isEmpty == false else { errorMessage = "No bookmarkable link found" phase = .failed return } 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 = initialTitle ?? result.metadata?.title ?? "" suggestedTags = result.autoTags ?? [] if notes.isEmpty { notes = result.metadata?.description ?? initialNotes } } // Pre-enable the toggle when the current tags match the auto-rule. createPodcast = PodcastRequests.matchesAutoTag(parsedTags) 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: "", description: notes, tagNames: parsedTags, isArchived: false, unread: readLater, shared: false ) _ = try await api.createBookmark(create) } // Hand off podcast generation to the main app (the extension is too // short-lived to generate audio itself). if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) { PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title) } 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) } } }