From f8693f4be08ad315e86924b39da590635fdd5c54 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Thu, 2 Jul 2026 01:11:03 -0500 Subject: [PATCH] Add local source ingest and podcasts --- Marks.xcodeproj/project.pbxproj | 22 ++ Marks/Intents/IntentSupport.swift | 2 +- Marks/MarksApp.swift | 9 + Marks/Models/Bookmark.swift | 21 +- Marks/Models/IngestPayload.swift | 95 +++++ Marks/Models/IngestedSource.swift | 69 ++++ Marks/Services/ClaudeService.swift | 8 +- Marks/Services/IngestedSourceStore.swift | 203 ++++++++++ Marks/Services/PodcastGenerationManager.swift | 9 +- Marks/Services/SourceSpotlightIndexer.swift | 40 ++ Marks/Views/AddBookmarkView.swift | 62 ++- Marks/Views/BookmarksViewModel.swift | 12 +- Marks/Views/PodcastPlayerView.swift | 16 +- Marks/Views/SourcesView.swift | 355 ++++++++++++++++++ MarksTests/AppIntentsTests.swift | 72 ++++ ShareExtension/ShareView.swift | 22 +- ShareExtension/ShareViewController.swift | 57 ++- project.yml | 1 + 18 files changed, 1044 insertions(+), 31 deletions(-) create mode 100644 Marks/Models/IngestPayload.swift create mode 100644 Marks/Models/IngestedSource.swift create mode 100644 Marks/Services/IngestedSourceStore.swift create mode 100644 Marks/Services/SourceSpotlightIndexer.swift create mode 100644 Marks/Views/SourcesView.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 9b661ba..bab44e0 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */; }; 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 */; }; @@ -15,6 +16,7 @@ 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; + 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; }; @@ -26,11 +28,14 @@ 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; + 70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; }; 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; + 81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */; }; 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; }; + 83CC9BA3176B05358A307373 /* IngestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */; }; 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 */; }; @@ -40,6 +45,7 @@ 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; + 95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; }; 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; @@ -113,10 +119,12 @@ 0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; }; 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestPayload.swift; sourceTree = ""; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = ""; }; 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = ""; }; 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = ""; }; + 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = ""; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = ""; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = ""; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; @@ -135,6 +143,7 @@ 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 = ""; }; + 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = ""; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = ""; }; @@ -155,6 +164,7 @@ CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; + CDB1DA808EE8041C4546DAAB /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = ""; }; D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = ""; }; D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = ""; }; D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = ""; }; @@ -164,6 +174,7 @@ DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = ""; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = ""; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = ""; }; F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; @@ -212,6 +223,8 @@ isa = PBXGroup; children = ( 8CA428181B35885F7D9F4D55 /* Bookmark.swift */, + 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */, + 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */, 07C21567B95F5069BA946252 /* ServerConfig.swift */, 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */, ); @@ -258,6 +271,7 @@ E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */, C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */, 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, + 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, FB7728D15C17219ABFF3EFFE /* Log.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, @@ -265,6 +279,7 @@ F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */, D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */, + F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */, 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */, 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */, ); @@ -301,6 +316,7 @@ A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */, BCC3BB2525F0F63445D419B9 /* SearchView.swift */, 5C29CB878BC334639E6194E2 /* SettingsView.swift */, + CDB1DA808EE8041C4546DAAB /* SourcesView.swift */, B13B9F2D890C7953531AC0D2 /* TagsView.swift */, ); path = Views; @@ -491,6 +507,7 @@ buildActionMask = 2147483647; files = ( 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, + 70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, @@ -535,6 +552,9 @@ BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, + 83CC9BA3176B05358A307373 /* IngestPayload.swift in Sources */, + 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */, + 81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */, DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, @@ -551,6 +571,8 @@ EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, + 031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */, + 95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */, 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */, AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, diff --git a/Marks/Intents/IntentSupport.swift b/Marks/Intents/IntentSupport.swift index 7b071f9..c0a09bb 100644 --- a/Marks/Intents/IntentSupport.swift +++ b/Marks/Intents/IntentSupport.swift @@ -3,7 +3,7 @@ import AppIntents /// Which top-level tab the app is showing. Used so an intent can switch tabs. enum AppTab: Hashable { - case bookmarks, tags, podcasts, search + case bookmarks, tags, sources, podcasts, search } /// Bridges App Intents (which run in the main app process, since there is no diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 19dfb05..a9516eb 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -39,6 +39,7 @@ struct MainContainer: View { @State private var selectedTab: AppTab = .bookmarks @State private var router = IntentRouter.shared @State private var library = PodcastLibrary.shared + @State private var sourceLibrary = IngestedSourceLibrary() @Environment(\.scenePhase) private var scenePhase init(config: ServerConfig, onDisconnect: @escaping () -> Void) { @@ -56,6 +57,14 @@ struct MainContainer: View { Tab("Tags", systemImage: "tag", value: AppTab.tags) { TagsView(viewModel: viewModel) } + Tab("Sources", systemImage: "tray.full", value: AppTab.sources) { + SourcesView( + library: sourceLibrary, + podcastPlayer: viewModel.podcastPlayer, + podcastGenerator: viewModel.podcastGenerator, + claude: viewModel.claude + ) + } Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) { PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) } diff --git a/Marks/Models/Bookmark.swift b/Marks/Models/Bookmark.swift index 36e47c2..2b85688 100644 --- a/Marks/Models/Bookmark.swift +++ b/Marks/Models/Bookmark.swift @@ -82,16 +82,35 @@ struct BookmarkCheck: Codable, Sendable { struct BookmarkCreate: Codable, Sendable { let url: String let title: String + let description: String let tagNames: [String] let isArchived: Bool let unread: Bool let shared: Bool enum CodingKeys: String, CodingKey { - case url, title, unread, shared + case url, title, description, unread, shared case tagNames = "tag_names" case isArchived = "is_archived" } + + init( + url: String, + title: String, + description: String = "", + tagNames: [String], + isArchived: Bool, + unread: Bool, + shared: Bool + ) { + self.url = url + self.title = title + self.description = description + self.tagNames = tagNames + self.isArchived = isArchived + self.unread = unread + self.shared = shared + } } struct BookmarkUpdate: Codable, Sendable { diff --git a/Marks/Models/IngestPayload.swift b/Marks/Models/IngestPayload.swift new file mode 100644 index 0000000..70382e0 --- /dev/null +++ b/Marks/Models/IngestPayload.swift @@ -0,0 +1,95 @@ +import Foundation + +struct IngestPayload: Equatable, Sendable { + let url: String + let title: String? + let notes: String +} + +enum IngestPayloadParser { + static func parse(item: Any?, typeIdentifier: String? = nil) -> IngestPayload? { + if let url = item as? URL { + return payload(from: url.absoluteString, notes: "") + } + + if let attributed = item as? NSAttributedString { + return payload(from: attributed.string, notes: attributed.string) + } + + if let string = item as? String { + return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "") + } + + if let data = item as? Data, + let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) { + return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "") + } + + return nil + } + + private static func payload(from text: String, notes: String) -> IngestPayload? { + guard let url = firstWebURL(in: text) else { return nil } + return IngestPayload(url: url, title: nil, notes: cleanedNotes(notes, excluding: url)) + } + + static func firstWebURL(in text: String) -> String? { + if let direct = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), + isBookmarkable(direct) { + return direct.absoluteString + } + + let decoded = decodeHTML(text) + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let range = NSRange(decoded.startIndex..\"'.,;:)]】」")) + + guard let url = URL(string: candidate), isBookmarkable(url) else { return nil } + return url.absoluteString + } + + private static func isBookmarkable(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + url.host?.isEmpty == false else { + return false + } + return true + } + + private static func cleanedNotes(_ notes: String, excluding url: String) -> String { + let trimmed = decodeHTML(notes) + .replacingOccurrences(of: url, with: "") + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + + return String(trimmed.prefix(1800)) + } + + private static func decodeHTML(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + } + + private static func shouldKeepNotes(for typeIdentifier: String?) -> Bool { + guard let typeIdentifier else { return true } + return !typeIdentifier.lowercased().contains("url") + } +} diff --git a/Marks/Models/IngestedSource.swift b/Marks/Models/IngestedSource.swift new file mode 100644 index 0000000..5836cbe --- /dev/null +++ b/Marks/Models/IngestedSource.swift @@ -0,0 +1,69 @@ +import Foundation + +enum IngestedSourceKind: String, Codable, Sendable, CaseIterable { + case text + case pdf + case file + + var label: String { + switch self { + case .text: "Text" + case .pdf: "PDF" + case .file: "File" + } + } +} + +struct IngestedSource: Identifiable, Codable, Sendable, Hashable { + let id: UUID + var kind: IngestedSourceKind + var title: String + var bodyText: String + var originalFilename: String? + var localFilename: String? + var tags: [String] + var createdAt: Date + var modifiedAt: Date + + init( + id: UUID = UUID(), + kind: IngestedSourceKind, + title: String, + bodyText: String, + originalFilename: String? = nil, + localFilename: String? = nil, + tags: [String] = [], + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) { + self.id = id + self.kind = kind + self.title = title + self.bodyText = bodyText + self.originalFilename = originalFilename + self.localFilename = localFilename + self.tags = tags + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } + + var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + return originalFilename ?? kind.label + } + + var excerpt: String { + bodyText + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + } + + var podcastURL: String { + "marks-source://\(id.uuidString)" + } + + var podcastText: String { + String(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).prefix(100_000)) + } +} diff --git a/Marks/Services/ClaudeService.swift b/Marks/Services/ClaudeService.swift index 5179cb4..c931a3b 100644 --- a/Marks/Services/ClaudeService.swift +++ b/Marks/Services/ClaudeService.swift @@ -56,9 +56,13 @@ struct ClaudeService: Sendable { // MARK: - Podcast - func generatePodcast(url: String) async throws -> String { + func generatePodcast(url: String, title: String? = nil, sourceText: String? = nil, sourceKind: String? = nil) async throws -> String { struct Response: Decodable { let job_id: String } - let data = try await post("/v1/podcast/generate", body: ["url": url]) + var body: [String: Any] = ["url": url] + if let title, !title.isEmpty { body["title"] = title } + if let sourceText, !sourceText.isEmpty { body["text"] = sourceText } + if let sourceKind, !sourceKind.isEmpty { body["source_kind"] = sourceKind } + let data = try await post("/v1/podcast/generate", body: body) return try JSONDecoder().decode(Response.self, from: data).job_id } diff --git a/Marks/Services/IngestedSourceStore.swift b/Marks/Services/IngestedSourceStore.swift new file mode 100644 index 0000000..49e614c --- /dev/null +++ b/Marks/Services/IngestedSourceStore.swift @@ -0,0 +1,203 @@ +import Foundation +import Observation +import PDFKit +import UniformTypeIdentifiers + +@MainActor +@Observable +final class IngestedSourceLibrary { + private(set) var sources: [IngestedSource] = [] + var error: String? + + private let store: IngestedSourceStore + + init(store: IngestedSourceStore = IngestedSourceStore()) { + self.store = store + } + + func load() { + do { + sources = try store.load() + Task { await SourceSpotlightIndexer.index(sources) } + } catch { + self.error = error.localizedDescription + } + } + + func addText(title: String, body: String, tags: [String]) { + let source = IngestedSource( + kind: .text, + title: title, + bodyText: body.trimmingCharacters(in: .whitespacesAndNewlines), + tags: tags + ) + upsert(source) + } + + func importFile(from url: URL, tags: [String]) { + do { + let source = try store.importFile(from: url, tags: tags) + upsert(source) + } catch { + self.error = error.localizedDescription + } + } + + func delete(_ source: IngestedSource) { + do { + try store.delete(source) + sources.removeAll { $0.id == source.id } + Task { await SourceSpotlightIndexer.remove(ids: [source.id]) } + } catch { + self.error = error.localizedDescription + } + } + + func fileURL(for source: IngestedSource) -> URL? { + store.fileURL(for: source) + } + + func search(_ query: String) -> [IngestedSource] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return sources } + return sources.filter { source in + source.displayTitle.lowercased().contains(q) || + source.bodyText.lowercased().contains(q) || + source.tags.contains { $0.lowercased().contains(q) } || + (source.originalFilename ?? "").lowercased().contains(q) + } + } + + private func upsert(_ source: IngestedSource) { + sources.removeAll { $0.id == source.id } + sources.insert(source, at: 0) + do { + try store.save(sources) + Task { await SourceSpotlightIndexer.index([source]) } + } catch { + self.error = error.localizedDescription + } + } +} + +struct IngestedSourceStore { + private let rootURL: URL + private let metadataURL: URL + private let filesURL: URL + + init(rootURL: URL? = nil) { + let base = rootURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.rootURL = base.appendingPathComponent("Sources", isDirectory: true) + self.metadataURL = self.rootURL.appendingPathComponent("sources.json") + self.filesURL = self.rootURL.appendingPathComponent("files", isDirectory: true) + } + + func load() throws -> [IngestedSource] { + guard FileManager.default.fileExists(atPath: metadataURL.path) else { return [] } + let data = try Data(contentsOf: metadataURL) + return try JSONDecoder.sourceDecoder.decode([IngestedSource].self, from: data) + .sorted { $0.createdAt > $1.createdAt } + } + + func save(_ sources: [IngestedSource]) throws { + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let data = try JSONEncoder.sourceEncoder.encode(sources) + try data.write(to: metadataURL, options: [.atomic]) + } + + func importFile(from url: URL, tags: [String]) throws -> IngestedSource { + try FileManager.default.createDirectory(at: filesURL, withIntermediateDirectories: true) + + let accessed = url.startAccessingSecurityScopedResource() + defer { + if accessed { url.stopAccessingSecurityScopedResource() } + } + + let id = UUID() + let originalFilename = url.lastPathComponent + let ext = url.pathExtension.isEmpty ? "dat" : url.pathExtension + let localFilename = "\(id.uuidString).\(ext)" + let destination = filesURL.appendingPathComponent(localFilename) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.copyItem(at: url, to: destination) + + let kind = url.pathExtension.lowercased() == "pdf" ? IngestedSourceKind.pdf : .file + let bodyText = kind == .pdf + ? PDFTextExtractor.extractText(from: destination) + : PlainTextExtractor.extractText(from: destination) + + return IngestedSource( + id: id, + kind: kind, + title: originalFilename.removingFileExtension, + bodyText: bodyText, + originalFilename: originalFilename, + localFilename: localFilename, + tags: tags + ) + } + + func delete(_ source: IngestedSource) throws { + if let url = fileURL(for: source), FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + var loaded = try load() + loaded.removeAll { $0.id == source.id } + try save(loaded) + } + + func fileURL(for source: IngestedSource) -> URL? { + guard let localFilename = source.localFilename else { return nil } + return filesURL.appendingPathComponent(localFilename) + } +} + +private enum PDFTextExtractor { + static func extractText(from url: URL) -> String { + guard let document = PDFDocument(url: url) else { return "" } + var pages: [String] = [] + for index in 0.. String { + guard let data = try? Data(contentsOf: url) else { return "" } + return String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .ascii) + ?? "" + } +} + +private extension String { + var removingFileExtension: String { + let ns = self as NSString + let name = ns.deletingPathExtension + return name.isEmpty ? self : name + } +} + +private extension JSONEncoder { + static var sourceEncoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} + +private extension JSONDecoder { + static var sourceDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Marks/Services/PodcastGenerationManager.swift b/Marks/Services/PodcastGenerationManager.swift index 57eb079..c027240 100644 --- a/Marks/Services/PodcastGenerationManager.swift +++ b/Marks/Services/PodcastGenerationManager.swift @@ -37,6 +37,8 @@ final class PodcastGenerationManager { func enqueue(articleUrl: String, title: String, parentBookmarkUrl: String? = nil, + sourceText: String? = nil, + sourceKind: String? = nil, onReady: ((URL, String) -> Void)? = nil) { let cached = ClaudeService.cachedPodcastURL(for: articleUrl) if FileManager.default.fileExists(atPath: cached.path) { @@ -52,7 +54,12 @@ final class PodcastGenerationManager { let task = Task { [weak self] in guard let self else { return } do { - let jobId = try await claude.generatePodcast(url: articleUrl) + let jobId = try await claude.generatePodcast( + url: articleUrl, + title: title, + sourceText: sourceText, + sourceKind: sourceKind + ) while !Task.isCancelled { let status = try await claude.podcastStatus(jobId: jobId) update(articleUrl) { diff --git a/Marks/Services/SourceSpotlightIndexer.swift b/Marks/Services/SourceSpotlightIndexer.swift new file mode 100644 index 0000000..95cad73 --- /dev/null +++ b/Marks/Services/SourceSpotlightIndexer.swift @@ -0,0 +1,40 @@ +import CoreSpotlight +import Foundation +import UniformTypeIdentifiers + +enum SourceSpotlightIndexer { + private static let domainIdentifier = "com.magicive.marks.sources" + + static func index(_ sources: [IngestedSource]) async { + guard CSSearchableIndex.isIndexingAvailable(), !sources.isEmpty else { return } + let items = sources.map { source in + let attributes = CSSearchableItemAttributeSet(contentType: source.kind == .pdf ? .pdf : .text) + attributes.title = source.displayTitle + attributes.contentDescription = source.bodyText + attributes.keywords = source.tags + [source.kind.label, source.originalFilename].compactMap { $0 } + attributes.displayName = source.originalFilename ?? source.displayTitle + return CSSearchableItem( + uniqueIdentifier: source.id.uuidString, + domainIdentifier: domainIdentifier, + attributeSet: attributes + ) + } + + do { + try await CSSearchableIndex.default().indexSearchableItems(items) + Log.spotlight.info("Indexed \(sources.count, privacy: .public) sources") + } catch { + Log.spotlight.error("Source index failed: \(error.localizedDescription, privacy: .public)") + } + } + + static func remove(ids: [UUID]) async { + guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return } + do { + try await CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids.map(\.uuidString)) + Log.spotlight.info("Removed \(ids.count, privacy: .public) sources") + } catch { + Log.spotlight.error("Source delete failed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Marks/Views/AddBookmarkView.swift b/Marks/Views/AddBookmarkView.swift index 8b65a12..3a8ef46 100644 --- a/Marks/Views/AddBookmarkView.swift +++ b/Marks/Views/AddBookmarkView.swift @@ -6,13 +6,44 @@ struct AddBookmarkView: View { @State private var url = "" @State private var title = "" + @State private var description = "" @State private var tagsText = "" + @State private var importText = "" @State private var isSaving = false @State private var error: String? + @State private var importMessage: String? var body: some View { NavigationStack { Form { + Section { + TextEditor(text: $importText) + .frame(minHeight: 96) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + HStack { + Button { + extractImportText() + } label: { + Label("Extract Link", systemImage: "link.badge.plus") + } + .disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Spacer() + + if let importMessage { + Text(importMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } header: { + Text("Import Text") + } footer: { + Text("Paste an email, newsletter, or message and Marks will pull out the first web link.") + } + Section { TextField("https://", text: $url) .keyboardType(.URL) @@ -28,6 +59,13 @@ struct AddBookmarkView: View { Text("Title") } + Section { + TextField("Optional", text: $description, axis: .vertical) + .lineLimit(2...5) + } header: { + Text("Notes") + } + Section { TextField("comma separated", text: $tagsText) .textInputAutocapitalization(.never) @@ -63,6 +101,23 @@ struct AddBookmarkView: View { } } + private func extractImportText() { + guard let payload = IngestPayloadParser.parse(item: importText, typeIdentifier: "public.plain-text") else { + importMessage = "No link found" + return + } + + url = payload.url + if let extractedTitle = payload.title, title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + title = extractedTitle + } + if description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + description = payload.notes + } + importMessage = "Link extracted" + error = nil + } + private func save() { let urlString = url.normalizedURL guard URL(string: urlString) != nil else { @@ -74,7 +129,12 @@ struct AddBookmarkView: View { error = nil Task { do { - try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags) + try await viewModel.addBookmark( + url: urlString, + title: title.trimmingCharacters(in: .whitespaces), + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + tags: tags + ) dismiss() } catch { self.error = error.localizedDescription diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 80a662e..0ff4d0c 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -128,8 +128,16 @@ final class BookmarksViewModel { isLoading = false } - func addBookmark(url: String, title: String, tags: [String]) async throws { - let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false) + func addBookmark(url: String, title: String, description: String = "", tags: [String]) async throws { + let create = BookmarkCreate( + url: url, + title: title, + description: description, + tagNames: tags, + isArchived: false, + unread: false, + shared: false + ) let bookmark = try await api.createBookmark(create) bookmarks.insert(bookmark, at: 0) Task { await SpotlightIndexer.index([bookmark]) } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 778df8c..158eced 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -76,7 +76,14 @@ final class PodcastPlayerViewModel { private var interruptionObserver: Any? private var currentPodcastTitle: String = "Marks Podcast" - func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) { + func start( + articleUrl: String, + articleTitle: String = "", + claude: ClaudeService, + parentBookmarkUrl: String? = nil, + sourceText: String? = nil, + sourceKind: String? = nil + ) { // Idempotent — don't restart if already generating or playing this article if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return } @@ -99,7 +106,12 @@ final class PodcastPlayerViewModel { pollingTask = Task { do { - let jobId = try await claude.generatePodcast(url: articleUrl) + let jobId = try await claude.generatePodcast( + url: articleUrl, + title: articleTitle, + sourceText: sourceText, + sourceKind: sourceKind + ) while !Task.isCancelled { let job = try await claude.podcastStatus(jobId: jobId) phase = .generating( diff --git a/Marks/Views/SourcesView.swift b/Marks/Views/SourcesView.swift new file mode 100644 index 0000000..13dd6f7 --- /dev/null +++ b/Marks/Views/SourcesView.swift @@ -0,0 +1,355 @@ +import QuickLook +import SwiftUI +import UniformTypeIdentifiers + +struct SourcesView: View { + @Bindable var library: IngestedSourceLibrary + let podcastPlayer: PodcastPlayerViewModel + let podcastGenerator: PodcastGenerationManager + let claude: ClaudeService + + @State private var searchText = "" + @State private var showTextImport = false + @State private var showFileImporter = false + @State private var selectedSource: IngestedSource? + @State private var showFullPlayer = false + + private var results: [IngestedSource] { + library.search(searchText) + } + + var body: some View { + NavigationStack { + List { + ForEach(results) { source in + HStack(spacing: 10) { + Button { + selectedSource = source + } label: { + SourceRow(source: source) + } + .buttonStyle(.plain) + + Button { + playOrGeneratePodcast(for: source) + } label: { + Image(systemName: podcastIcon(for: source)) + .font(.title3) + .foregroundStyle(.blue) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .disabled(source.podcastText.isEmpty) + } + .swipeActions { + Button { + playOrGeneratePodcast(for: source) + } label: { + Label("Podcast", systemImage: "headphones") + } + .tint(.blue) + + Button(role: .destructive) { + library.delete(source) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + .listStyle(.plain) + .navigationTitle("Sources") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + showTextImport = true + } label: { + Label("Import Text", systemImage: "doc.text") + } + Button { + showFileImporter = true + } label: { + Label("Import File", systemImage: "doc.badge.plus") + } + } label: { + Image(systemName: "plus") + } + } + } + .overlay { + if results.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "No Sources" : "No Results", + systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", + description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.") + ) + } + } + .searchable(text: $searchText, prompt: "Search sources") + } + .task { library.load() } + .sheet(isPresented: $showTextImport) { + ImportTextSourceView(library: library) + } + .sheet(item: $selectedSource) { source in + SourceDetailView( + source: source, + fileURL: library.fileURL(for: source), + podcastCached: podcastCached(for: source), + podcastGenerating: podcastGenerator.isGenerating(source.podcastURL), + onPodcast: { + selectedSource = nil + Task { + try? await Task.sleep(for: .milliseconds(250)) + playOrGeneratePodcast(for: source) + } + } + ) + } + .sheet(isPresented: $showFullPlayer) { + PodcastPlayerView( + vm: podcastPlayer, + articleUrl: podcastPlayer.currentArticleUrl, + articleTitle: podcastPlayer.currentArticleTitle, + claude: claude, + stopOnDismiss: false + ) + } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.pdf, .plainText, .text, .data], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + for url in urls { + library.importFile(from: url, tags: []) + } + case .failure(let error): + library.error = error.localizedDescription + } + } + .alert("Source Error", isPresented: Binding( + get: { library.error != nil }, + set: { if !$0 { library.error = nil } } + )) { + Button("OK") { library.error = nil } + } message: { + Text(library.error ?? "") + } + } + + private func playOrGeneratePodcast(for source: IngestedSource) { + let podcastURL = source.podcastURL + let title = source.displayTitle + let existing = PodcastIndex.find(for: podcastURL).first + + if let existing { + podcastPlayer.start(articleUrl: existing.articleUrl, articleTitle: existing.title ?? title, claude: claude) + showFullPlayer = true + return + } + + if podcastPlayer.currentArticleUrl.isEmpty { + podcastPlayer.start( + articleUrl: podcastURL, + articleTitle: title, + claude: claude, + sourceText: source.podcastText, + sourceKind: source.kind.rawValue + ) + showFullPlayer = true + } else { + podcastGenerator.enqueue( + articleUrl: podcastURL, + title: title, + sourceText: source.podcastText, + sourceKind: source.kind.rawValue + ) + } + } + + private func podcastCached(for source: IngestedSource) -> Bool { + FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: source.podcastURL).path) + } + + private func podcastIcon(for source: IngestedSource) -> String { + if podcastGenerator.isGenerating(source.podcastURL) { return "waveform.circle.fill" } + if podcastCached(for: source) { return "headphones.circle.fill" } + return "headphones.circle" + } +} + +private struct SourceRow: View { + let source: IngestedSource + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: iconName) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.blue) + .frame(width: 30, height: 30) + .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + + VStack(alignment: .leading, spacing: 5) { + Text(source.displayTitle) + .font(.headline) + .lineLimit(2) + if !source.excerpt.isEmpty { + Text(source.excerpt) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + HStack(spacing: 8) { + Text(source.kind.label) + Text(source.createdAt, style: .date) + if !source.tags.isEmpty { + Text(source.tags.joined(separator: ", ")) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 8) + } + + private var iconName: String { + switch source.kind { + case .text: "doc.text" + case .pdf: "doc.richtext" + case .file: "doc" + } + } +} + +private struct ImportTextSourceView: View { + @Bindable var library: IngestedSourceLibrary + @Environment(\.dismiss) private var dismiss + + @State private var title = "" + @State private var bodyText = "" + @State private var tagsText = "" + + var body: some View { + NavigationStack { + Form { + Section("Title") { + TextField("Optional", text: $title) + } + Section("Text") { + TextEditor(text: $bodyText) + .frame(minHeight: 180) + .textInputAutocapitalization(.sentences) + } + Section { + TextField("comma separated", text: $tagsText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Tags") + } footer: { + Text("Separate tags with commas") + } + } + .navigationTitle("Import Text") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + library.addText(title: title, body: bodyText, tags: tagsText.parsedTags) + dismiss() + } + .disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +private struct SourceDetailView: View { + let source: IngestedSource + let fileURL: URL? + let podcastCached: Bool + let podcastGenerating: Bool + let onPodcast: () -> Void + + @State private var previewURL: URL? + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(source.displayTitle) + .font(.title2.weight(.semibold)) + Text(source.kind.label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if !source.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(source.tags, id: \.self) { tag in + Text(tag) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.blue.opacity(0.12), in: Capsule()) + } + } + } + } + + if let fileURL { + HStack { + Button { + previewURL = fileURL + } label: { + Label("Open Original", systemImage: "doc.viewfinder") + } + .buttonStyle(.bordered) + + Button { + onPodcast() + } label: { + Label(podcastTitle, systemImage: "headphones") + } + .buttonStyle(.borderedProminent) + .disabled(source.podcastText.isEmpty || podcastGenerating) + } + } else { + Button { + onPodcast() + } label: { + Label(podcastTitle, systemImage: "headphones") + } + .buttonStyle(.borderedProminent) + .disabled(source.podcastText.isEmpty || podcastGenerating) + } + + Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText) + .font(.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + } + .navigationTitle("Source") + .navigationBarTitleDisplayMode(.inline) + } + .quickLookPreview($previewURL) + } + + private var podcastTitle: String { + if podcastGenerating { return "Generating" } + if podcastCached { return "Play Podcast" } + return "Create Podcast" + } +} diff --git a/MarksTests/AppIntentsTests.swift b/MarksTests/AppIntentsTests.swift index 3a0ec6a..413be8d 100644 --- a/MarksTests/AppIntentsTests.swift +++ b/MarksTests/AppIntentsTests.swift @@ -104,4 +104,76 @@ final class AppIntentsTests: XCTestCase { func testAppShortcutsAreRegistered() { XCTAssertGreaterThanOrEqual(MarksShortcuts.appShortcuts.count, 4) } + + // MARK: - Ingest payload parsing + + func testIngestParsesDirectURL() { + let payload = IngestPayloadParser.parse(item: URL(string: "https://example.com/article")!) + XCTAssertEqual(payload?.url, "https://example.com/article") + XCTAssertEqual(payload?.notes, "") + } + + func testIngestFindsURLInSharedEmailText() { + let text = """ + Thought you might want to save this: + + https://example.com/story?utm_source=newsletter + + Sent from Spark + """ + + let payload = IngestPayloadParser.parse(item: text, typeIdentifier: "public.plain-text") + XCTAssertEqual(payload?.url, "https://example.com/story?utm_source=newsletter") + XCTAssertTrue(payload?.notes.contains("Sent from Spark") == true) + } + + func testIngestIgnoresMailLinksAndUsesWebURL() { + let text = "mailto:friend@example.com\nRead https://example.com/read-later." + let payload = IngestPayloadParser.parse(item: text, typeIdentifier: "public.plain-text") + XCTAssertEqual(payload?.url, "https://example.com/read-later") + } + + func testIngestDecodesHTMLLinksFromEmailClients() { + let html = "

Save this: article

" + let payload = IngestPayloadParser.parse(item: html, typeIdentifier: "public.html") + XCTAssertEqual(payload?.url, "https://example.com/a?x=1&y=2") + } + + // MARK: - Local sources + + func testSourceStoreSavesAndLoadsMetadata() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = IngestedSourceStore(rootURL: root) + let source = IngestedSource(kind: .text, title: "Meeting Notes", bodyText: "Follow up on paper receipts.", tags: ["receipts"]) + try store.save([source]) + + let loaded = try store.load() + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.id, source.id) + XCTAssertEqual(loaded.first?.kind, .text) + XCTAssertEqual(loaded.first?.title, "Meeting Notes") + XCTAssertEqual(loaded.first?.bodyText, "Follow up on paper receipts.") + XCTAssertEqual(loaded.first?.tags, ["receipts"]) + } + + func testSourceStoreImportsPlainTextFile() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let input = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: input) + } + + try Data("Offline clipping text".utf8).write(to: input) + + let store = IngestedSourceStore(rootURL: root) + let source = try store.importFile(from: input, tags: ["offline"]) + + XCTAssertEqual(source.kind, .file) + XCTAssertEqual(source.bodyText, "Offline clipping text") + XCTAssertEqual(source.tags, ["offline"]) + XCTAssertNotNil(store.fileURL(for: source)) + } } diff --git a/ShareExtension/ShareView.swift b/ShareExtension/ShareView.swift index 590f6ba..a9c4758 100644 --- a/ShareExtension/ShareView.swift +++ b/ShareExtension/ShareView.swift @@ -5,6 +5,8 @@ import SwiftUI /// 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 @@ -26,10 +28,14 @@ struct ShareView: View { private let api: LinkdingAPI? - init(url: String, onClose: @escaping (_ saved: Bool) -> Void) { + 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 } @@ -225,6 +231,14 @@ struct ShareView: View { // 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 @@ -239,8 +253,11 @@ struct ShareView: View { notes = bookmark.description readLater = bookmark.unread } else { - title = result.metadata?.title ?? "" + 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) @@ -295,6 +312,7 @@ struct ShareView: View { let create = BookmarkCreate( url: url, title: "", + description: notes, tagNames: parsedTags, isArchived: false, unread: readLater, diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 1d1f8b4..acc787c 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -4,6 +4,13 @@ import UniformTypeIdentifiers @objc(ShareViewController) class ShareViewController: UIViewController { + private let supportedTypeIdentifiers = [ + UTType.url.identifier, + UTType.html.identifier, + UTType.rtf.identifier, + UTType.plainText.identifier, + UTType.text.identifier + ] override func viewDidLoad() { super.viewDidLoad() @@ -11,37 +18,49 @@ class ShareViewController: UIViewController { blur.frame = view.bounds blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(blur) - extractURL() + extractPayload() } - private func extractURL() { + private func extractPayload() { guard let item = extensionContext?.inputItems.first as? NSExtensionItem, let attachments = item.attachments else { - present(url: nil) + present(payload: nil) return } - func load(typeId: String) -> Bool { - guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else { - return false - } - attachment.loadItem(forTypeIdentifier: typeId, options: nil) { [weak self] item, _ in - let urlString = typeId == UTType.url.identifier - ? ((item as? URL)?.absoluteString ?? (item as? String)) - : (item as? String) - DispatchQueue.main.async { self?.present(url: urlString) } - } - return true + let candidates = attachments.flatMap { attachment in + supportedTypeIdentifiers + .filter { attachment.hasItemConformingToTypeIdentifier($0) } + .map { (attachment, $0) } } - if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) { - present(url: nil) + loadNext(candidates, index: 0) + } + + private func loadNext(_ candidates: [(NSItemProvider, String)], index: Int) { + guard index < candidates.count else { + present(payload: nil) + return + } + + let (attachment, typeIdentifier) = candidates[index] + attachment.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, _ in + guard let self else { return } + if let payload = IngestPayloadParser.parse(item: item, typeIdentifier: typeIdentifier) { + DispatchQueue.main.async { self.present(payload: payload) } + } else { + DispatchQueue.main.async { self.loadNext(candidates, index: index + 1) } + } } } /// 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 + private func present(payload: IngestPayload?) { + let root = ShareView( + url: payload?.url ?? "", + initialTitle: payload?.title, + initialNotes: payload?.notes ?? "" + ) { [weak self] _ in self?.close() } let host = UIHostingController(rootView: root) @@ -52,7 +71,7 @@ class ShareViewController: UIViewController { view.addSubview(host.view) host.didMove(toParent: self) - if url == nil { + if payload == 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() } } diff --git a/project.yml b/project.yml index 940ccfe..f03ab79 100644 --- a/project.yml +++ b/project.yml @@ -86,6 +86,7 @@ targets: # 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/IngestPayload.swift - path: Marks/Models/ServerConfig.swift - path: Marks/Services/LinkdingAPI.swift - path: Marks/Services/MarksAuth.swift