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:
@@ -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 = "<group>"; };
|
||||
629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
|
||||
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; };
|
||||
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = "<group>"; };
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
|
||||
@@ -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 = "<group>"; };
|
||||
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
|
||||
9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; };
|
||||
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
||||
@@ -213,7 +221,9 @@
|
||||
children = (
|
||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */,
|
||||
03D800669E343FF305468424 /* ShareExtension.entitlements */,
|
||||
9B7A85A23A13D754F6A75E4D /* ShareView.swift */,
|
||||
ADEAC824576633CC77370262 /* ShareViewController.swift */,
|
||||
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */,
|
||||
);
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
30
ShareExtension/TagSuggester.swift
Normal file
30
ShareExtension/TagSuggester.swift
Normal file
@@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user