From e82e69c5f330d7a48ef6b5a3bbbb40bace9d92f5 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Tue, 23 Jun 2026 20:36:46 -0500 Subject: [PATCH] feat(share): dup-aware interactive save card with AI tag suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Marks.xcodeproj/project.pbxproj | 16 ++ Marks/MarksApp.swift | 8 + Marks/Models/Bookmark.swift | 26 ++ Marks/Models/ServerConfig.swift | 7 + Marks/Services/LinkdingAPI.swift | 22 ++ Marks/Views/BookmarkRow.swift | 11 +- ShareExtension/ShareView.swift | 307 +++++++++++++++++++++++ ShareExtension/ShareViewController.swift | 109 ++------ ShareExtension/TagSuggester.swift | 30 +++ project.yml | 6 + 10 files changed, 454 insertions(+), 88 deletions(-) create mode 100644 ShareExtension/ShareView.swift create mode 100644 ShareExtension/TagSuggester.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index e08027b..c183d80 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -9,8 +9,10 @@ /* Begin PBXBuildFile section */ 0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; }; + 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; + 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; @@ -21,6 +23,7 @@ 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; + 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; @@ -37,9 +40,11 @@ B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; + B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; + D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */; }; DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; }; DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; }; E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; @@ -47,6 +52,7 @@ E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; }; + F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */; }; FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7623601C25E481DF58371F2A /* AppIntentsTests.swift */; }; /* End PBXBuildFile section */ @@ -108,6 +114,7 @@ 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = ""; }; 629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = ""; }; 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = ""; }; + 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = ""; }; @@ -117,6 +124,7 @@ 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = ""; }; 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = ""; }; + 9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = ""; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = ""; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; @@ -213,7 +221,9 @@ children = ( E895C34E4D2A1C4709B25FF1 /* Info.plist */, 03D800669E343FF305468424 /* ShareExtension.entitlements */, + 9B7A85A23A13D754F6A75E4D /* ShareView.swift */, ADEAC824576633CC77370262 /* ShareViewController.swift */, + 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */, ); path = ShareExtension; sourceTree = ""; @@ -448,7 +458,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, + 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, + B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, + F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */, + 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */, 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */, + D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index e10a01c..0dea39c 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -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) } diff --git a/Marks/Models/Bookmark.swift b/Marks/Models/Bookmark.swift index e06e772..36e47c2 100644 --- a/Marks/Models/Bookmark.swift +++ b/Marks/Models/Bookmark.swift @@ -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 diff --git a/Marks/Models/ServerConfig.swift b/Marks/Models/ServerConfig.swift index f83d40d..82fdd1f 100644 --- a/Marks/Models/ServerConfig.swift +++ b/Marks/Models/ServerConfig.swift @@ -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) diff --git a/Marks/Services/LinkdingAPI.swift b/Marks/Services/LinkdingAPI.swift index 7eb468d..3f438f0 100644 --- a/Marks/Services/LinkdingAPI.swift +++ b/Marks/Services/LinkdingAPI.swift @@ -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/") diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift index 98b3c3b..7718be1 100644 --- a/Marks/Views/BookmarkRow.swift +++ b/Marks/Views/BookmarkRow.swift @@ -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) diff --git a/ShareExtension/ShareView.swift b/ShareExtension/ShareView.swift new file mode 100644 index 0000000..061d0f3 --- /dev/null +++ b/ShareExtension/ShareView.swift @@ -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 = [] // 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() + 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) + } + } +} diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index ca147a8..1d1f8b4 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -1,25 +1,23 @@ import UIKit +import SwiftUI import UniformTypeIdentifiers @objc(ShareViewController) class ShareViewController: UIViewController { - private let appGroupId = "group.com.magicive.marks" - private let configKey = "serverConfig" - override func viewDidLoad() { super.viewDidLoad() let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) blur.frame = view.bounds blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(blur) - extractURLAndSave() + extractURL() } - private func extractURLAndSave() { + private func extractURL() { guard let item = extensionContext?.inputItems.first as? NSExtensionItem, let attachments = item.attachments else { - showResult(success: false, message: "Nothing to save") + present(url: nil) return } @@ -31,99 +29,36 @@ class ShareViewController: UIViewController { let urlString = typeId == UTType.url.identifier ? ((item as? URL)?.absoluteString ?? (item as? String)) : (item as? String) - DispatchQueue.main.async { - if let url = urlString { self?.save(url: url) } - else { self?.showResult(success: false, message: "No URL found") } - } + DispatchQueue.main.async { self?.present(url: urlString) } } return true } if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) { - showResult(success: false, message: "No URL found") + present(url: nil) } } - private func save(url: String) { - guard let groupDefaults = UserDefaults(suiteName: appGroupId), - let data = groupDefaults.data(forKey: configKey), - let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let host = config["host"] as? String, - let token = config["token"] as? String, - let useHttps = config["useHttps"] as? Bool else { - showResult(success: false, message: "Open Marks first") - return + /// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card. + private func present(url: String?) { + let root = ShareView(url: url ?? "") { [weak self] _ in + self?.close() } + let host = UIHostingController(rootView: root) + host.view.backgroundColor = .clear + addChild(host) + host.view.frame = view.bounds + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(host.view) + host.didMove(toParent: self) - var base = (useHttps ? "https" : "http") + "://" + host - if let port = config["port"] as? Int { base += ":\(port)" } - if let path = config["path"] as? String, !path.isEmpty { base += path } - - guard let apiUrl = URL(string: "\(base)/api/bookmarks/") else { - showResult(success: false, message: "Bad server URL") - return + if url == nil { + // No URL to work with — let the card render its failure state briefly. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() } } - - var req = URLRequest(url: apiUrl) - req.httpMethod = "POST" - req.setValue("Token \(token)", forHTTPHeaderField: "Authorization") - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try? JSONSerialization.data(withJSONObject: [ - "url": url, "is_archived": false, "unread": false, "shared": false - ]) - - URLSession.shared.dataTask(with: req) { [weak self] _, response, error in - DispatchQueue.main.async { - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - let ok = error == nil && (code == 200 || code == 201) - self?.showResult(success: ok, message: ok ? "Bookmarked" : "Failed to save") - } - }.resume() } - private func showResult(success: Bool, message: String) { - let card = UIView() - card.backgroundColor = success ? .systemGreen : .systemRed - card.layer.cornerRadius = 16 - card.layer.cornerCurve = .continuous - card.translatesAutoresizingMaskIntoConstraints = false - card.alpha = 0 - - let stack = UIStackView() - stack.axis = .horizontal - stack.spacing = 10 - stack.alignment = .center - stack.translatesAutoresizingMaskIntoConstraints = false - - let icon = UIImageView(image: UIImage(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill")) - icon.tintColor = .white - icon.preferredSymbolConfiguration = .init(pointSize: 22, weight: .semibold) - - let label = UILabel() - label.text = message - label.textColor = .white - label.font = .systemFont(ofSize: 17, weight: .semibold) - - stack.addArrangedSubview(icon) - stack.addArrangedSubview(label) - card.addSubview(stack) - view.addSubview(card) - - NSLayoutConstraint.activate([ - stack.centerXAnchor.constraint(equalTo: card.centerXAnchor), - stack.centerYAnchor.constraint(equalTo: card.centerYAnchor), - stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 24), - card.centerXAnchor.constraint(equalTo: view.centerXAnchor), - card.centerYAnchor.constraint(equalTo: view.centerYAnchor), - card.heightAnchor.constraint(equalToConstant: 56), - card.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), - ]) - - UIView.animate(withDuration: 0.2) { card.alpha = 1 } - DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.8 : 2.0)) { [weak self] in - UIView.animate(withDuration: 0.15, animations: { card.alpha = 0 }) { _ in - self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - } - } + private func close() { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } } diff --git a/ShareExtension/TagSuggester.swift b/ShareExtension/TagSuggester.swift new file mode 100644 index 0000000..3ea9b5e --- /dev/null +++ b/ShareExtension/TagSuggester.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Lightweight, extension-local client for AI tag suggestions. Reuses the same +/// backend + anonymous device auth as the main app's `ClaudeService.enrich`, but +/// without pulling the whole service (and its podcast/collection deps) into the +/// extension. The backend fetches the page itself, so suggestions reflect the +/// actual web page content — not just the URL. +enum TagSuggester { + /// Asks the backend for tags for `url`. Returns [] on any failure — tag + /// suggestion is best-effort and must never block saving. + static func suggest(url: String, title: String) async -> [String] { + struct Response: Decodable { let tags: [String] } + do { + let token = try await MarksAuth.validToken() + guard let endpoint = URL(string: MarksAuth.baseURL + "/v1/marks/enrich") else { return [] } + var req = URLRequest(url: endpoint) + req.httpMethod = "POST" + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: [ + "url": url, "title": title, "tags": [String](), + ]) + let (data, response) = try await URLSession.shared.data(for: req) + guard (200...299).contains((response as? HTTPURLResponse)?.statusCode ?? 0) else { return [] } + return try JSONDecoder().decode(Response.self, from: data).tags + } catch { + return [] + } + } +} diff --git a/project.yml b/project.yml index 64d1222..5361619 100644 --- a/project.yml +++ b/project.yml @@ -83,6 +83,12 @@ targets: platform: iOS sources: - path: ShareExtension + # Shared with the main app so the extension can talk to linkding + the AI + # backend (the save card checks for duplicates and suggests tags). + - path: Marks/Models/Bookmark.swift + - path: Marks/Models/ServerConfig.swift + - path: Marks/Services/LinkdingAPI.swift + - path: Marks/Services/MarksAuth.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension