Compare commits

3 Commits

Author SHA1 Message Date
Krishna Kumar
8e1d94a4ea Add source editing to ingest
All checks were successful
CI / build-and-deploy (push) Successful in 25s
2026-07-02 16:35:02 -05:00
Krishna Kumar
f7735431a8 Update OpenWiki for source ingest
All checks were successful
CI / build-and-deploy (push) Successful in 16s
2026-07-02 01:20:18 -05:00
Krishna Kumar
f8693f4be0 Add local source ingest and podcasts 2026-07-02 01:11:03 -05:00
22 changed files with 1245 additions and 58 deletions

View File

@@ -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 */,

View File

@@ -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

View File

@@ -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)
} }

View File

@@ -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 {

View 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: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
}
private static func shouldKeepNotes(for typeIdentifier: String?) -> Bool {
guard let typeIdentifier else { return true }
return !typeIdentifier.lowercased().contains("url")
}
}

View 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))
}
}

View File

@@ -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
} }

View File

@@ -0,0 +1,212 @@
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 update(_ source: IngestedSource, title: String, bodyText: String, tags: [String]) {
var updated = source
updated.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
updated.bodyText = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
updated.tags = tags
updated.modifiedAt = Date()
upsert(updated)
}
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
}
}

View File

@@ -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) {

View 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)")
}
}
}

View File

@@ -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

View File

@@ -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]) }

View File

@@ -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(

View File

@@ -0,0 +1,440 @@
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 editingSource: 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),
onEdit: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
editingSource = source
}
},
onPodcast: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
playOrGeneratePodcast(for: source)
}
}
)
}
.sheet(item: $editingSource) { source in
EditSourceView(source: source, library: library)
}
.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 EditSourceView: View {
let source: IngestedSource
@Bindable var library: IngestedSourceLibrary
@Environment(\.dismiss) private var dismiss
@State private var title: String
@State private var bodyText: String
@State private var tagsText: String
init(source: IngestedSource, library: IngestedSourceLibrary) {
self.source = source
self.library = library
_title = State(initialValue: source.title)
_bodyText = State(initialValue: source.bodyText)
_tagsText = State(initialValue: source.tags.joined(separator: ", "))
}
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Optional", text: $title)
}
Section {
TextEditor(text: $bodyText)
.frame(minHeight: 220)
.textInputAutocapitalization(.sentences)
} header: {
Text(source.kind == .text ? "Text" : "Extracted Text")
} footer: {
if source.kind != .text {
Text("Editing extracted text does not modify the original file.")
}
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
}
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
library.update(source, title: title, bodyText: 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 onEdit: () -> Void
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)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
}
}
}
.quickLookPreview($previewURL)
}
private var podcastTitle: String {
if podcastGenerating { return "Generating" }
if podcastCached { return "Play Podcast" }
return "Create Podcast"
}
}

View File

@@ -104,4 +104,98 @@ 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&amp;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))
}
@MainActor
func testSourceLibraryUpdatesMetadataAndSearch() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let library = IngestedSourceLibrary(store: IngestedSourceStore(rootURL: root))
library.addText(title: "Draft", body: "Original extracted text", tags: ["raw"])
let source = try XCTUnwrap(library.sources.first)
library.update(source, title: "Cleaned Notes", bodyText: "Cleaned source body", tags: ["clean", "notes"])
let updated = try XCTUnwrap(library.sources.first)
XCTAssertEqual(updated.title, "Cleaned Notes")
XCTAssertEqual(updated.bodyText, "Cleaned source body")
XCTAssertEqual(updated.tags, ["clean", "notes"])
XCTAssertGreaterThanOrEqual(updated.modifiedAt, source.modifiedAt)
XCTAssertEqual(library.search("clean").first?.id, source.id)
let reloaded = IngestedSourceStore(rootURL: root)
XCTAssertEqual(try reloaded.load().first?.title, "Cleaned Notes")
}
} }

View File

@@ -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,

View File

@@ -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() }
} }

View File

