Add local source ingest and podcasts
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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, ); }; };
|
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 */; };
|
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
|
||||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.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 */; };
|
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
|
||||||
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.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 */; };
|
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.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 */; };
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
|
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
|
||||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.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 */; };
|
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
|
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
|
||||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
|
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
|
||||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
|
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 */; };
|
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 */; };
|
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; };
|
||||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||||
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
|
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 */; };
|
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
|
||||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
||||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.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 */; };
|
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
|
||||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
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; };
|
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 = "<group>"; };
|
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||||
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
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 = "<group>"; };
|
||||||
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
||||||
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||||
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
|
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
|
||||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
||||||
|
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = "<group>"; };
|
||||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||||
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
||||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
@@ -135,6 +143,7 @@
|
|||||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.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>"; };
|
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>"; };
|
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
|
||||||
|
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = "<group>"; };
|
||||||
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
||||||
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = "<group>"; };
|
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = "<group>"; };
|
||||||
@@ -155,6 +164,7 @@
|
|||||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
|
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
|
||||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
||||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
|
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
|
||||||
|
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = "<group>"; };
|
||||||
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; };
|
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; };
|
||||||
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = "<group>"; };
|
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = "<group>"; };
|
||||||
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = "<group>"; };
|
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = "<group>"; };
|
||||||
@@ -164,6 +174,7 @@
|
|||||||
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
|
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
|
||||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
|
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
|
||||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
|
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
|
||||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
|
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
|
||||||
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
|
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;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
||||||
|
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */,
|
||||||
|
0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */,
|
||||||
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
||||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */,
|
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */,
|
||||||
);
|
);
|
||||||
@@ -258,6 +271,7 @@
|
|||||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
||||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
||||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||||
|
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */,
|
||||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||||
FB7728D15C17219ABFF3EFFE /* Log.swift */,
|
FB7728D15C17219ABFF3EFFE /* Log.swift */,
|
||||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||||
@@ -265,6 +279,7 @@
|
|||||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||||
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
|
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
|
||||||
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
|
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
|
||||||
|
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */,
|
||||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
||||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
||||||
);
|
);
|
||||||
@@ -301,6 +316,7 @@
|
|||||||
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */,
|
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */,
|
||||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||||
|
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
|
||||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
@@ -491,6 +507,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
|
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
|
||||||
|
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
|
||||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
|
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
|
||||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
|
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
|
||||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
|
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
|
||||||
@@ -535,6 +552,9 @@
|
|||||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||||
C3189071834E0F8898408C37 /* EditBookmarkView.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 */,
|
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
||||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
@@ -551,6 +571,8 @@
|
|||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||||
|
031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */,
|
||||||
|
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */,
|
||||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
|
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
|
||||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
|
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
|
||||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AppIntents
|
|||||||
|
|
||||||
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
|
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
|
||||||
enum AppTab: Hashable {
|
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
|
/// Bridges App Intents (which run in the main app process, since there is no
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct MainContainer: View {
|
|||||||
@State private var selectedTab: AppTab = .bookmarks
|
@State private var selectedTab: AppTab = .bookmarks
|
||||||
@State private var router = IntentRouter.shared
|
@State private var router = IntentRouter.shared
|
||||||
@State private var library = PodcastLibrary.shared
|
@State private var library = PodcastLibrary.shared
|
||||||
|
@State private var sourceLibrary = IngestedSourceLibrary()
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||||
@@ -56,6 +57,14 @@ struct MainContainer: View {
|
|||||||
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
|
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
|
||||||
TagsView(viewModel: viewModel)
|
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) {
|
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
||||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,16 +82,35 @@ struct BookmarkCheck: Codable, Sendable {
|
|||||||
struct BookmarkCreate: Codable, Sendable {
|
struct BookmarkCreate: Codable, Sendable {
|
||||||
let url: String
|
let url: String
|
||||||
let title: String
|
let title: String
|
||||||
|
let description: String
|
||||||
let tagNames: [String]
|
let tagNames: [String]
|
||||||
let isArchived: Bool
|
let isArchived: Bool
|
||||||
let unread: Bool
|
let unread: Bool
|
||||||
let shared: Bool
|
let shared: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case url, title, unread, shared
|
case url, title, description, unread, shared
|
||||||
case tagNames = "tag_names"
|
case tagNames = "tag_names"
|
||||||
case isArchived = "is_archived"
|
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 {
|
struct BookmarkUpdate: Codable, Sendable {
|
||||||
|
|||||||
95
Marks/Models/IngestPayload.swift
Normal file
95
Marks/Models/IngestPayload.swift
Normal file
@@ -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..<decoded.endIndex, in: decoded)
|
||||||
|
let match = detector?.matches(in: decoded, range: range).first { result in
|
||||||
|
guard let url = result.url else { return false }
|
||||||
|
return isBookmarkable(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let matchRange = match?.range,
|
||||||
|
let swiftRange = Range(matchRange, in: decoded) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = String(decoded[swiftRange])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: " \n\t\r<>\"'.,;:)]】」"))
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Marks/Models/IngestedSource.swift
Normal file
69
Marks/Models/IngestedSource.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,9 +56,13 @@ struct ClaudeService: Sendable {
|
|||||||
|
|
||||||
// MARK: - Podcast
|
// 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 }
|
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
|
return try JSONDecoder().decode(Response.self, from: data).job_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
203
Marks/Services/IngestedSourceStore.swift
Normal file
203
Marks/Services/IngestedSourceStore.swift
Normal file
@@ -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..<document.pageCount {
|
||||||
|
if let text = document.page(at: index)?.string?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!text.isEmpty {
|
||||||
|
pages.append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PlainTextExtractor {
|
||||||
|
static func extractText(from url: URL) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ final class PodcastGenerationManager {
|
|||||||
func enqueue(articleUrl: String,
|
func enqueue(articleUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
parentBookmarkUrl: String? = nil,
|
parentBookmarkUrl: String? = nil,
|
||||||
|
sourceText: String? = nil,
|
||||||
|
sourceKind: String? = nil,
|
||||||
onReady: ((URL, String) -> Void)? = nil) {
|
onReady: ((URL, String) -> Void)? = nil) {
|
||||||
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||||
if FileManager.default.fileExists(atPath: cached.path) {
|
if FileManager.default.fileExists(atPath: cached.path) {
|
||||||
@@ -52,7 +54,12 @@ final class PodcastGenerationManager {
|
|||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
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 {
|
while !Task.isCancelled {
|
||||||
let status = try await claude.podcastStatus(jobId: jobId)
|
let status = try await claude.podcastStatus(jobId: jobId)
|
||||||
update(articleUrl) {
|
update(articleUrl) {
|
||||||
|
|||||||
40
Marks/Services/SourceSpotlightIndexer.swift
Normal file
40
Marks/Services/SourceSpotlightIndexer.swift
Normal file
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,44 @@ struct AddBookmarkView: View {
|
|||||||
|
|
||||||
@State private var url = ""
|
@State private var url = ""
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
|
@State private var description = ""
|
||||||
@State private var tagsText = ""
|
@State private var tagsText = ""
|
||||||
|
@State private var importText = ""
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
@State private var importMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
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 {
|
Section {
|
||||||
TextField("https://", text: $url)
|
TextField("https://", text: $url)
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
@@ -28,6 +59,13 @@ struct AddBookmarkView: View {
|
|||||||
Text("Title")
|
Text("Title")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("Optional", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(2...5)
|
||||||
|
} header: {
|
||||||
|
Text("Notes")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
TextField("comma separated", text: $tagsText)
|
TextField("comma separated", text: $tagsText)
|
||||||
.textInputAutocapitalization(.never)
|
.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() {
|
private func save() {
|
||||||
let urlString = url.normalizedURL
|
let urlString = url.normalizedURL
|
||||||
guard URL(string: urlString) != nil else {
|
guard URL(string: urlString) != nil else {
|
||||||
@@ -74,7 +129,12 @@ struct AddBookmarkView: View {
|
|||||||
error = nil
|
error = nil
|
||||||
Task {
|
Task {
|
||||||
do {
|
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()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
@@ -128,8 +128,16 @@ final class BookmarksViewModel {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func addBookmark(url: String, title: String, tags: [String]) async throws {
|
func addBookmark(url: String, title: String, description: String = "", tags: [String]) async throws {
|
||||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
let create = BookmarkCreate(
|
||||||
|
url: url,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
tagNames: tags,
|
||||||
|
isArchived: false,
|
||||||
|
unread: false,
|
||||||
|
shared: false
|
||||||
|
)
|
||||||
let bookmark = try await api.createBookmark(create)
|
let bookmark = try await api.createBookmark(create)
|
||||||
bookmarks.insert(bookmark, at: 0)
|
bookmarks.insert(bookmark, at: 0)
|
||||||
Task { await SpotlightIndexer.index([bookmark]) }
|
Task { await SpotlightIndexer.index([bookmark]) }
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ final class PodcastPlayerViewModel {
|
|||||||
private var interruptionObserver: Any?
|
private var interruptionObserver: Any?
|
||||||
private var currentPodcastTitle: String = "Marks Podcast"
|
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
|
// Idempotent — don't restart if already generating or playing this article
|
||||||
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
||||||
|
|
||||||
@@ -99,7 +106,12 @@ final class PodcastPlayerViewModel {
|
|||||||
|
|
||||||
pollingTask = Task {
|
pollingTask = Task {
|
||||||
do {
|
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 {
|
while !Task.isCancelled {
|
||||||
let job = try await claude.podcastStatus(jobId: jobId)
|
let job = try await claude.podcastStatus(jobId: jobId)
|
||||||
phase = .generating(
|
phase = .generating(
|
||||||
|
|||||||
355
Marks/Views/SourcesView.swift
Normal file
355
Marks/Views/SourcesView.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,4 +104,76 @@ final class AppIntentsTests: XCTestCase {
|
|||||||
func testAppShortcutsAreRegistered() {
|
func testAppShortcutsAreRegistered() {
|
||||||
XCTAssertGreaterThanOrEqual(MarksShortcuts.appShortcuts.count, 4)
|
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 = "<p>Save this: <a href=\"https://example.com/a?x=1&y=2\">article</a></p>"
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import SwiftUI
|
|||||||
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
|
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
|
||||||
struct ShareView: View {
|
struct ShareView: View {
|
||||||
let url: String
|
let url: String
|
||||||
|
let initialTitle: String?
|
||||||
|
let initialNotes: String
|
||||||
/// Called when the extension should dismiss. `saved` is true after a successful
|
/// Called when the extension should dismiss. `saved` is true after a successful
|
||||||
/// write, false on cancel.
|
/// write, false on cancel.
|
||||||
let onClose: (_ saved: Bool) -> Void
|
let onClose: (_ saved: Bool) -> Void
|
||||||
@@ -26,10 +28,14 @@ struct ShareView: View {
|
|||||||
|
|
||||||
private let api: LinkdingAPI?
|
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.url = url
|
||||||
|
self.initialTitle = initialTitle
|
||||||
|
self.initialNotes = initialNotes
|
||||||
self.onClose = onClose
|
self.onClose = onClose
|
||||||
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
||||||
|
_title = State(initialValue: initialTitle ?? "")
|
||||||
|
_notes = State(initialValue: initialNotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Phase { case loading, editing, saving, done, failed }
|
private enum Phase { case loading, editing, saving, done, failed }
|
||||||
@@ -225,6 +231,14 @@ struct ShareView: View {
|
|||||||
// MARK: - Networking
|
// MARK: - Networking
|
||||||
|
|
||||||
private func check() async {
|
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 {
|
guard let api else {
|
||||||
errorMessage = "Open Marks first"
|
errorMessage = "Open Marks first"
|
||||||
phase = .failed
|
phase = .failed
|
||||||
@@ -239,8 +253,11 @@ struct ShareView: View {
|
|||||||
notes = bookmark.description
|
notes = bookmark.description
|
||||||
readLater = bookmark.unread
|
readLater = bookmark.unread
|
||||||
} else {
|
} else {
|
||||||
title = result.metadata?.title ?? ""
|
title = initialTitle ?? result.metadata?.title ?? ""
|
||||||
suggestedTags = result.autoTags ?? []
|
suggestedTags = result.autoTags ?? []
|
||||||
|
if notes.isEmpty {
|
||||||
|
notes = result.metadata?.description ?? initialNotes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Pre-enable the toggle when the current tags match the auto-rule.
|
// Pre-enable the toggle when the current tags match the auto-rule.
|
||||||
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
|
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
|
||||||
@@ -295,6 +312,7 @@ struct ShareView: View {
|
|||||||
let create = BookmarkCreate(
|
let create = BookmarkCreate(
|
||||||
url: url,
|
url: url,
|
||||||
title: "",
|
title: "",
|
||||||
|
description: notes,
|
||||||
tagNames: parsedTags,
|
tagNames: parsedTags,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
unread: readLater,
|
unread: readLater,
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
@objc(ShareViewController)
|
@objc(ShareViewController)
|
||||||
class ShareViewController: UIViewController {
|
class ShareViewController: UIViewController {
|
||||||
|
private let supportedTypeIdentifiers = [
|
||||||
|
UTType.url.identifier,
|
||||||
|
UTType.html.identifier,
|
||||||
|
UTType.rtf.identifier,
|
||||||
|
UTType.plainText.identifier,
|
||||||
|
UTType.text.identifier
|
||||||
|
]
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
@@ -11,37 +18,49 @@ class ShareViewController: UIViewController {
|
|||||||
blur.frame = view.bounds
|
blur.frame = view.bounds
|
||||||
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
view.addSubview(blur)
|
view.addSubview(blur)
|
||||||
extractURL()
|
extractPayload()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractURL() {
|
private func extractPayload() {
|
||||||
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||||
let attachments = item.attachments else {
|
let attachments = item.attachments else {
|
||||||
present(url: nil)
|
present(payload: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func load(typeId: String) -> Bool {
|
let candidates = attachments.flatMap { attachment in
|
||||||
guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else {
|
supportedTypeIdentifiers
|
||||||
return false
|
.filter { attachment.hasItemConformingToTypeIdentifier($0) }
|
||||||
}
|
.map { (attachment, $0) }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
loadNext(candidates, index: 0)
|
||||||
present(url: nil)
|
}
|
||||||
|
|
||||||
|
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.
|
/// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
|
||||||
private func present(url: String?) {
|
private func present(payload: IngestPayload?) {
|
||||||
let root = ShareView(url: url ?? "") { [weak self] _ in
|
let root = ShareView(
|
||||||
|
url: payload?.url ?? "",
|
||||||
|
initialTitle: payload?.title,
|
||||||
|
initialNotes: payload?.notes ?? ""
|
||||||
|
) { [weak self] _ in
|
||||||
self?.close()
|
self?.close()
|
||||||
}
|
}
|
||||||
let host = UIHostingController(rootView: root)
|
let host = UIHostingController(rootView: root)
|
||||||
@@ -52,7 +71,7 @@ class ShareViewController: UIViewController {
|
|||||||
view.addSubview(host.view)
|
view.addSubview(host.view)
|
||||||
host.didMove(toParent: self)
|
host.didMove(toParent: self)
|
||||||
|
|
||||||
if url == nil {
|
if payload == nil {
|
||||||
// No URL to work with — let the card render its failure state briefly.
|
// 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() }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ targets:
|
|||||||
# Shared with the main app so the extension can talk to linkding + the AI
|
# Shared with the main app so the extension can talk to linkding + the AI
|
||||||
# backend (the save card checks for duplicates and suggests tags).
|
# backend (the save card checks for duplicates and suggests tags).
|
||||||
- path: Marks/Models/Bookmark.swift
|
- path: Marks/Models/Bookmark.swift
|
||||||
|
- path: Marks/Models/IngestPayload.swift
|
||||||
- path: Marks/Models/ServerConfig.swift
|
- path: Marks/Models/ServerConfig.swift
|
||||||
- path: Marks/Services/LinkdingAPI.swift
|
- path: Marks/Services/LinkdingAPI.swift
|
||||||
- path: Marks/Services/MarksAuth.swift
|
- path: Marks/Services/MarksAuth.swift
|
||||||
|
|||||||
Reference in New Issue
Block a user