@@ -7,7 +7,7 @@ This page explains how the major Marks runtime pieces fit together. For product-
`/project.yml` is the XcodeGen project definition. It defines four targets: `/project.yml` is the XcodeGen project definition. It defines four targets:
- `Marks` — the main iOS app. Sources include `/Marks` plus `/MarksWidget/WidgetDataStore.swift` so the app can write widget data. - `Marks` — the main iOS app. Sources include `/Marks` plus `/MarksWidget/WidgetDataStore.swift` so the app can write widget data.
- `ShareExtension` — app extension with `/ShareExtension` plus selected shared model/service files from `/Marks` (`Bookmark`, `ServerConfig`, `LinkdingAPI`, `MarksAuth`, `Log`, `PodcastRequests`). - `ShareExtension` — app extension with `/ShareExtension` plus selected shared model/service files from `/Marks` (`Bookmark`, `IngestPayload`, `ServerConfig`, `LinkdingAPI`, `MarksAuth`, `Log`, `PodcastRequests`).
- `MarksWidget` — WidgetKit extension with `/MarksWidget`. - `MarksWidget` — WidgetKit extension with `/MarksWidget`.
- `MarksTests` — unit-test bundle depending on `Marks`. - `MarksTests` — unit-test bundle depending on `Marks`.
@@ -22,7 +22,8 @@ At startup:
1. `MarksApp` loads a saved `ServerConfig` or falls back to a built-in default config. 1. `MarksApp` loads a saved `ServerConfig` or falls back to a built-in default config.
2. `MainContainer` constructs `LinkdingAPI(config:)`. 2. `MainContainer` constructs `LinkdingAPI(config:)`.
3. `MainContainer` stores `BookmarksViewModel(api:cacheKey:)` in `@State`, keeping the model stable across SwiftUI re-renders. 3. `MainContainer` stores `BookmarksViewModel(api:cacheKey:)` in `@State`, keeping the model stable across SwiftUI re-renders.
4. A top-level `TabView` presents Bookmarks, Tags, Podcasts, and Search. 4. `MainContainer` also creates an `IngestedSourceLibrary` for local source storage.
5. A top-level `TabView` presents Bookmarks, Tags, Sources, Podcasts, and Search.
`MainContainer` also handles: `MainContainer` also handles:
@@ -70,6 +71,19 @@ Implemented operations include:
`Bookmark`, `BookmarkResponse`, create/update payloads, `BookmarkCheck`, tag response models, and `SmartCollection` live in `/Marks/Models/Bookmark.swift`. `Bookmark`, `BookmarkResponse`, create/update payloads, `BookmarkCheck`, tag response models, and `SmartCollection` live in `/Marks/Models/Bookmark.swift`.
## Local source ingest
Local source ingest is separate from linkding bookmarks. It is owned by:
- `/Marks/Models/IngestedSource.swift` — source metadata for text, PDF, and generic file imports.
- `/Marks/Services/IngestedSourceStore.swift` — JSON metadata persistence, security-scoped file copying, PDFKit text extraction, plain-text extraction, and local search.
- `/Marks/Services/SourceSpotlightIndexer.swift` — Spotlight indexing/removal for source title, body text, tags, kind, and original filename.
- `/Marks/Views/SourcesView.swift` — Sources tab list/search, text import, file importer, QuickLook preview, delete, and podcast actions.
`IngestedSourceStore` writes `Sources/sources.json` and imported originals under `Sources/files/` in Application Support. Text imports store only metadata/body text. File imports copy the selected file into app storage; PDF imports extract text with `PDFDocument`, while other files attempt UTF-8/ASCII extraction.
Source podcasts use `IngestedSource.podcastURL` (`marks-source://{uuid}`) as the podcast cache/index key and send up to 100,000 characters of `bodyText` to the backend as `text`.
## Configuration and app-group sharing ## Configuration and app-group sharing
`ServerConfig` contains linkding host, optional port, path, token, and HTTPS flag. Its extension stores config in two places: `ServerConfig` contains linkding host, optional port, path, token, and HTTPS flag. Its extension stores config in two places:
@@ -94,7 +108,7 @@ Security note: `/Marks/MarksApp.swift` currently includes a built-in default con
`/Marks/Services/AnalyticsService.swift` sends events through the backend path used by `ClaudeService`/`MarksAuth`. `/Marks/Services/AnalyticsService.swift` sends events through the backend path used by `ClaudeService`/`MarksAuth`.
Podcast audio is downloaded to Application Support under a `podcasts` directory using a stable hash of the article URL. `PodcastIndex` records metadata in `index.json`; `PodcastLibrary` reads that index for the Podcasts tab and widget metadata. Podcast generation accepts either a web URL or a local source pseudo-URL. `ClaudeService.generatePodcast(url:title:sourceText:sourceKind:)` always sends `url` and may also send `title`, `text`, and `source_kind`. Podcast audio is downloaded to Application Support under a `podcasts` directory using a stable hash of the article/source URL. `PodcastIndex` records metadata in `index.json`; `PodcastLibrary` reads that index for the Podcasts tab and widget metadata.
## On-device Ask and Spotlight ## On-device Ask and Spotlight
@@ -109,13 +123,13 @@ The app has two AI paths:
- `SpotlightBookmarkSearch`/`BookmarkSearchTool` search the index for relevant bookmarks. - `SpotlightBookmarkSearch`/`BookmarkSearchTool` search the index for relevant bookmarks.
- `BookmarkEntity` also conforms to indexed App Intents concepts for system-level entity discovery. - `BookmarkEntity` also conforms to indexed App Intents concepts for system-level entity discovery.
If changing bookmark fields, keep `Bookmark`, `BookmarkEntity`, Spotlight indexing, widget payloads, and row UI in sync. If changing bookmark fields, keep `Bookmark`, `BookmarkEntity`, Spotlight indexing, widget payloads, and row UI in sync. If changing source fields, keep `IngestedSource`, `IngestedSourceStore`, `SourceSpotlightIndexer`, Sources UI, and source podcast generation in sync.
## Share extension architecture ## Share extension architecture
The share extension is implemented by `/ShareExtension/ShareViewController.swift` and `/ShareExtension/ShareView.swift`. The share extension is implemented by `/ShareExtension/ShareViewController.swift` and `/ShareExtension/ShareView.swift`.
It accepts a shared URL/text payload, creates a SwiftUI save card, loads shared `ServerConfig`, checks linkding for an existing bookmark, and lets the user edit tags, notes, read-later state, and a Create Podcast toggle. It accepts shared URL, HTML, RTF, plain-text, and text payloads, uses `IngestPayloadParser` to find the first bookmarkable `http`/`https` URL and preserve non-URL notes, creates a SwiftUI save card, loads shared `ServerConfig`, checks linkding for an existing bookmark, and lets the user edit tags, notes, read-later state, and a Create Podcast toggle.
For suggestions, it combines: For suggestions, it combines:
@@ -123,7 +137,7 @@ For suggestions, it combines:
- the user's existing tag vocabulary from `fetchTags()`, - the user's existing tag vocabulary from `fetchTags()`,
- backend AI tag suggestions through `/ShareExtension/TagSuggester.swift`. - backend AI tag suggestions through `/ShareExtension/TagSuggester.swift`.
Existing bookmarks are updated rather than duplicated. New bookmarks are created through `LinkdingAPI`. Podcast generation is not performed inside the extension; it queues a `PodcastRequests` item in the app group for the main app to process later. Existing bookmarks are updated rather than duplicated. New bookmarks are created through `LinkdingAPI`. Podcast generation is not performed inside the extension; it queues a `PodcastRequests` item in the app group for the main app to process later. The same parser is used by in-app Add Bookmark text import.
## Widget architecture and deep links ## Widget architecture and deep links
@@ -157,4 +171,4 @@ Widget links use custom URLs built by `URL.marksBookmark(_:)` and `URL.marksPodc
## Recent history context ## Recent history context
Recent git history shows major investment in podcast UX and on-device AI: the latest merge added played/unplayed queues, background podcast generation, share-sheet audio requests, podcast library/index changes, and player improvements. A prior AI commit added on-device RAG, Spotlight indexing, and unified logging. Use those areas cautiously because they span app state, storage, widgets, share extension, and App Intents rather than a single view. Recent git history shows major investment in ingest and podcast UX: the latest change added local text/PDF/file sources, richer URL extraction for share/add flows, source Spotlight indexing, and source-backed podcast generation. Earlier podcast and AI changes added played/unplayed queues, background podcast generation, share-sheet audio requests, podcast library/index changes, player improvements, on-device RAG, Spotlight indexing, and unified logging. Use those areas cautiously because they span app state, storage, widgets, share extension, and App Intents rather than a single view.

View File

@@ -32,7 +32,7 @@ xcodegen generate
Common target-membership traps: Common target-membership traps:
- The main app includes `/MarksWidget/WidgetDataStore.swift` directly so it can write widget JSON. - The main app includes `/MarksWidget/WidgetDataStore.swift` directly so it can write widget JSON.
- The share extension includes selected shared files from `/Marks/Models` and `/Marks/Services`; if the extension needs new model/service code, add it in `/project.yml`. - The share extension includes selected shared files from `/Marks/Models` and `/Marks/Services`, including `IngestPayload.swift` for URL extraction; if the extension needs new model/service code, add it in `/project.yml`.
- The app, widget, and share extension all depend on matching app-group entitlements. - The app, widget, and share extension all depend on matching app-group entitlements.
## Testing ## Testing
@@ -46,6 +46,8 @@ Covered today:
- `IntentRouter` open/search setters. - `IntentRouter` open/search setters.
- Direct no-network `perform()` tests for Search and Open intents. - Direct no-network `perform()` tests for Search and Open intents.
- App Shortcuts registration count. - App Shortcuts registration count.
- `IngestPayloadParser` URL extraction from direct URLs, plain text, HTML-ish text, and notes.
- `IngestedSourceStore` save/load behavior plus plain-text file import into a temporary root.
Not currently covered by repository tests: Not currently covered by repository tests:
@@ -56,6 +58,7 @@ Not currently covered by repository tests:
- Widget timeline rendering/deep links. - Widget timeline rendering/deep links.
- Browser reading progress/reader mode. - Browser reading progress/reader mode.
- Podcast generation, playback, queue, played/unplayed, and background audio behavior. - Podcast generation, playback, queue, played/unplayed, and background audio behavior.
- Sources tab UI, QuickLook presentation, PDF extraction edge cases, and source-backed podcast backend behavior.
Suggested test command: Suggested test command:
@@ -88,17 +91,26 @@ AI changes:
Share extension changes: Share extension changes:
- Share a new URL and an already-saved URL. - Share a new URL, an already-saved URL, and text/HTML/RTF content that contains a URL plus notes.
- Verify duplicate-aware update behavior. - Verify duplicate-aware update behavior.
- Test linkding auto-tags, AI suggestion chips, tag parsing, notes, read-later, and Create Podcast. - Test linkding auto-tags, AI suggestion chips, tag parsing, notes, read-later, and Create Podcast.
- Confirm the extension works when shared config is missing; current UI shows an "Open Marks first" style failure. - Confirm the extension works when shared config is missing; current UI shows an "Open Marks first" style failure.
Source ingest changes:
- Import typed text with title/tags and confirm it appears in Sources search.
- Import a text file and a PDF through the file importer; confirm originals copy into Application Support and QuickLook opens imported files.
- Confirm PDF text extraction works for text PDFs and handles image-only PDFs gracefully.
- Delete a source and confirm metadata, copied file, and Spotlight entry are removed.
- Create a podcast from a source and confirm cached playback uses the same `marks-source://` entry.
Podcast changes: Podcast changes:
- Generate from bookmark row and Browser view. - Generate from bookmark row and Browser view.
- Start generation while another podcast is playing; confirm background generation does not interrupt playback. - Start generation while another podcast is playing; confirm background generation does not interrupt playback.
- Test queue playback, played/unplayed state, delete, sleep timer, saved speed, background audio, remote controls, now-playing metadata, relaunch restore, and widget recent podcasts. - Test queue playback, played/unplayed state, delete, sleep timer, saved speed, background audio, remote controls, now-playing metadata, relaunch restore, and widget recent podcasts.
- Share a URL with Create Podcast and confirm the main app drains and starts the queued request. - Share a URL with Create Podcast and confirm the main app drains and starts the queued request.
- Generate from a local text/PDF source and confirm backend support for `text` and `source_kind`.
Widget/deep-link changes: Widget/deep-link changes:
@@ -136,6 +148,8 @@ App-group defaults/files (`group.com.magicive.marks`):
Application Support files: Application Support files:
- `bookmarks_cache.json` or `bookmarks_{host}.json` — cached bookmarks. - `bookmarks_cache.json` or `bookmarks_{host}.json` — cached bookmarks.
- `Sources/sources.json` — local source metadata.
- `Sources/files/{uuid}.{ext}` — copied originals for imported PDFs/files.
- `podcasts/{hash}.mp3` — generated podcast audio. - `podcasts/{hash}.mp3` — generated podcast audio.
- `podcasts/index.json` — podcast metadata and played state. - `podcasts/index.json` — podcast metadata and played state.
@@ -148,7 +162,8 @@ Application Support files:
- `MarksAuth` stores backend JWT/device state in `UserDefaults`, not Keychain. - `MarksAuth` stores backend JWT/device state in `UserDefaults`, not Keychain.
- App-group ID strings are duplicated in code and entitlements; update all occurrences together if changing it. - App-group ID strings are duplicated in code and entitlements; update all occurrences together if changing it.
- Share-extension podcast queue draining clears requests before generation success, so crash/retry semantics deserve care. - Share-extension podcast queue draining clears requests before generation success, so crash/retry semantics deserve care.
- Recent git history shows podcast and on-device AI features were added across many files; avoid narrow changes that ignore cross-target data flow. - Source-backed podcast generation sends extracted text to the Marks backend; if the backend only accepts URL fetches, local source podcasts will fail even though bookmark podcasts still work.
- Recent git history shows source ingest, podcast, and on-device AI features were added across many files; avoid narrow changes that ignore cross-target data flow.
## Existing design guidance ## Existing design guidance

View File

@@ -1,19 +1,19 @@
# Marks OpenWiki Quickstart # Marks OpenWiki Quickstart
Marks is a native SwiftUI iOS client for [linkding](https://github.com/sissbruecker/linkding), a self-hosted bookmark manager. The app targets iOS 26 and Swift 6, and includes a main app, share extension, widget extension, App Intents, AI-assisted bookmark features, and podcast generation/playback. Marks is a native SwiftUI iOS client for [linkding](https://github.com/sissbruecker/linkding), a self-hosted bookmark manager. The app targets iOS 26 and Swift 6, and includes a main app, share extension, widget extension, App Intents, AI-assisted bookmark features, local source ingest, and podcast generation/playback.
Start here when changing the repository, then follow the page links below for the area you are touching. Start here when changing the repository, then follow the page links below for the area you are touching.
## What this repository contains ## What this repository contains
- **Main iOS app** in `/Marks` with SwiftUI tabs for bookmarks, tags, podcasts, and search. The entrypoint is `/Marks/MarksApp.swift`. - **Main iOS app** in `/Marks` with SwiftUI tabs for bookmarks, tags, sources, podcasts, and search. The entrypoint is `/Marks/MarksApp.swift`.
- **Models** in `/Marks/Models`, centered on `Bookmark`, linkding response/create/update payloads, tags, smart collections, and `ServerConfig`. - **Models** in `/Marks/Models`, centered on `Bookmark`, linkding response/create/update payloads, tags, smart collections, `ServerConfig`, URL ingest payloads, and locally ingested sources.
- **Services** in `/Marks/Services` for linkding networking, AI/backend calls, auth, on-device bookmark Q&A, Spotlight indexing, widget/podcast data, analytics, and logging. - **Services** in `/Marks/Services` for linkding networking, AI/backend calls, auth, on-device bookmark Q&A, Spotlight indexing, source storage, widget/podcast data, analytics, and logging.
- **Views** in `/Marks/Views` for the primary product surfaces. `BookmarksViewModel` is the central app state object. - **Views** in `/Marks/Views` for the primary product surfaces. `BookmarksViewModel` is the central app state object.
- **App Intents** in `/Marks/Intents` for opening, searching, adding, listing unread, and summarizing bookmarks. - **App Intents** in `/Marks/Intents` for opening, searching, adding, listing unread, and summarizing bookmarks.
- **Share extension** in `/ShareExtension` for saving shared URLs/text to linkding and queueing podcast generation. - **Share extension** in `/ShareExtension` for extracting URLs from shared URLs, text, HTML, and RTF, saving to linkding, and queueing podcast generation.
- **Widget extension** in `/MarksWidget` for recent bookmarks, random bookmark, and recent podcast widgets. - **Widget extension** in `/MarksWidget` for recent bookmarks, random bookmark, and recent podcast widgets.
- **Tests** in `/MarksTests`, currently focused on the App Intents layer. - **Tests** in `/MarksTests`, currently focused on App Intents plus ingest parser/source-store coverage.
- **XcodeGen project spec** in `/project.yml`; `Marks.xcodeproj` is generated/checked in but `project.yml` is the project definition to inspect first. - **XcodeGen project spec** in `/project.yml`; `Marks.xcodeproj` is generated/checked in but `project.yml` is the project definition to inspect first.
- **Design notes** in `/docs/design-wwdc26.md` for iOS 26/Liquid Glass, search, widgets, and row-design guidance. - **Design notes** in `/docs/design-wwdc26.md` for iOS 26/Liquid Glass, search, widgets, and row-design guidance.
@@ -46,9 +46,11 @@ Adjust the simulator name to what is installed locally.
## Runtime architecture in one minute ## Runtime architecture in one minute
At launch, `MarksApp` loads a `ServerConfig`, constructs `LinkdingAPI`, and stores a shared `BookmarksViewModel` in `MainContainer` so it survives SwiftUI re-renders (`/Marks/MarksApp.swift`, `/Marks/Views/BookmarksViewModel.swift`). The top-level `TabView` has Bookmarks, Tags, Podcasts, and Search tabs. At launch, `MarksApp` loads a `ServerConfig`, constructs `LinkdingAPI`, and stores a shared `BookmarksViewModel` in `MainContainer` so it survives SwiftUI re-renders (`/Marks/MarksApp.swift`, `/Marks/Views/BookmarksViewModel.swift`). The top-level `TabView` has Bookmarks, Tags, Sources, Podcasts, and Search tabs.
`BookmarksViewModel` fetches paginated bookmarks from linkding through `LinkdingAPI`, restores local AI metadata, caches bookmarks to Application Support, writes recent bookmark metadata for widgets, and indexes bookmarks into Spotlight. It also owns `ClaudeService`, `PodcastPlayerViewModel`, and `PodcastGenerationManager`, so AI, podcast, search, and list state are shared across tabs. `BookmarksViewModel` fetches paginated bookmarks from linkding through `LinkdingAPI`, restores local AI metadata, caches bookmarks to Application Support, writes recent bookmark metadata for widgets, and indexes bookmarks into Spotlight. It also owns `ClaudeService`, `PodcastPlayerViewModel`, and `PodcastGenerationManager`, so AI, podcast, search, and list state are shared across tabs. `MainContainer` separately owns `IngestedSourceLibrary` for the Sources tab.
Local sources are stored in Application Support under `Sources/`: `sources.json` stores metadata, `Sources/files/` stores imported files, PDF text is extracted with PDFKit, and sources are indexed into Spotlight through `SourceSpotlightIndexer`.
Extensions and widgets use the shared app group `group.com.magicive.marks`. `ServerConfig.save()` writes to standard defaults and the app-group defaults; `WidgetDataStore` writes app-group JSON files for widgets; `PodcastRequests` uses app-group defaults to hand podcast requests from the share extension to the main app. Extensions and widgets use the shared app group `group.com.magicive.marks`. `ServerConfig.save()` writes to standard defaults and the app-group defaults; `WidgetDataStore` writes app-group JSON files for widgets; `PodcastRequests` uses app-group defaults to hand podcast requests from the share extension to the main app.
@@ -56,7 +58,7 @@ Extensions and widgets use the shared app group `group.com.magicive.marks`. `Ser
- **linkding** is the source of truth for bookmarks and tags. `LinkdingAPI` uses token auth and calls linkding bookmark, check, tag, create, update, delete, archive, and connection-test endpoints. - **linkding** is the source of truth for bookmarks and tags. `LinkdingAPI` uses token auth and calls linkding bookmark, check, tag, create, update, delete, archive, and connection-test endpoints.
- **Marks backend** is used by `MarksAuth`/`ClaudeService` for AI enrichment, semantic search, smart collections, analytics events, and podcast generation/audio download. - **Marks backend** is used by `MarksAuth`/`ClaudeService` for AI enrichment, semantic search, smart collections, analytics events, and podcast generation/audio download.
- **Apple on-device intelligence and Spotlight** power the in-app Ask surface via `FoundationModels`, `CoreSpotlight`, and the `BookmarkSearchTool` retrieval path. - **Apple on-device intelligence and Spotlight** power the in-app Ask surface via `FoundationModels`, `CoreSpotlight`, and the `BookmarkSearchTool` retrieval path. Spotlight is also used for locally ingested sources.
- **WidgetKit, App Intents, AVFoundation, and MediaPlayer** support widgets, Siri/Shortcuts integration, podcast playback, background audio, now-playing metadata, and remote controls. - **WidgetKit, App Intents, AVFoundation, and MediaPlayer** support widgets, Siri/Shortcuts integration, podcast playback, background audio, now-playing metadata, and remote controls.
Do not document or expose secret values. `/Marks/MarksApp.swift` currently contains a built-in default server configuration with a token-like value; treat it as sensitive source material and avoid copying it into docs, logs, screenshots, or tests. Do not document or expose secret values. `/Marks/MarksApp.swift` currently contains a built-in default server configuration with a token-like value; treat it as sensitive source material and avoid copying it into docs, logs, screenshots, or tests.
@@ -68,14 +70,16 @@ Do not document or expose secret values. `/Marks/MarksApp.swift` currently conta
- Settings' disconnect callback currently resets to the built-in default config path rather than forcing onboarding. Confirm intended product behavior before changing account/config flows. - Settings' disconnect callback currently resets to the built-in default config path rather than forcing onboarding. Confirm intended product behavior before changing account/config flows.
- `BookmarksViewModel` is shared across tabs, so changes to bookmark arrays, filters, search, enrichment, and pagination can affect Bookmarks, Tags, Search, Podcasts, widgets, and Spotlight indexing. - `BookmarksViewModel` is shared across tabs, so changes to bookmark arrays, filters, search, enrichment, and pagination can affect Bookmarks, Tags, Search, Podcasts, widgets, and Spotlight indexing.
- Share-extension podcast requests are queued in shared defaults and drained by the main app on launch/foreground. Be careful with crash/retry semantics when touching that flow. - Share-extension podcast requests are queued in shared defaults and drained by the main app on launch/foreground. Be careful with crash/retry semantics when touching that flow.
- Existing automated tests cover App Intents only. For UI, networking, sync, share extension, widgets, and podcasts, add tests where feasible and perform manual verification. - Local source podcasts use `marks-source://{uuid}` pseudo-URLs plus extracted text sent to `ClaudeService.generatePodcast(...)`. The backend must accept `text` and `source_kind` for non-web podcast generation.
- Existing automated tests cover App Intents plus focused ingest parser/source-store behavior. For UI, networking, sync, share extension, widgets, and podcasts, add tests where feasible and perform manual verification.
## Where to start for common changes ## Where to start for common changes
- Bookmark list, pagination, search, tags, enrichment state: `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/BookmarksView.swift`, `/Marks/Services/LinkdingAPI.swift`. - Bookmark list, pagination, search, tags, enrichment state: `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/BookmarksView.swift`, `/Marks/Services/LinkdingAPI.swift`.
- Linkding API changes: `/Marks/Services/LinkdingAPI.swift`, `/Marks/Models/Bookmark.swift`, `/ShareExtension/ShareView.swift`, `/Marks/Intents/IntentSupport.swift`. - Linkding API changes: `/Marks/Services/LinkdingAPI.swift`, `/Marks/Models/Bookmark.swift`, `/ShareExtension/ShareView.swift`, `/Marks/Intents/IntentSupport.swift`.
- AI/backend changes: `/Marks/Services/ClaudeService.swift`, `/Marks/Services/MarksAuth.swift`, `/Marks/Services/BookmarkAssistant.swift`, `/Marks/Services/BookmarkSearchTool.swift`. - AI/backend changes: `/Marks/Services/ClaudeService.swift`, `/Marks/Services/MarksAuth.swift`, `/Marks/Services/BookmarkAssistant.swift`, `/Marks/Services/BookmarkSearchTool.swift`.
- Podcast generation/playback: `/Marks/Services/PodcastGenerationManager.swift`, `/Marks/Services/PodcastIndex.swift`, `/Marks/Views/PodcastPlayerView.swift`, `/Marks/Views/BookmarksViewModel.swift`. - Source ingest and local documents: `/Marks/Views/SourcesView.swift`, `/Marks/Models/IngestedSource.swift`, `/Marks/Services/IngestedSourceStore.swift`, `/Marks/Services/SourceSpotlightIndexer.swift`.
- Share extension: `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, plus shared files declared in `/project.yml`. - Podcast generation/playback: `/Marks/Services/PodcastGenerationManager.swift`, `/Marks/Services/PodcastIndex.swift`, `/Marks/Views/PodcastPlayerView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/SourcesView.swift`.
- Share extension and text/link extraction: `/Marks/Models/IngestPayload.swift`, `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, plus shared files declared in `/project.yml`.
- Widgets/deep links: `/MarksWidget/WidgetDataStore.swift`, `/MarksWidget/*Widget.swift`, `/Marks/MarksApp.swift`. - Widgets/deep links: `/MarksWidget/WidgetDataStore.swift`, `/MarksWidget/*Widget.swift`, `/Marks/MarksApp.swift`.
- App Intents/Siri/Shortcuts: `/Marks/Intents/*`, `/MarksTests/AppIntentsTests.swift`. - App Intents/Siri/Shortcuts: `/Marks/Intents/*`, `/MarksTests/AppIntentsTests.swift`.

View File

@@ -20,7 +20,7 @@ Change guidance:
Primary sources: `/Marks/Views/AddBookmarkView.swift`, `/Marks/Views/EditBookmarkView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Models/Bookmark.swift`. Primary sources: `/Marks/Views/AddBookmarkView.swift`, `/Marks/Views/EditBookmarkView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Models/Bookmark.swift`.
The Add view collects URL, optional title, and comma-separated tags, normalizes/validates the URL, and calls `BookmarksViewModel.addBookmark(url:title:tags:)`. Added bookmarks are inserted locally, indexed into Spotlight, and enriched in a background task. The Add view collects URL, optional title, description/notes, and comma-separated tags, normalizes/validates the URL, and calls `BookmarksViewModel.addBookmark(url:title:description:tags:)`. It also has a paste/import text path that uses `IngestPayloadParser` to extract the first `http`/`https` URL from plain text and preserve surrounding notes as the bookmark description. Added bookmarks are inserted locally, indexed into Spotlight, and enriched in a background task.
The Edit view updates URL, title, description, tags, unread, and shared state through `BookmarkUpdate`. `BookmarksViewModel.updateBookmark(_:)` preserves locally stored AI summary/tags when replacing the bookmark returned from linkding. The Edit view updates URL, title, description, tags, unread, and shared state through `BookmarkUpdate`. `BookmarksViewModel.updateBookmark(_:)` preserves locally stored AI summary/tags when replacing the bookmark returned from linkding.
@@ -80,9 +80,9 @@ Change guidance:
## Share extension save flow ## Share extension save flow
Primary sources: `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, `/Marks/Services/PodcastRequests.swift`, `/project.yml`. Primary sources: `/Marks/Models/IngestPayload.swift`, `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, `/Marks/Services/PodcastRequests.swift`, `/project.yml`.
The share extension extracts a URL/text payload and shows an interactive save card. On load it: The share extension scans URL, HTML, RTF, plain-text, and text attachments, then `IngestPayloadParser` extracts the first bookmarkable web URL. For non-URL text payloads it also preserves cleaned surrounding text as notes. On load it:
1. reads linkding config from app-group defaults, 1. reads linkding config from app-group defaults,
2. calls `LinkdingAPI.checkBookmark(url:)`, 2. calls `LinkdingAPI.checkBookmark(url:)`,
@@ -96,13 +96,37 @@ Change guidance:
- The extension is short-lived. Keep long-running work in the main app. - The extension is short-lived. Keep long-running work in the main app.
- The extension depends on shared source files listed in `/project.yml`; adding service dependencies may require target membership updates. - The extension depends on shared source files listed in `/project.yml`; adding service dependencies may require target membership updates.
- `IngestPayloadParser` intentionally ignores non-HTTP(S) URLs and caps preserved notes at 1,800 characters.
- `ShareExtension/Info.plist` currently uses a broad activation rule. Narrowing supported payloads is a product decision. - `ShareExtension/Info.plist` currently uses a broad activation rule. Narrowing supported payloads is a product decision.
## Sources
Primary sources: `/Marks/Views/SourcesView.swift`, `/Marks/Models/IngestedSource.swift`, `/Marks/Services/IngestedSourceStore.swift`, `/Marks/Services/SourceSpotlightIndexer.swift`.
The Sources tab stores non-web material in Marks. Users can:
- import typed/pasted text with an optional title and tags,
- import PDFs, plain text files, generic text/data files through the document picker,
- edit source title, tags, and stored body/extracted text,
- search across source title, body text, tags, and original filename,
- open imported originals through QuickLook,
- delete sources and copied files,
- create or play a podcast from extractable source text.
`IngestedSourceLibrary.load()` reads `Sources/sources.json` from Application Support and indexes sources into Spotlight. Text imports write metadata/body text directly. File imports copy the selected security-scoped file into `Sources/files/`; PDFs extract text with PDFKit, while other files attempt UTF-8/ASCII extraction.
Editing an imported file source updates Marks' stored extracted text and metadata, but does not modify the copied original file.
Change guidance:
- Empty or image-only PDFs may import with no extractable text; podcast actions are disabled when `podcastText` is empty.
- Source podcasts use `marks-source://{uuid}` as the cache/index URL and send extracted text plus `source_kind` to the backend.
- If changing source storage, update editing, deletion, Spotlight removal, QuickLook file lookup, and source podcast cache behavior together.
## Podcasts ## Podcasts
Primary sources: `/Marks/Views/PodcastPlayerView.swift`, `/Marks/Services/PodcastGenerationManager.swift`, `/Marks/Services/PodcastIndex.swift`, `/Marks/Services/PodcastLibrary.swift`, `/Marks/Services/ClaudeService.swift`, `/Marks/Services/PodcastRequests.swift`, `/Marks/Info.plist`. Primary sources: `/Marks/Views/PodcastPlayerView.swift`, `/Marks/Services/PodcastGenerationManager.swift`, `/Marks/Services/PodcastIndex.swift`, `/Marks/Services/PodcastLibrary.swift`, `/Marks/Services/ClaudeService.swift`, `/Marks/Services/PodcastRequests.swift`, `/Marks/Info.plist`.
Podcast generation converts an article URL into audio through the Marks backend: Podcast generation converts an article URL, or a locally ingested source with extracted text, into audio through the Marks backend:
1. create generation job (`/v1/podcast/generate`), 1. create generation job (`/v1/podcast/generate`),
2. poll status every few seconds, 2. poll status every few seconds,
@@ -112,7 +136,7 @@ Podcast generation converts an article URL into audio through the Marks backend:
6. reload `PodcastLibrary`, 6. reload `PodcastLibrary`,
7. write recent podcast metadata for widgets. 7. write recent podcast metadata for widgets.
`BookmarksViewModel.playOrGeneratePodcast(...)` prevents interrupting current playback. If the player is idle, it starts foreground generation/playback and callers present the player. If busy, it enqueues background generation through `PodcastGenerationManager`. `BookmarksViewModel.playOrGeneratePodcast(...)` prevents interrupting current bookmark playback. If the player is idle, it starts foreground generation/playback and callers present the player. If busy, it enqueues background generation through `PodcastGenerationManager`. `SourcesView` follows the same foreground/background pattern for local sources, using `marks-source://{uuid}` plus `sourceText`/`sourceKind`.
The player uses AVFoundation/MediaPlayer for playback, background audio, progress, saved playback speed, restored paused session, queue playback, now-playing metadata, remote controls, sleep timer, and played/unplayed state. The player uses AVFoundation/MediaPlayer for playback, background audio, progress, saved playback speed, restored paused session, queue playback, now-playing metadata, remote controls, sleep timer, and played/unplayed state.
@@ -121,6 +145,7 @@ Change guidance:
- The main app plist enables background audio and the custom `marks` URL scheme. - The main app plist enables background audio and the custom `marks` URL scheme.
- Widget podcast metadata is in the app group, but audio files and `PodcastIndex` are in the main app's Application Support directory. - Widget podcast metadata is in the app group, but audio files and `PodcastIndex` are in the main app's Application Support directory.
- Share-extension queued podcast requests are drained by `BookmarksViewModel.processPendingPodcastRequests()` on launch/foreground. Consider retry semantics before changing drain behavior. - Share-extension queued podcast requests are drained by `BookmarksViewModel.processPendingPodcastRequests()` on launch/foreground. Consider retry semantics before changing drain behavior.
- Source-backed podcast generation depends on backend support for the optional `text` and `source_kind` fields in `POST /v1/podcast/generate`.
## Widgets and deep links ## Widgets and deep links

View File

@@ -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