Compare commits
10 Commits
f8de444f8f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1d94a4ea | ||
|
|
f7735431a8 | ||
|
|
f8693f4be0 | ||
|
|
4d875e12d6 | ||
| b496e607fc | |||
|
|
1f49a7dcbe | ||
|
|
a155c15e17 | ||
|
|
d2ccbc94b5 | ||
|
|
5e69d99996 | ||
|
|
af3112530e |
10
AGENTS.md
Normal file
10
AGENTS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## OpenWiki
|
||||
|
||||
This repository has documentation located in the /openwiki directory.
|
||||
|
||||
Start here:
|
||||
- [OpenWiki quickstart](openwiki/quickstart.md)
|
||||
|
||||
OpenWiki includes repository overview, architecture notes, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
|
||||
When working in this repository, read the OpenWiki quickstart first, then follow its links to the relevant architecture, workflow, domain, operation, and testing notes.
|
||||
@@ -7,35 +7,45 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */; };
|
||||
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
|
||||
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
||||
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; };
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
|
||||
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */; };
|
||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
|
||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
|
||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
|
||||
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */; };
|
||||
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; };
|
||||
83CC9BA3176B05358A307373 /* IngestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */; };
|
||||
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; };
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
|
||||
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
||||
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
|
||||
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
|
||||
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; };
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
@@ -56,6 +66,7 @@
|
||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
|
||||
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
|
||||
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */; };
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
|
||||
@@ -108,8 +119,12 @@
|
||||
0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -128,6 +143,7 @@
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -148,13 +164,17 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
||||
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
|
||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -203,6 +223,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
||||
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */,
|
||||
0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */,
|
||||
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */,
|
||||
);
|
||||
@@ -244,14 +266,20 @@
|
||||
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */,
|
||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||
FB7728D15C17219ABFF3EFFE /* Log.swift */,
|
||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
|
||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
|
||||
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
|
||||
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */,
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
||||
);
|
||||
@@ -288,6 +316,7 @@
|
||||
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */,
|
||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
@@ -478,9 +507,11 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
|
||||
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
|
||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
|
||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
|
||||
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */,
|
||||
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */,
|
||||
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */,
|
||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */,
|
||||
@@ -504,6 +535,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */,
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
|
||||
@@ -520,6 +552,9 @@
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||
83CC9BA3176B05358A307373 /* IngestPayload.swift in Sources */,
|
||||
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */,
|
||||
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */,
|
||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||
@@ -528,11 +563,16 @@
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
|
||||
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
|
||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
|
||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||
031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */,
|
||||
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */,
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
|
||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
||||
|
||||
@@ -3,7 +3,7 @@ import AppIntents
|
||||
|
||||
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
|
||||
enum AppTab: Hashable {
|
||||
case bookmarks, tags, podcasts, search
|
||||
case bookmarks, tags, sources, podcasts, search
|
||||
}
|
||||
|
||||
/// Bridges App Intents (which run in the main app process, since there is no
|
||||
|
||||
@@ -38,6 +38,8 @@ struct MainContainer: View {
|
||||
@State private var showDeepLinkPlayer = false
|
||||
@State private var selectedTab: AppTab = .bookmarks
|
||||
@State private var router = IntentRouter.shared
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var sourceLibrary = IngestedSourceLibrary()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||
@@ -55,9 +57,18 @@ struct MainContainer: View {
|
||||
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
|
||||
TagsView(viewModel: viewModel)
|
||||
}
|
||||
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||
Tab("Sources", systemImage: "tray.full", value: AppTab.sources) {
|
||||
SourcesView(
|
||||
library: sourceLibrary,
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
}
|
||||
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
.badge(library.unplayedCount)
|
||||
Tab(value: AppTab.search, role: .search) {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
@@ -65,18 +76,28 @@ struct MainContainer: View {
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
.task { applyPendingIntent() }
|
||||
.task {
|
||||
applyPendingIntent()
|
||||
viewModel.processPendingPodcastRequests()
|
||||
viewModel.podcastPlayer.restoreSession(claude: viewModel.claude)
|
||||
}
|
||||
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
|
||||
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
|
||||
.onChange(of: scenePhase) { old, new in
|
||||
// Refresh when returning to the foreground (cold launch is already
|
||||
// covered by BookmarksView's .task, so only react to a real re-entry).
|
||||
if new == .active && old != .active {
|
||||
viewModel.processPendingPodcastRequests()
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
// Persist playback position + session when leaving the foreground so a
|
||||
// relaunch can resume where you left off.
|
||||
if new == .background {
|
||||
viewModel.podcastPlayer.saveProgress()
|
||||
}
|
||||
}
|
||||
.sheet(item: $deepLinkBrowser) { item in
|
||||
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
|
||||
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
.sheet(isPresented: $showDeepLinkPlayer) {
|
||||
PodcastPlayerView(
|
||||
|
||||
@@ -82,16 +82,35 @@ struct BookmarkCheck: Codable, Sendable {
|
||||
struct BookmarkCreate: Codable, Sendable {
|
||||
let url: String
|
||||
let title: String
|
||||
let description: String
|
||||
let tagNames: [String]
|
||||
let isArchived: Bool
|
||||
let unread: Bool
|
||||
let shared: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url, title, unread, shared
|
||||
case url, title, description, unread, shared
|
||||
case tagNames = "tag_names"
|
||||
case isArchived = "is_archived"
|
||||
}
|
||||
|
||||
init(
|
||||
url: String,
|
||||
title: String,
|
||||
description: String = "",
|
||||
tagNames: [String],
|
||||
isArchived: Bool,
|
||||
unread: Bool,
|
||||
shared: Bool
|
||||
) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.tagNames = tagNames
|
||||
self.isArchived = isArchived
|
||||
self.unread = unread
|
||||
self.shared = shared
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkUpdate: Codable, Sendable {
|
||||
|
||||
95
Marks/Models/IngestPayload.swift
Normal file
95
Marks/Models/IngestPayload.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
struct IngestPayload: Equatable, Sendable {
|
||||
let url: String
|
||||
let title: String?
|
||||
let notes: String
|
||||
}
|
||||
|
||||
enum IngestPayloadParser {
|
||||
static func parse(item: Any?, typeIdentifier: String? = nil) -> IngestPayload? {
|
||||
if let url = item as? URL {
|
||||
return payload(from: url.absoluteString, notes: "")
|
||||
}
|
||||
|
||||
if let attributed = item as? NSAttributedString {
|
||||
return payload(from: attributed.string, notes: attributed.string)
|
||||
}
|
||||
|
||||
if let string = item as? String {
|
||||
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
|
||||
}
|
||||
|
||||
if let data = item as? Data,
|
||||
let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) {
|
||||
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func payload(from text: String, notes: String) -> IngestPayload? {
|
||||
guard let url = firstWebURL(in: text) else { return nil }
|
||||
return IngestPayload(url: url, title: nil, notes: cleanedNotes(notes, excluding: url))
|
||||
}
|
||||
|
||||
static func firstWebURL(in text: String) -> String? {
|
||||
if let direct = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
isBookmarkable(direct) {
|
||||
return direct.absoluteString
|
||||
}
|
||||
|
||||
let decoded = decodeHTML(text)
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(decoded.startIndex..<decoded.endIndex, in: decoded)
|
||||
let match = detector?.matches(in: decoded, range: range).first { result in
|
||||
guard let url = result.url else { return false }
|
||||
return isBookmarkable(url)
|
||||
}
|
||||
|
||||
guard let matchRange = match?.range,
|
||||
let swiftRange = Range(matchRange, in: decoded) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidate = String(decoded[swiftRange])
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " \n\t\r<>\"'.,;:)]】」"))
|
||||
|
||||
guard let url = URL(string: candidate), isBookmarkable(url) else { return nil }
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
private static func isBookmarkable(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
url.host?.isEmpty == false else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func cleanedNotes(_ notes: String, excluding url: String) -> String {
|
||||
let trimmed = decodeHTML(notes)
|
||||
.replacingOccurrences(of: url, with: "")
|
||||
.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return String(trimmed.prefix(1800))
|
||||
}
|
||||
|
||||
private static func decodeHTML(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private static func shouldKeepNotes(for typeIdentifier: String?) -> Bool {
|
||||
guard let typeIdentifier else { return true }
|
||||
return !typeIdentifier.lowercased().contains("url")
|
||||
}
|
||||
}
|
||||
69
Marks/Models/IngestedSource.swift
Normal file
69
Marks/Models/IngestedSource.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
enum IngestedSourceKind: String, Codable, Sendable, CaseIterable {
|
||||
case text
|
||||
case pdf
|
||||
case file
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .text: "Text"
|
||||
case .pdf: "PDF"
|
||||
case .file: "File"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IngestedSource: Identifiable, Codable, Sendable, Hashable {
|
||||
let id: UUID
|
||||
var kind: IngestedSourceKind
|
||||
var title: String
|
||||
var bodyText: String
|
||||
var originalFilename: String?
|
||||
var localFilename: String?
|
||||
var tags: [String]
|
||||
var createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
kind: IngestedSourceKind,
|
||||
title: String,
|
||||
bodyText: String,
|
||||
originalFilename: String? = nil,
|
||||
localFilename: String? = nil,
|
||||
tags: [String] = [],
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.bodyText = bodyText
|
||||
self.originalFilename = originalFilename
|
||||
self.localFilename = localFilename
|
||||
self.tags = tags
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return originalFilename ?? kind.label
|
||||
}
|
||||
|
||||
var excerpt: String {
|
||||
bodyText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
|
||||
var podcastURL: String {
|
||||
"marks-source://\(id.uuidString)"
|
||||
}
|
||||
|
||||
var podcastText: String {
|
||||
String(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).prefix(100_000))
|
||||
}
|
||||
}
|
||||
27
Marks/Services/AISummaryStore.swift
Normal file
27
Marks/Services/AISummaryStore.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// URL-keyed cache of AI summaries so views that only know a URL — e.g. the
|
||||
/// deliberately-decoupled Podcasts tab — can show a bookmark's summary without
|
||||
/// holding the full `Bookmark`. Written by `BookmarksViewModel` as it enriches
|
||||
/// and loads bookmarks; read by the episode detail sheet.
|
||||
enum AISummaryStore {
|
||||
private static let key = "aiSummaryByURL"
|
||||
|
||||
static func summary(for url: String) -> String? {
|
||||
let store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
|
||||
let value = store[url]
|
||||
return (value?.isEmpty ?? true) ? nil : value
|
||||
}
|
||||
|
||||
static func set(_ summary: String?, for url: String) {
|
||||
var store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
|
||||
if let summary, !summary.isEmpty {
|
||||
guard store[url] != summary else { return } // no-op if unchanged
|
||||
store[url] = summary
|
||||
} else {
|
||||
guard store[url] != nil else { return }
|
||||
store.removeValue(forKey: url)
|
||||
}
|
||||
UserDefaults.standard.set(store, forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,13 @@ struct ClaudeService: Sendable {
|
||||
|
||||
// MARK: - Podcast
|
||||
|
||||
func generatePodcast(url: String) async throws -> String {
|
||||
func generatePodcast(url: String, title: String? = nil, sourceText: String? = nil, sourceKind: String? = nil) async throws -> String {
|
||||
struct Response: Decodable { let job_id: String }
|
||||
let data = try await post("/v1/podcast/generate", body: ["url": url])
|
||||
var body: [String: Any] = ["url": url]
|
||||
if let title, !title.isEmpty { body["title"] = title }
|
||||
if let sourceText, !sourceText.isEmpty { body["text"] = sourceText }
|
||||
if let sourceKind, !sourceKind.isEmpty { body["source_kind"] = sourceKind }
|
||||
let data = try await post("/v1/podcast/generate", body: body)
|
||||
return try JSONDecoder().decode(Response.self, from: data).job_id
|
||||
}
|
||||
|
||||
|
||||
212
Marks/Services/IngestedSourceStore.swift
Normal file
212
Marks/Services/IngestedSourceStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
127
Marks/Services/PodcastGenerationManager.swift
Normal file
127
Marks/Services/PodcastGenerationManager.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Runs podcast generation (generate → poll → download) **off** the player, so a
|
||||
/// new episode can be produced in the background while another one keeps playing.
|
||||
/// The finished audio is written to the on-disk cache and recorded in
|
||||
/// `PodcastIndex`; the optional `onReady` callback lets a caller start playback
|
||||
/// only when nothing else is playing.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PodcastGenerationManager {
|
||||
struct Job: Identifiable {
|
||||
let id: String // articleUrl
|
||||
var articleUrl: String
|
||||
var title: String
|
||||
var progress: Double // 0…1
|
||||
var label: String
|
||||
var failed: Bool = false
|
||||
var error: String?
|
||||
}
|
||||
|
||||
private(set) var jobs: [Job] = []
|
||||
|
||||
private var tasks: [String: Task<Void, Never>] = [:]
|
||||
private let claude = ClaudeService()
|
||||
|
||||
/// Jobs still working (not failed). Drives the "Generating…" banner.
|
||||
var activeJobs: [Job] { jobs.filter { !$0.failed } }
|
||||
var hasActive: Bool { !activeJobs.isEmpty }
|
||||
|
||||
func isGenerating(_ articleUrl: String) -> Bool { tasks[articleUrl] != nil }
|
||||
func job(for articleUrl: String) -> Job? { jobs.first { $0.articleUrl == articleUrl } }
|
||||
|
||||
/// Enqueue a background generation for `articleUrl`. Idempotent per URL.
|
||||
/// If audio is already cached, `onReady` fires immediately and nothing runs.
|
||||
/// `onReady` is invoked on the main actor once audio is available.
|
||||
func enqueue(articleUrl: String,
|
||||
title: String,
|
||||
parentBookmarkUrl: String? = nil,
|
||||
sourceText: String? = nil,
|
||||
sourceKind: String? = nil,
|
||||
onReady: ((URL, String) -> Void)? = nil) {
|
||||
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
if FileManager.default.fileExists(atPath: cached.path) {
|
||||
onReady?(cached, title)
|
||||
return
|
||||
}
|
||||
if tasks[articleUrl] != nil { return } // already in flight
|
||||
|
||||
jobs.insert(Job(id: articleUrl, articleUrl: articleUrl, title: title,
|
||||
progress: 0, label: "Starting…"), at: 0)
|
||||
Analytics.track("podcast.bg_started", ["url": articleUrl])
|
||||
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let jobId = try await claude.generatePodcast(
|
||||
url: articleUrl,
|
||||
title: title,
|
||||
sourceText: sourceText,
|
||||
sourceKind: sourceKind
|
||||
)
|
||||
while !Task.isCancelled {
|
||||
let status = try await claude.podcastStatus(jobId: jobId)
|
||||
update(articleUrl) {
|
||||
$0.progress = Double(status.progress) / 100
|
||||
$0.label = PodcastPlayerViewModel.statusLabel(status.status)
|
||||
}
|
||||
if status.isDone {
|
||||
update(articleUrl) { $0.label = "Preparing audio…"; $0.progress = 1 }
|
||||
let url = try await claude.downloadPodcastAudio(
|
||||
jobId: jobId, articleUrl: articleUrl,
|
||||
title: status.title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
let finalTitle = status.title ?? title
|
||||
finish(articleUrl)
|
||||
PodcastLibrary.shared.reload() // new episode → refresh list + badge
|
||||
Analytics.track("podcast.bg_completed", ["url": articleUrl])
|
||||
onReady?(url, finalTitle)
|
||||
return
|
||||
}
|
||||
if status.isFailed {
|
||||
fail(articleUrl, status.error ?? "Generation failed")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
}
|
||||
} catch is CancellationError {
|
||||
finish(articleUrl)
|
||||
} catch {
|
||||
fail(articleUrl, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
tasks[articleUrl] = task
|
||||
}
|
||||
|
||||
/// Cancel an in-flight job and drop it from the list.
|
||||
func cancel(_ articleUrl: String) {
|
||||
tasks[articleUrl]?.cancel()
|
||||
tasks[articleUrl] = nil
|
||||
jobs.removeAll { $0.articleUrl == articleUrl }
|
||||
}
|
||||
|
||||
func dismiss(_ articleUrl: String) { jobs.removeAll { $0.articleUrl == articleUrl } }
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func update(_ url: String, _ mutate: (inout Job) -> Void) {
|
||||
guard let i = jobs.firstIndex(where: { $0.articleUrl == url }) else { return }
|
||||
mutate(&jobs[i])
|
||||
}
|
||||
|
||||
private func finish(_ url: String) {
|
||||
tasks[url] = nil
|
||||
jobs.removeAll { $0.articleUrl == url }
|
||||
}
|
||||
|
||||
private func fail(_ url: String, _ message: String) {
|
||||
tasks[url] = nil
|
||||
update(url) { $0.failed = true; $0.error = message; $0.label = message }
|
||||
Analytics.track("podcast.bg_failed", ["url": url, "error": message])
|
||||
// Failed jobs linger briefly so the user sees them, then self-clear.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
self?.dismiss(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ struct PodcastEntry: Codable, Identifiable {
|
||||
var title: String?
|
||||
let createdAt: Date
|
||||
var parentBookmarkUrl: String?
|
||||
/// When the episode was finished. `nil` means unplayed/new. Absent in
|
||||
/// pre-existing index files, which decode as `nil` (i.e. unplayed).
|
||||
var playedAt: Date? = nil
|
||||
|
||||
var isPlayed: Bool { playedAt != nil }
|
||||
}
|
||||
|
||||
enum PodcastIndex {
|
||||
@@ -49,6 +54,18 @@ enum PodcastIndex {
|
||||
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
|
||||
}
|
||||
|
||||
static func isPlayed(articleUrl: String) -> Bool {
|
||||
all().first { $0.articleUrl == articleUrl }?.isPlayed ?? false
|
||||
}
|
||||
|
||||
/// Mark an episode played (default) or back to unplayed. No-op if unknown.
|
||||
static func setPlayed(articleUrl: String, _ played: Bool = true) {
|
||||
var entries = all()
|
||||
guard let i = entries.firstIndex(where: { $0.articleUrl == articleUrl }) else { return }
|
||||
entries[i].playedAt = played ? Date() : nil
|
||||
save(entries)
|
||||
}
|
||||
|
||||
static func remove(articleUrl: String) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
|
||||
31
Marks/Services/PodcastLibrary.swift
Normal file
31
Marks/Services/PodcastLibrary.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Observable, in-memory mirror of `PodcastIndex` so SwiftUI updates immediately
|
||||
/// on changes — the Podcasts tab badge and the library list both read from here.
|
||||
/// Route podcast mutations through this store so the unplayed badge stays in sync.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PodcastLibrary {
|
||||
static let shared = PodcastLibrary()
|
||||
|
||||
private(set) var entries: [PodcastEntry] = []
|
||||
|
||||
var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
|
||||
var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
|
||||
var unplayedCount: Int { entries.reduce(0) { $0 + ($1.isPlayed ? 0 : 1) } }
|
||||
|
||||
private init() { entries = PodcastIndex.all() }
|
||||
|
||||
func reload() { entries = PodcastIndex.all() }
|
||||
|
||||
func setPlayed(_ articleUrl: String, _ played: Bool) {
|
||||
PodcastIndex.setPlayed(articleUrl: articleUrl, played)
|
||||
reload()
|
||||
}
|
||||
|
||||
func remove(_ articleUrl: String) {
|
||||
PodcastIndex.remove(articleUrl: articleUrl)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
61
Marks/Services/PodcastRequests.swift
Normal file
61
Marks/Services/PodcastRequests.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Cross-process (main app ⇆ share extension) queue of podcast-generation
|
||||
/// requests, plus the user's "auto-generate for these tags" rule. Lives in the
|
||||
/// shared App Group so the share extension can enqueue work the main app runs —
|
||||
/// the extension itself is too short-lived to generate audio.
|
||||
enum PodcastRequests {
|
||||
private static let appGroupId = "group.com.magicive.marks"
|
||||
private static let pendingKey = "pendingPodcastRequests"
|
||||
private static let autoTagsKey = "podcastAutoTags"
|
||||
|
||||
private static var defaults: UserDefaults? { UserDefaults(suiteName: appGroupId) }
|
||||
|
||||
struct Request: Codable {
|
||||
let url: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
// MARK: - Pending queue
|
||||
|
||||
/// Enqueue a request. De-duplicates on URL so repeated saves don't pile up.
|
||||
static func enqueue(url: String, title: String) {
|
||||
var list = pending()
|
||||
guard !list.contains(where: { $0.url == url }) else { return }
|
||||
list.append(Request(url: url, title: title))
|
||||
save(list)
|
||||
}
|
||||
|
||||
static func pending() -> [Request] {
|
||||
guard let data = defaults?.data(forKey: pendingKey),
|
||||
let list = try? JSONDecoder().decode([Request].self, from: data) else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
/// Return everything pending and clear the queue.
|
||||
static func drain() -> [Request] {
|
||||
let list = pending()
|
||||
defaults?.removeObject(forKey: pendingKey)
|
||||
return list
|
||||
}
|
||||
|
||||
private static func save(_ list: [Request]) {
|
||||
guard let data = try? JSONEncoder().encode(list) else { return }
|
||||
defaults?.set(data, forKey: pendingKey)
|
||||
}
|
||||
|
||||
// MARK: - Auto-tag rule
|
||||
|
||||
/// Tags that auto-trigger podcast generation when a bookmark is saved.
|
||||
static var autoTags: [String] {
|
||||
get { defaults?.stringArray(forKey: autoTagsKey) ?? [] }
|
||||
set { defaults?.set(newValue, forKey: autoTagsKey) }
|
||||
}
|
||||
|
||||
/// Whether saving a bookmark with `tags` should auto-generate a podcast.
|
||||
static func matchesAutoTag(_ tags: [String]) -> Bool {
|
||||
let auto = Set(autoTags.map { $0.lowercased() })
|
||||
guard !auto.isEmpty else { return false }
|
||||
return tags.contains { auto.contains($0.lowercased()) }
|
||||
}
|
||||
}
|
||||
40
Marks/Services/SourceSpotlightIndexer.swift
Normal file
40
Marks/Services/SourceSpotlightIndexer.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import CoreSpotlight
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SourceSpotlightIndexer {
|
||||
private static let domainIdentifier = "com.magicive.marks.sources"
|
||||
|
||||
static func index(_ sources: [IngestedSource]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !sources.isEmpty else { return }
|
||||
let items = sources.map { source in
|
||||
let attributes = CSSearchableItemAttributeSet(contentType: source.kind == .pdf ? .pdf : .text)
|
||||
attributes.title = source.displayTitle
|
||||
attributes.contentDescription = source.bodyText
|
||||
attributes.keywords = source.tags + [source.kind.label, source.originalFilename].compactMap { $0 }
|
||||
attributes.displayName = source.originalFilename ?? source.displayTitle
|
||||
return CSSearchableItem(
|
||||
uniqueIdentifier: source.id.uuidString,
|
||||
domainIdentifier: domainIdentifier,
|
||||
attributeSet: attributes
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try await CSSearchableIndex.default().indexSearchableItems(items)
|
||||
Log.spotlight.info("Indexed \(sources.count, privacy: .public) sources")
|
||||
} catch {
|
||||
Log.spotlight.error("Source index failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func remove(ids: [UUID]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
|
||||
do {
|
||||
try await CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids.map(\.uuidString))
|
||||
Log.spotlight.info("Removed \(ids.count, privacy: .public) sources")
|
||||
} catch {
|
||||
Log.spotlight.error("Source delete failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,44 @@ struct AddBookmarkView: View {
|
||||
|
||||
@State private var url = ""
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var importText = ""
|
||||
@State private var isSaving = false
|
||||
@State private var error: String?
|
||||
@State private var importMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $importText)
|
||||
.frame(minHeight: 96)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
extractImportText()
|
||||
} label: {
|
||||
Label("Extract Link", systemImage: "link.badge.plus")
|
||||
}
|
||||
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let importMessage {
|
||||
Text(importMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Import Text")
|
||||
} footer: {
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.keyboardType(.URL)
|
||||
@@ -28,6 +59,13 @@ struct AddBookmarkView: View {
|
||||
Text("Title")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
} header: {
|
||||
Text("Notes")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -63,6 +101,23 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func extractImportText() {
|
||||
guard let payload = IngestPayloadParser.parse(item: importText, typeIdentifier: "public.plain-text") else {
|
||||
importMessage = "No link found"
|
||||
return
|
||||
}
|
||||
|
||||
url = payload.url
|
||||
if let extractedTitle = payload.title, title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
title = extractedTitle
|
||||
}
|
||||
if description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
description = payload.notes
|
||||
}
|
||||
importMessage = "Link extracted"
|
||||
error = nil
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let urlString = url.normalizedURL
|
||||
guard URL(string: urlString) != nil else {
|
||||
@@ -74,7 +129,12 @@ struct AddBookmarkView: View {
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
|
||||
try await viewModel.addBookmark(
|
||||
url: urlString,
|
||||
title: title.trimmingCharacters(in: .whitespaces),
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
tags: tags
|
||||
)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
@@ -85,7 +85,7 @@ struct BookmarkListRow: View {
|
||||
)
|
||||
}
|
||||
.sheet(item: $episodePickerBookmark) { b in
|
||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +100,7 @@ struct BookmarkListRow: View {
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
} else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ struct BookmarksView: View {
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
if viewModel.podcastGenerator.hasActive {
|
||||
podcastGeneratingBanner
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
|
||||
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
||||
showFullPlayer = true
|
||||
@@ -157,6 +161,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
||||
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
@@ -178,7 +183,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
.bookmarkOnscreen(bookmark)
|
||||
@@ -230,6 +235,40 @@ struct BookmarksView: View {
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var podcastGeneratingBanner: some View {
|
||||
let jobs = viewModel.podcastGenerator.activeJobs
|
||||
return HStack(spacing: 10) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
if let first = jobs.first {
|
||||
Text(first.title.isEmpty ? first.label : first.title)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
.frame(width: 44)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||
|
||||
@@ -18,6 +18,7 @@ final class BookmarksViewModel {
|
||||
private let api: LinkdingAPI
|
||||
let claude = ClaudeService()
|
||||
let podcastPlayer = PodcastPlayerViewModel()
|
||||
let podcastGenerator = PodcastGenerationManager()
|
||||
private var enrichTask: Task<Void, Never>?
|
||||
private let cacheFileUrl: URL
|
||||
|
||||
@@ -29,6 +30,32 @@ final class BookmarksViewModel {
|
||||
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
|
||||
}
|
||||
|
||||
/// True while the player is showing an episode (playing or generating one).
|
||||
var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty }
|
||||
|
||||
/// Handle a headphone tap without ever interrupting current playback.
|
||||
/// If the player is idle, generate-and-play in the foreground and return
|
||||
/// `true` (caller should present the full player). If the player is busy,
|
||||
/// generate in the background — the finished episode lands in the library —
|
||||
/// and return `false`.
|
||||
@discardableResult
|
||||
func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool {
|
||||
if isPlayerBusy {
|
||||
podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return false
|
||||
}
|
||||
podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drain podcast requests queued by the share extension and start generating
|
||||
/// them in the background. Safe to call repeatedly (the queue is cleared).
|
||||
func processPendingPodcastRequests() {
|
||||
for req in PodcastRequests.drain() {
|
||||
podcastGenerator.enqueue(articleUrl: req.url, title: req.title)
|
||||
}
|
||||
}
|
||||
|
||||
func load(useCache: Bool = true) async {
|
||||
if useCache { loadFromCache() }
|
||||
isLoading = true
|
||||
@@ -101,8 +128,16 @@ final class BookmarksViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func addBookmark(url: String, title: String, tags: [String]) async throws {
|
||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||
func addBookmark(url: String, title: String, description: String = "", tags: [String]) async throws {
|
||||
let create = BookmarkCreate(
|
||||
url: url,
|
||||
title: title,
|
||||
description: description,
|
||||
tagNames: tags,
|
||||
isArchived: false,
|
||||
unread: false,
|
||||
shared: false
|
||||
)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
bookmarks.insert(bookmark, at: 0)
|
||||
Task { await SpotlightIndexer.index([bookmark]) }
|
||||
@@ -272,6 +307,8 @@ final class BookmarksViewModel {
|
||||
"tags": bookmark.aiTags ?? []
|
||||
]
|
||||
UserDefaults.standard.set(store, forKey: "aiData")
|
||||
// Mirror by URL so the decoupled Podcasts tab can show summaries.
|
||||
AISummaryStore.set(bookmark.aiSummary, for: bookmark.url)
|
||||
}
|
||||
|
||||
private func restoreAIData() {
|
||||
@@ -284,6 +321,8 @@ final class BookmarksViewModel {
|
||||
if let d = store["\(list[i].id)"] {
|
||||
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
list[i].aiTags = d["tags"] as? [String]
|
||||
// Keep the URL-keyed summary cache populated for existing bookmarks.
|
||||
if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ struct BrowserView: View {
|
||||
let initialTitle: String
|
||||
let claude: ClaudeService
|
||||
let podcastPlayer: PodcastPlayerViewModel
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
var onArchive: (() async -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -232,11 +233,12 @@ struct BrowserView: View {
|
||||
@State private var navForwardCount = 0
|
||||
@State private var toolbarHidden = false
|
||||
|
||||
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) {
|
||||
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
|
||||
self.initialURL = url
|
||||
self.initialTitle = title
|
||||
self.claude = claude
|
||||
self.podcastPlayer = podcastPlayer
|
||||
self.podcastGenerator = podcastGenerator
|
||||
self.onArchive = onArchive
|
||||
self._state = State(initialValue: BrowserState(url: url))
|
||||
}
|
||||
@@ -375,6 +377,9 @@ struct BrowserView: View {
|
||||
Button {
|
||||
let currentUrl = (state.currentURL ?? initialURL).absoluteString
|
||||
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
|
||||
// Never interrupt current playback: play-and-show only when idle,
|
||||
// otherwise generate in the background.
|
||||
if podcastPlayer.currentArticleUrl.isEmpty {
|
||||
podcastPlayer.start(
|
||||
articleUrl: currentUrl,
|
||||
articleTitle: currentTitle,
|
||||
@@ -382,6 +387,13 @@ struct BrowserView: View {
|
||||
parentBookmarkUrl: initialURL.absoluteString
|
||||
)
|
||||
showPodcast = true
|
||||
} else {
|
||||
podcastGenerator.enqueue(
|
||||
articleUrl: currentUrl,
|
||||
title: currentTitle,
|
||||
parentBookmarkUrl: initialURL.absoluteString
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "headphones")
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
@@ -42,10 +42,21 @@ final class PodcastPlayerViewModel {
|
||||
var duration: Double = 1
|
||||
var currentArticleUrl: String = ""
|
||||
var currentArticleTitle: String = ""
|
||||
var playbackSpeed: Float = 1.0
|
||||
var playbackSpeed: Float = PodcastPlayerViewModel.savedSpeed
|
||||
|
||||
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
|
||||
|
||||
/// Sleep-timer selection. `.off` when disabled.
|
||||
enum SleepTimer: Hashable { case off, minutes(Int), endOfEpisode }
|
||||
var sleepTimer: SleepTimer = .off
|
||||
|
||||
private static let speedKey = "podcastPlaybackSpeed"
|
||||
private static let sessionKey = "podcastSession"
|
||||
static var savedSpeed: Float {
|
||||
let v = UserDefaults.standard.double(forKey: speedKey)
|
||||
return v > 0 ? Float(v) : 1.0
|
||||
}
|
||||
|
||||
var isGenerating: Bool {
|
||||
switch phase {
|
||||
case .generating, .downloading: return true
|
||||
@@ -53,19 +64,33 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remaining episodes to auto-play after the current one finishes.
|
||||
private(set) var queue: [PodcastEntry] = []
|
||||
/// Retained so queued episodes can be started from the end-of-item observer.
|
||||
private var lastClaude: ClaudeService?
|
||||
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var sleepTask: Task<Void, Never>?
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: Any?
|
||||
private var interruptionObserver: Any?
|
||||
private var currentPodcastTitle: String = "Marks Podcast"
|
||||
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
|
||||
func start(
|
||||
articleUrl: String,
|
||||
articleTitle: String = "",
|
||||
claude: ClaudeService,
|
||||
parentBookmarkUrl: String? = nil,
|
||||
sourceText: String? = nil,
|
||||
sourceKind: String? = nil
|
||||
) {
|
||||
// Idempotent — don't restart if already generating or playing this article
|
||||
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
||||
|
||||
// Teardown previous episode (saves its position)
|
||||
if player != nil { stop() }
|
||||
|
||||
lastClaude = claude
|
||||
currentArticleUrl = articleUrl
|
||||
currentArticleTitle = articleTitle
|
||||
|
||||
@@ -81,7 +106,12 @@ final class PodcastPlayerViewModel {
|
||||
|
||||
pollingTask = Task {
|
||||
do {
|
||||
let jobId = try await claude.generatePodcast(url: articleUrl)
|
||||
let jobId = try await claude.generatePodcast(
|
||||
url: articleUrl,
|
||||
title: articleTitle,
|
||||
sourceText: sourceText,
|
||||
sourceKind: sourceKind
|
||||
)
|
||||
while !Task.isCancelled {
|
||||
let job = try await claude.podcastStatus(jobId: jobId)
|
||||
phase = .generating(
|
||||
@@ -112,7 +142,20 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play `entries` in order, auto-advancing through the queue as each finishes.
|
||||
func playQueue(_ entries: [PodcastEntry], claude: ClaudeService) {
|
||||
guard let first = entries.first else { return }
|
||||
// `start` calls `stop`, which clears the queue — set it afterwards.
|
||||
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
|
||||
queue = Array(entries.dropFirst())
|
||||
persistSession()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue = []
|
||||
clearSession()
|
||||
sleepTask?.cancel(); sleepTask = nil
|
||||
sleepTimer = .off
|
||||
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||||
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||||
}
|
||||
@@ -146,6 +189,7 @@ final class PodcastPlayerViewModel {
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
} else {
|
||||
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
|
||||
player.rate = playbackSpeed
|
||||
}
|
||||
isPlaying.toggle()
|
||||
@@ -168,16 +212,90 @@ final class PodcastPlayerViewModel {
|
||||
|
||||
func setPlaybackSpeed(_ speed: Float) {
|
||||
playbackSpeed = speed
|
||||
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
|
||||
if isPlaying { player?.rate = speed }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func setupPlayer(url: URL, title: String) {
|
||||
// MARK: - Sleep timer
|
||||
|
||||
func setSleepTimer(_ timer: SleepTimer) {
|
||||
sleepTimer = timer
|
||||
sleepTask?.cancel(); sleepTask = nil
|
||||
guard case .minutes(let m) = timer else { return } // .endOfEpisode handled at finish
|
||||
sleepTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Double(m) * 60))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.fireSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func fireSleepTimer() {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
sleepTimer = .off
|
||||
sleepTask = nil
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
// MARK: - Session persistence (survives app relaunch)
|
||||
|
||||
private struct SavedSession: Codable {
|
||||
let currentUrl: String
|
||||
let currentTitle: String
|
||||
let podcastTitle: String
|
||||
let queue: [PodcastEntry]
|
||||
}
|
||||
|
||||
/// Persist the current episode + queue so relaunch can restore a paused session.
|
||||
func persistSession() {
|
||||
guard !currentArticleUrl.isEmpty else { clearSession(); return }
|
||||
let session = SavedSession(currentUrl: currentArticleUrl,
|
||||
currentTitle: currentArticleTitle,
|
||||
podcastTitle: currentPodcastTitle,
|
||||
queue: queue)
|
||||
if let data = try? JSONEncoder().encode(session) {
|
||||
UserDefaults.standard.set(data, forKey: Self.sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearSession() {
|
||||
UserDefaults.standard.removeObject(forKey: Self.sessionKey)
|
||||
}
|
||||
|
||||
/// Save the current playback position + session (call when backgrounding).
|
||||
func saveProgress() {
|
||||
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||||
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||||
}
|
||||
persistSession()
|
||||
}
|
||||
|
||||
/// Restore a previously-playing episode into a **paused** player on launch,
|
||||
/// with its queue intact. No-op if nothing is saved or the audio is gone.
|
||||
func restoreSession(claude: ClaudeService) {
|
||||
guard currentArticleUrl.isEmpty, player == nil else { return }
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.sessionKey),
|
||||
let session = try? JSONDecoder().decode(SavedSession.self, from: data) else { return }
|
||||
let cached = ClaudeService.cachedPodcastURL(for: session.currentUrl)
|
||||
guard FileManager.default.fileExists(atPath: cached.path) else { clearSession(); return }
|
||||
lastClaude = claude
|
||||
currentArticleUrl = session.currentUrl
|
||||
currentArticleTitle = session.currentTitle
|
||||
queue = session.queue
|
||||
setupPlayer(url: cached, title: session.podcastTitle, autoPlay: false)
|
||||
}
|
||||
|
||||
private func setupPlayer(url: URL, title: String, autoPlay: Bool = true) {
|
||||
currentPodcastTitle = title
|
||||
// A freshly generated episode may have just landed in the index.
|
||||
PodcastLibrary.shared.reload()
|
||||
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
||||
options: [.allowBluetoothA2DP])
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
// Only claim the audio session when actually playing — a restored, paused
|
||||
// session on launch must not interrupt the user's other audio.
|
||||
if autoPlay { try? AVAudioSession.sharedInstance().setActive(true) }
|
||||
|
||||
let item = AVPlayerItem(url: url)
|
||||
let p = AVPlayer(playerItem: item)
|
||||
@@ -196,16 +314,36 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to start when playback finishes, like standard media players
|
||||
// On finish: mark the episode played, then auto-advance to the next
|
||||
// queued episode, or reset to the start like a standard media player.
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let finished = self.currentArticleUrl
|
||||
PodcastIndex.setPlayed(articleUrl: finished, true)
|
||||
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
|
||||
PodcastLibrary.shared.reload()
|
||||
|
||||
// Sleep timer set to "end of episode" stops here — no auto-advance.
|
||||
let stopForSleep = self.sleepTimer == .endOfEpisode
|
||||
if stopForSleep { self.sleepTimer = .off }
|
||||
|
||||
if !stopForSleep, !self.queue.isEmpty {
|
||||
let next = self.queue.removeFirst()
|
||||
let remaining = self.queue
|
||||
let claude = self.lastClaude ?? ClaudeService()
|
||||
self.start(articleUrl: next.articleUrl,
|
||||
articleTitle: next.title ?? "", claude: claude)
|
||||
self.queue = remaining // `start` cleared it via `stop`
|
||||
self.persistSession()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.currentTime = 0
|
||||
self.player?.seek(to: .zero)
|
||||
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
|
||||
self.updateNowPlaying()
|
||||
self.persistSession()
|
||||
}
|
||||
}
|
||||
|
||||
// Pause on phone calls / Siri; resume when interruption ends
|
||||
@@ -245,14 +383,20 @@ final class PodcastPlayerViewModel {
|
||||
setupRemoteCommands()
|
||||
phase = .ready(title: title)
|
||||
updateNowPlaying()
|
||||
if autoPlay {
|
||||
p.rate = playbackSpeed
|
||||
isPlaying = true
|
||||
} else {
|
||||
isPlaying = false
|
||||
}
|
||||
persistSession()
|
||||
}
|
||||
|
||||
private func setupRemoteCommands() {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
c.playCommand.isEnabled = true
|
||||
c.playCommand.addTarget { [weak self] _ in
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
|
||||
}
|
||||
c.pauseCommand.isEnabled = true
|
||||
@@ -308,7 +452,7 @@ final class PodcastPlayerViewModel {
|
||||
return MPMediaItemArtwork(boundsSize: size) { _ in image }
|
||||
}()
|
||||
|
||||
private static func statusLabel(_ status: String) -> String {
|
||||
static func statusLabel(_ status: String) -> String {
|
||||
switch status {
|
||||
case "queued": return "Queued…"
|
||||
case "fetching": return "Fetching article…"
|
||||
@@ -330,6 +474,33 @@ struct PodcastPlayerView: View {
|
||||
var stopOnDismiss: Bool = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isPlayed = false
|
||||
|
||||
/// The episode currently loaded in the player (follows queue advances),
|
||||
/// falling back to the URL this sheet was opened with.
|
||||
private var activeUrl: String {
|
||||
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl
|
||||
}
|
||||
|
||||
private var sleepActive: Bool { vm.sleepTimer != .off }
|
||||
private var sleepLabel: String {
|
||||
switch vm.sleepTimer {
|
||||
case .off: return "Sleep"
|
||||
case .minutes(let m): return "\(m) min"
|
||||
case .endOfEpisode: return "End"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sleepButton(_ title: String, _ option: PodcastPlayerViewModel.SleepTimer) -> some View {
|
||||
Button { vm.setSleepTimer(option) } label: {
|
||||
if vm.sleepTimer == option {
|
||||
Label(title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -357,6 +528,10 @@ struct PodcastPlayerView: View {
|
||||
}
|
||||
.onAppear {
|
||||
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
|
||||
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
|
||||
}
|
||||
.onChange(of: vm.currentArticleUrl) { _, _ in
|
||||
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
|
||||
}
|
||||
.onDisappear {
|
||||
if stopOnDismiss { vm.stop() }
|
||||
@@ -445,6 +620,7 @@ struct PodcastPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Menu {
|
||||
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
|
||||
Button {
|
||||
@@ -467,6 +643,35 @@ struct PodcastPlayerView: View {
|
||||
.glassEffect(in: Capsule())
|
||||
}
|
||||
|
||||
Menu {
|
||||
sleepButton("Off", .off)
|
||||
sleepButton("15 Minutes", .minutes(15))
|
||||
sleepButton("30 Minutes", .minutes(30))
|
||||
sleepButton("45 Minutes", .minutes(45))
|
||||
sleepButton("End of Episode", .endOfEpisode)
|
||||
} label: {
|
||||
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(sleepActive ? Color.blue : .secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.glassEffect(in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 28) {
|
||||
Button {
|
||||
isPlayed.toggle()
|
||||
PodcastLibrary.shared.setPlayed(activeUrl, isPlayed)
|
||||
} label: {
|
||||
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
|
||||
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isPlayed ? Color.green : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
|
||||
if let url = URL(string: articleUrl) {
|
||||
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
|
||||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||||
@@ -476,6 +681,7 @@ struct PodcastPlayerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func failedView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
@@ -603,14 +809,16 @@ struct MiniPlayerView: View {
|
||||
struct PodcastLibraryView: View {
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var showFullPlayer = false
|
||||
@State private var detailEntry: PodcastEntry?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
if library.entries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Podcasts",
|
||||
systemImage: "headphones",
|
||||
@@ -618,47 +826,30 @@ struct PodcastLibraryView: View {
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(entries) { entry in
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.lineLimit(2)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
if !unplayed.isEmpty {
|
||||
Section("Up Next") {
|
||||
ForEach(unplayed) { row(for: $0) }
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
play(entry)
|
||||
} label: {
|
||||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
deleteEntry(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
if !played.isEmpty {
|
||||
Section("Played") {
|
||||
ForEach(played) { row(for: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
indexSet.forEach { deleteEntry(entries[$0]) }
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Podcasts")
|
||||
.toolbar {
|
||||
if !entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) { EditButton() }
|
||||
if !unplayed.isEmpty {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
vm.playQueue(unplayed, claude: claude)
|
||||
} label: {
|
||||
Label("Play All", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -679,8 +870,102 @@ struct PodcastLibraryView: View {
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.sheet(item: $detailEntry) { entry in
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.all() }
|
||||
}
|
||||
.onAppear { library.reload() }
|
||||
// When the current episode changes (finished, advanced, or stopped),
|
||||
// re-read the index so played items move into the Played section.
|
||||
.onChange(of: vm.currentArticleUrl) { _, _ in library.reload() }
|
||||
}
|
||||
|
||||
private var unplayed: [PodcastEntry] { library.unplayed }
|
||||
private var played: [PodcastEntry] { library.played }
|
||||
|
||||
@ViewBuilder
|
||||
private func row(for entry: PodcastEntry) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if entry.isPlayed {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("Played")
|
||||
}
|
||||
Button {
|
||||
play(entry)
|
||||
} label: {
|
||||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { detailEntry = entry }
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
setPlayed(entry, !entry.isPlayed)
|
||||
} label: {
|
||||
if entry.isPlayed {
|
||||
Label("Unplayed", systemImage: "circle")
|
||||
} else {
|
||||
Label("Played", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
.tint(entry.isPlayed ? .gray : .green)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
deleteEntry(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
play(entry)
|
||||
} label: {
|
||||
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
|
||||
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
|
||||
}
|
||||
Button {
|
||||
detailEntry = entry
|
||||
} label: {
|
||||
Label("Episode Details", systemImage: "info.circle")
|
||||
}
|
||||
Button {
|
||||
setPlayed(entry, !entry.isPlayed)
|
||||
} label: {
|
||||
if entry.isPlayed {
|
||||
Label("Mark as Unplayed", systemImage: "circle")
|
||||
} else {
|
||||
Label("Mark as Played", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
deleteEntry(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
|
||||
library.setPlayed(entry.articleUrl, played)
|
||||
}
|
||||
|
||||
private func isCurrent(_ entry: PodcastEntry) -> Bool {
|
||||
@@ -696,8 +981,7 @@ struct PodcastLibraryView: View {
|
||||
}
|
||||
|
||||
private func deleteEntry(_ entry: PodcastEntry) {
|
||||
PodcastIndex.remove(articleUrl: entry.articleUrl)
|
||||
entries.removeAll { $0.articleUrl == entry.articleUrl }
|
||||
library.remove(entry.articleUrl)
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
}
|
||||
}
|
||||
@@ -708,8 +992,10 @@ struct EpisodePickerView: View {
|
||||
let bookmark: Bookmark
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@State private var detailEntry: PodcastEntry?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -745,6 +1031,8 @@ struct EpisodePickerView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { detailEntry = entry }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) { deleteEntry(entry) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
@@ -760,6 +1048,9 @@ struct EpisodePickerView: View {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $detailEntry) { entry in
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
|
||||
}
|
||||
@@ -770,3 +1061,131 @@ struct EpisodePickerView: View {
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Episode detail
|
||||
|
||||
/// Detail sheet for a single episode: reconnects the podcast to its source
|
||||
/// bookmark and gathers per-episode actions (play, mark played, open source,
|
||||
/// share, delete) in one place.
|
||||
struct EpisodeDetailView: View {
|
||||
let entry: PodcastEntry
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var showBrowser = false
|
||||
|
||||
private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl }
|
||||
private var isPlayed: Bool {
|
||||
library.entries.first { $0.articleUrl == entry.articleUrl }?.isPlayed ?? entry.isPlayed
|
||||
}
|
||||
private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) }
|
||||
private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl }
|
||||
private var summary: String? {
|
||||
AISummaryStore.summary(for: entry.parentBookmarkUrl ?? entry.articleUrl)
|
||||
?? AISummaryStore.summary(for: entry.articleUrl)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
Label(domain, systemImage: "link")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 12) {
|
||||
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
|
||||
systemImage: "calendar")
|
||||
if isPlayed {
|
||||
Label("Played", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Section("Summary") {
|
||||
Text(summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
if isCurrent {
|
||||
vm.togglePlayPause()
|
||||
} else {
|
||||
vm.start(articleUrl: entry.articleUrl,
|
||||
articleTitle: entry.title ?? "", claude: claude)
|
||||
}
|
||||
} label: {
|
||||
Label(isCurrent && vm.isPlaying ? "Pause" : "Play",
|
||||
systemImage: isCurrent && vm.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
Button {
|
||||
library.setPlayed(entry.articleUrl, !isPlayed)
|
||||
} label: {
|
||||
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
|
||||
systemImage: isPlayed ? "circle" : "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
if let sourceURL {
|
||||
Section {
|
||||
Button {
|
||||
showBrowser = true
|
||||
} label: {
|
||||
Label("Open Bookmark", systemImage: "book")
|
||||
}
|
||||
Button {
|
||||
openURL(sourceURL)
|
||||
} label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
ShareLink(item: sourceURL) {
|
||||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
library.remove(entry.articleUrl)
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Delete Episode", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Episode")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showBrowser) {
|
||||
if let sourceURL {
|
||||
BrowserView(url: sourceURL,
|
||||
title: entry.title ?? domain,
|
||||
claude: claude,
|
||||
podcastPlayer: vm,
|
||||
podcastGenerator: podcastGenerator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ struct SearchView: View {
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ struct SettingsView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
@@ -15,6 +17,21 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("e.g. listen podcast", text: $autoTagText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: autoTagText) { _, new in
|
||||
PodcastRequests.autoTags = new
|
||||
.split(whereSeparator: { $0 == " " || $0 == "," })
|
||||
.map(String.init)
|
||||
}
|
||||
} header: {
|
||||
Text("Auto-Podcast Tags")
|
||||
} footer: {
|
||||
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
|
||||
}
|
||||
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
|
||||
440
Marks/Views/SourcesView.swift
Normal file
440
Marks/Views/SourcesView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,8 @@ struct TagBookmarksView: View {
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
|
||||
@@ -104,4 +104,98 @@ final class AppIntentsTests: XCTestCase {
|
||||
func testAppShortcutsAreRegistered() {
|
||||
XCTAssertGreaterThanOrEqual(MarksShortcuts.appShortcuts.count, 4)
|
||||
}
|
||||
|
||||
// MARK: - Ingest payload parsing
|
||||
|
||||
func testIngestParsesDirectURL() {
|
||||
let payload = IngestPayloadParser.parse(item: URL(string: "https://example.com/article")!)
|
||||
XCTAssertEqual(payload?.url, "https://example.com/article")
|
||||
XCTAssertEqual(payload?.notes, "")
|
||||
}
|
||||
|
||||
func testIngestFindsURLInSharedEmailText() {
|
||||
let text = """
|
||||
Thought you might want to save this:
|
||||
|
||||
https://example.com/story?utm_source=newsletter
|
||||
|
||||
Sent from Spark
|
||||
"""
|
||||
|
||||
let payload = IngestPayloadParser.parse(item: text, typeIdentifier: "public.plain-text")
|
||||
XCTAssertEqual(payload?.url, "https://example.com/story?utm_source=newsletter")
|
||||
XCTAssertTrue(payload?.notes.contains("Sent from Spark") == true)
|
||||
}
|
||||
|
||||
func testIngestIgnoresMailLinksAndUsesWebURL() {
|
||||
let text = "mailto:friend@example.com\nRead https://example.com/read-later."
|
||||
let payload = IngestPayloadParser.parse(item: text, typeIdentifier: "public.plain-text")
|
||||
XCTAssertEqual(payload?.url, "https://example.com/read-later")
|
||||
}
|
||||
|
||||
func testIngestDecodesHTMLLinksFromEmailClients() {
|
||||
let html = "<p>Save this: <a href=\"https://example.com/a?x=1&y=2\">article</a></p>"
|
||||
let payload = IngestPayloadParser.parse(item: html, typeIdentifier: "public.html")
|
||||
XCTAssertEqual(payload?.url, "https://example.com/a?x=1&y=2")
|
||||
}
|
||||
|
||||
// MARK: - Local sources
|
||||
|
||||
func testSourceStoreSavesAndLoadsMetadata() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let store = IngestedSourceStore(rootURL: root)
|
||||
let source = IngestedSource(kind: .text, title: "Meeting Notes", bodyText: "Follow up on paper receipts.", tags: ["receipts"])
|
||||
try store.save([source])
|
||||
|
||||
let loaded = try store.load()
|
||||
XCTAssertEqual(loaded.count, 1)
|
||||
XCTAssertEqual(loaded.first?.id, source.id)
|
||||
XCTAssertEqual(loaded.first?.kind, .text)
|
||||
XCTAssertEqual(loaded.first?.title, "Meeting Notes")
|
||||
XCTAssertEqual(loaded.first?.bodyText, "Follow up on paper receipts.")
|
||||
XCTAssertEqual(loaded.first?.tags, ["receipts"])
|
||||
}
|
||||
|
||||
func testSourceStoreImportsPlainTextFile() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let input = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
try? FileManager.default.removeItem(at: input)
|
||||
}
|
||||
|
||||
try Data("Offline clipping text".utf8).write(to: input)
|
||||
|
||||
let store = IngestedSourceStore(rootURL: root)
|
||||
let source = try store.importFile(from: input, tags: ["offline"])
|
||||
|
||||
XCTAssertEqual(source.kind, .file)
|
||||
XCTAssertEqual(source.bodyText, "Offline clipping text")
|
||||
XCTAssertEqual(source.tags, ["offline"])
|
||||
XCTAssertNotNil(store.fileURL(for: source))
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import SwiftUI
|
||||
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
|
||||
struct ShareView: View {
|
||||
let url: String
|
||||
let initialTitle: String?
|
||||
let initialNotes: String
|
||||
/// Called when the extension should dismiss. `saved` is true after a successful
|
||||
/// write, false on cancel.
|
||||
let onClose: (_ saved: Bool) -> Void
|
||||
@@ -15,6 +17,7 @@ struct ShareView: View {
|
||||
@State private var tagText = ""
|
||||
@State private var notes = ""
|
||||
@State private var readLater = false
|
||||
@State private var createPodcast = false
|
||||
@State private var suggestedTags: [String] = [] // linkding auto_tags
|
||||
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
|
||||
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
|
||||
@@ -25,10 +28,14 @@ struct ShareView: View {
|
||||
|
||||
private let api: LinkdingAPI?
|
||||
|
||||
init(url: String, onClose: @escaping (_ saved: Bool) -> Void) {
|
||||
init(url: String, initialTitle: String? = nil, initialNotes: String = "", onClose: @escaping (_ saved: Bool) -> Void) {
|
||||
self.url = url
|
||||
self.initialTitle = initialTitle
|
||||
self.initialNotes = initialNotes
|
||||
self.onClose = onClose
|
||||
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
||||
_title = State(initialValue: initialTitle ?? "")
|
||||
_notes = State(initialValue: initialNotes)
|
||||
}
|
||||
|
||||
private enum Phase { case loading, editing, saving, done, failed }
|
||||
@@ -151,6 +158,11 @@ struct ShareView: View {
|
||||
|
||||
Toggle("Read later", isOn: $readLater)
|
||||
.font(.subheadline)
|
||||
|
||||
Toggle(isOn: $createPodcast) {
|
||||
Label("Create podcast", systemImage: "headphones")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +231,14 @@ struct ShareView: View {
|
||||
// MARK: - Networking
|
||||
|
||||
private func check() async {
|
||||
guard let parsed = URL(string: url),
|
||||
let scheme = parsed.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
parsed.host?.isEmpty == false else {
|
||||
errorMessage = "No bookmarkable link found"
|
||||
phase = .failed
|
||||
return
|
||||
}
|
||||
guard let api else {
|
||||
errorMessage = "Open Marks first"
|
||||
phase = .failed
|
||||
@@ -233,9 +253,14 @@ struct ShareView: View {
|
||||
notes = bookmark.description
|
||||
readLater = bookmark.unread
|
||||
} else {
|
||||
title = result.metadata?.title ?? ""
|
||||
title = initialTitle ?? result.metadata?.title ?? ""
|
||||
suggestedTags = result.autoTags ?? []
|
||||
if notes.isEmpty {
|
||||
notes = result.metadata?.description ?? initialNotes
|
||||
}
|
||||
}
|
||||
// Pre-enable the toggle when the current tags match the auto-rule.
|
||||
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
|
||||
phase = .editing
|
||||
await loadSuggestions()
|
||||
} catch {
|
||||
@@ -287,6 +312,7 @@ struct ShareView: View {
|
||||
let create = BookmarkCreate(
|
||||
url: url,
|
||||
title: "",
|
||||
description: notes,
|
||||
tagNames: parsedTags,
|
||||
isArchived: false,
|
||||
unread: readLater,
|
||||
@@ -294,6 +320,11 @@ struct ShareView: View {
|
||||
)
|
||||
_ = try await api.createBookmark(create)
|
||||
}
|
||||
// Hand off podcast generation to the main app (the extension is too
|
||||
// short-lived to generate audio itself).
|
||||
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
|
||||
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
|
||||
}
|
||||
phase = .done
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
onClose(true)
|
||||
|
||||
@@ -4,6 +4,13 @@ import UniformTypeIdentifiers
|
||||
|
||||
@objc(ShareViewController)
|
||||
class ShareViewController: UIViewController {
|
||||
private let supportedTypeIdentifiers = [
|
||||
UTType.url.identifier,
|
||||
UTType.html.identifier,
|
||||
UTType.rtf.identifier,
|
||||
UTType.plainText.identifier,
|
||||
UTType.text.identifier
|
||||
]
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@@ -11,37 +18,49 @@ class ShareViewController: UIViewController {
|
||||
blur.frame = view.bounds
|
||||
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(blur)
|
||||
extractURL()
|
||||
extractPayload()
|
||||
}
|
||||
|
||||
private func extractURL() {
|
||||
private func extractPayload() {
|
||||
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = item.attachments else {
|
||||
present(url: nil)
|
||||
present(payload: nil)
|
||||
return
|
||||
}
|
||||
|
||||
func load(typeId: String) -> Bool {
|
||||
guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else {
|
||||
return false
|
||||
}
|
||||
attachment.loadItem(forTypeIdentifier: typeId, options: nil) { [weak self] item, _ in
|
||||
let urlString = typeId == UTType.url.identifier
|
||||
? ((item as? URL)?.absoluteString ?? (item as? String))
|
||||
: (item as? String)
|
||||
DispatchQueue.main.async { self?.present(url: urlString) }
|
||||
}
|
||||
return true
|
||||
let candidates = attachments.flatMap { attachment in
|
||||
supportedTypeIdentifiers
|
||||
.filter { attachment.hasItemConformingToTypeIdentifier($0) }
|
||||
.map { (attachment, $0) }
|
||||
}
|
||||
|
||||
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
||||
present(url: nil)
|
||||
loadNext(candidates, index: 0)
|
||||
}
|
||||
|
||||
private func loadNext(_ candidates: [(NSItemProvider, String)], index: Int) {
|
||||
guard index < candidates.count else {
|
||||
present(payload: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let (attachment, typeIdentifier) = candidates[index]
|
||||
attachment.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, _ in
|
||||
guard let self else { return }
|
||||
if let payload = IngestPayloadParser.parse(item: item, typeIdentifier: typeIdentifier) {
|
||||
DispatchQueue.main.async { self.present(payload: payload) }
|
||||
} else {
|
||||
DispatchQueue.main.async { self.loadNext(candidates, index: index + 1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
|
||||
private func present(url: String?) {
|
||||
let root = ShareView(url: url ?? "") { [weak self] _ in
|
||||
private func present(payload: IngestPayload?) {
|
||||
let root = ShareView(
|
||||
url: payload?.url ?? "",
|
||||
initialTitle: payload?.title,
|
||||
initialNotes: payload?.notes ?? ""
|
||||
) { [weak self] _ in
|
||||
self?.close()
|
||||
}
|
||||
let host = UIHostingController(rootView: root)
|
||||
@@ -52,7 +71,7 @@ class ShareViewController: UIViewController {
|
||||
view.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
|
||||
if url == nil {
|
||||
if payload == nil {
|
||||
// No URL to work with — let the card render its failure state briefly.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() }
|
||||
}
|
||||
|
||||
11
openwiki/.last-update.json
Normal file
11
openwiki/.last-update.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"updatedAt": "2026-07-02T05:28:44Z",
|
||||
"gitHead": "b496e607fc37e795c765355dd99c191518e6f7f2",
|
||||
"mode": "init",
|
||||
"pages": [
|
||||
"openwiki/quickstart.md",
|
||||
"openwiki/architecture/app-architecture.md",
|
||||
"openwiki/workflows/product-workflows.md",
|
||||
"openwiki/operations/development-and-testing.md"
|
||||
]
|
||||
}
|
||||
174
openwiki/architecture/app-architecture.md
Normal file
174
openwiki/architecture/app-architecture.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# App Architecture
|
||||
|
||||
This page explains how the major Marks runtime pieces fit together. For product-level flows, see [Product workflows](../workflows/product-workflows.md). For setup and verification, see [Development and testing](../operations/development-and-testing.md).
|
||||
|
||||
## Targets and source layout
|
||||
|
||||
`/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.
|
||||
- `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`.
|
||||
- `MarksTests` — unit-test bundle depending on `Marks`.
|
||||
|
||||
The app is Swift 6.0 and targets iOS 26.0. The main app, share extension, and widget extension share the app group `group.com.magicive.marks` through entitlements and runtime code.
|
||||
|
||||
## App entrypoint and navigation shell
|
||||
|
||||
`/Marks/MarksApp.swift` contains the `@main` app and `MainContainer`.
|
||||
|
||||
At startup:
|
||||
|
||||
1. `MarksApp` loads a saved `ServerConfig` or falls back to a built-in default config.
|
||||
2. `MainContainer` constructs `LinkdingAPI(config:)`.
|
||||
3. `MainContainer` stores `BookmarksViewModel(api:cacheKey:)` in `@State`, keeping the model stable across SwiftUI re-renders.
|
||||
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:
|
||||
|
||||
- custom deep links (`marks://bookmark?...`, `marks://podcast?...`),
|
||||
- App Intent handoff through `IntentRouter.shared`,
|
||||
- pending podcast requests queued by the share extension,
|
||||
- foreground refreshes and background podcast progress persistence.
|
||||
|
||||
## Central state: `BookmarksViewModel`
|
||||
|
||||
`/Marks/Views/BookmarksViewModel.swift` is `@Observable @MainActor` and is the core app state object. It owns:
|
||||
|
||||
- loaded bookmarks, errors, loading flags, pagination URL, search query, and unread filter,
|
||||
- generated smart collections and enrichment progress,
|
||||
- a `LinkdingAPI` actor for server sync,
|
||||
- `ClaudeService` for backend AI/podcast calls,
|
||||
- `PodcastPlayerViewModel` and `PodcastGenerationManager`,
|
||||
- the disk bookmark cache URL.
|
||||
|
||||
Key side effects during bookmark loading and mutations:
|
||||
|
||||
- `load()` optionally restores cached bookmarks from Application Support, fetches linkding bookmarks, restores local AI metadata, saves the full unfiltered cache, writes recent bookmarks to `WidgetDataStore`, and indexes bookmarks into Spotlight.
|
||||
- `loadMore()` follows linkding's `next` URL and indexes newly appended bookmarks.
|
||||
- `delete()` and `archive()` mutate linkding, remove local entries, and remove Spotlight entries.
|
||||
- `addBookmark()` saves to linkding, inserts locally, indexes the bookmark, and starts best-effort AI enrichment.
|
||||
- `enrichAll()` and background `startEnrichment()` call AI enrichment, persist AI metadata, and reindex enriched bookmarks.
|
||||
|
||||
Because one `BookmarksViewModel` instance is shared by all tabs, array mutations and filters have cross-tab effects.
|
||||
|
||||
## Linkding integration
|
||||
|
||||
`/Marks/Services/LinkdingAPI.swift` is an actor wrapping linkding's REST API. It builds URLs from `ServerConfig` (`/Marks/Models/ServerConfig.swift`) and sends `Authorization: Token ...` headers.
|
||||
|
||||
Implemented operations include:
|
||||
|
||||
- `fetchBookmarks(search:offset:limit:unread:)` → `GET /api/bookmarks/`,
|
||||
- `fetchBookmarksFromUrl(_:)` for pagination `next` URLs,
|
||||
- `checkBookmark(url:)` → `GET /api/bookmarks/check/`, used by the share extension,
|
||||
- `fetchTags(limit:)` → `GET /api/tags/`,
|
||||
- `createBookmark(_:)` → `POST /api/bookmarks/`,
|
||||
- `updateBookmark(id:update:)` → `PATCH /api/bookmarks/{id}/`,
|
||||
- `deleteBookmark(id:)` → `DELETE /api/bookmarks/{id}/`,
|
||||
- `archiveBookmark(id:)` → `POST /api/bookmarks/{id}/archive/`,
|
||||
- `verifyConnection()` for configuration checks.
|
||||
|
||||
`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
|
||||
|
||||
`ServerConfig` contains linkding host, optional port, path, token, and HTTPS flag. Its extension stores config in two places:
|
||||
|
||||
- `UserDefaults.standard` for the main app,
|
||||
- `UserDefaults(suiteName: "group.com.magicive.marks")` for extensions.
|
||||
|
||||
`ServerConfig.loadShared()` is used by the share extension, which cannot read the main app's standard defaults. Avoid changing config keys or app-group IDs without updating all entitlements, `ServerConfig`, `WidgetDataStore`, and `PodcastRequests` together.
|
||||
|
||||
Security note: `/Marks/MarksApp.swift` currently includes a built-in default config with a token-like value. Treat it as sensitive; do not copy it into documentation or generated output.
|
||||
|
||||
## Backend AI, analytics, and podcasts
|
||||
|
||||
`/Marks/Services/MarksAuth.swift` registers an anonymous device with the Marks backend and caches a JWT in `UserDefaults.standard`. `ClaudeService` uses that token for backend calls:
|
||||
|
||||
- bookmark enrichment: `POST /v1/marks/enrich`,
|
||||
- semantic search: `POST /v1/marks/search`,
|
||||
- smart collections: `POST /v1/marks/collections`,
|
||||
- podcast generation: `POST /v1/podcast/generate`,
|
||||
- podcast polling: `GET /v1/podcast/status/{jobId}`,
|
||||
- podcast audio download: `GET /v1/podcast/audio/{jobId}`.
|
||||
|
||||
`/Marks/Services/AnalyticsService.swift` sends events through the backend path used by `ClaudeService`/`MarksAuth`.
|
||||
|
||||
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
|
||||
|
||||
The app has two AI paths:
|
||||
|
||||
1. **Backend AI** through `ClaudeService` for enrichment, semantic search, smart collections, and podcasts.
|
||||
2. **On-device RAG** through `/Marks/Services/BookmarkAssistant.swift` for the Ask UI.
|
||||
|
||||
`BookmarkAssistant` uses `FoundationModels.LanguageModelSession` with `BookmarkSearchTool`. Its instructions require answers to be grounded in bookmarks. Retrieval flows through Spotlight:
|
||||
|
||||
- `SpotlightIndexer` indexes bookmarks after loads, load-more operations, add, and enrichment.
|
||||
- `SpotlightBookmarkSearch`/`BookmarkSearchTool` search the index for relevant bookmarks.
|
||||
- `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 source fields, keep `IngestedSource`, `IngestedSourceStore`, `SourceSpotlightIndexer`, Sources UI, and source podcast generation in sync.
|
||||
|
||||
## Share extension architecture
|
||||
|
||||
The share extension is implemented by `/ShareExtension/ShareViewController.swift` and `/ShareExtension/ShareView.swift`.
|
||||
|
||||
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:
|
||||
|
||||
- linkding `checkBookmark` metadata and `auto_tags`,
|
||||
- the user's existing tag vocabulary from `fetchTags()`,
|
||||
- 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. The same parser is used by in-app Add Bookmark text import.
|
||||
|
||||
## Widget architecture and deep links
|
||||
|
||||
`/MarksWidget/MarksWidgetBundle.swift` registers three widgets:
|
||||
|
||||
- `RecentBookmarksWidget` (`systemMedium`),
|
||||
- `RandomBookmarkWidget` (`systemSmall`),
|
||||
- `RecentPodcastsWidget` (`systemMedium`).
|
||||
|
||||
Widgets read only app-group JSON through `/MarksWidget/WidgetDataStore.swift`:
|
||||
|
||||
- `widget_bookmarks.json`,
|
||||
- `widget_podcasts.json`.
|
||||
|
||||
They do not call network APIs. Main-app writes trigger `WidgetCenter.shared.reloadAllTimelines()` when WidgetKit is available.
|
||||
|
||||
Widget links use custom URLs built by `URL.marksBookmark(_:)` and `URL.marksPodcast(_:)`. `MainContainer.handleDeepLink(_:)` opens bookmark URLs in `BrowserView` or starts podcast playback and shows the player.
|
||||
|
||||
## App Intents and Shortcuts
|
||||
|
||||
`/Marks/Intents/MarksAppIntents.swift` defines:
|
||||
|
||||
- `OpenBookmarkIntent` — opens a selected `BookmarkEntity` in app.
|
||||
- `SearchMarksIntent` — conforms to the system search schema and routes text to the Search tab.
|
||||
- `AddBookmarkIntent` — saves a URL headlessly through linkding and refreshes widget data.
|
||||
- `ShowUnreadIntent` — fetches up to 10 unread bookmarks.
|
||||
- `SummarizeBookmarkIntent` — calls backend AI enrichment for a selected bookmark.
|
||||
- `MarksShortcuts` — registers Add Bookmark, Search, Unread, and Summarize shortcuts.
|
||||
|
||||
`IntentRouter` bridges intents that need to open UI back into `MainContainer`. `BookmarkEntity` maps between linkding `Bookmark` values and App Intents entities.
|
||||
|
||||
## Recent history context
|
||||
|
||||
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.
|
||||
170
openwiki/operations/development-and-testing.md
Normal file
170
openwiki/operations/development-and-testing.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Development and Testing
|
||||
|
||||
This page covers local setup, generated project guidance, runtime configuration/storage, and verification strategy for changes.
|
||||
|
||||
## Local setup
|
||||
|
||||
Requirements from `/README.md` and `/project.yml`:
|
||||
|
||||
- macOS with Xcode capable of building the declared iOS 26 / Swift 6 project.
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen).
|
||||
- A linkding server with API access for real sync testing.
|
||||
- Access to the Marks backend for AI, semantic search, smart collections, analytics, and podcast generation flows.
|
||||
|
||||
Setup:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
xcodegen generate
|
||||
open Marks.xcodeproj
|
||||
```
|
||||
|
||||
Build and run the `Marks` scheme in Xcode. `/project.yml` declares schemes for `Marks`, `MarksWidget`, and `ShareExtension`; the `Marks` scheme builds the app, share extension, and widget extension and runs `MarksTests` in the test action.
|
||||
|
||||
## Project generation rules
|
||||
|
||||
Use `/project.yml` as the source of truth for targets, source membership, entitlements, and dependencies. Regenerate the Xcode project after changing target membership:
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
Common target-membership traps:
|
||||
|
||||
- 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`, 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.
|
||||
|
||||
## Testing
|
||||
|
||||
Current automated test coverage is concentrated in `/MarksTests/AppIntentsTests.swift`.
|
||||
|
||||
Covered today:
|
||||
|
||||
- `BookmarkEntity` mapping from `Bookmark`, fallback title behavior, and round-trip fields.
|
||||
- `EntityIdentifier` encoding.
|
||||
- `IntentRouter` open/search setters.
|
||||
- Direct no-network `perform()` tests for Search and Open intents.
|
||||
- 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:
|
||||
|
||||
- `BookmarksViewModel` load/cache/pagination/mutation flows.
|
||||
- Linkding networking with mocks.
|
||||
- AI enrichment, semantic search, smart collections, or podcast backend calls.
|
||||
- Share extension save/update/tag suggestion flows.
|
||||
- Widget timeline rendering/deep links.
|
||||
- Browser reading progress/reader mode.
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
xcodebuild test -scheme Marks -destination 'platform=iOS Simulator,name=iPhone 16'
|
||||
```
|
||||
|
||||
Use an installed simulator name if `iPhone 16` is unavailable.
|
||||
|
||||
## Manual verification checklist by area
|
||||
|
||||
Bookmark sync/list changes:
|
||||
|
||||
- Launch with valid linkding config.
|
||||
- Confirm cached bookmarks render quickly, then network refresh updates the list.
|
||||
- Test pull-to-refresh, pagination, unread filter, add, edit, archive, delete.
|
||||
- Confirm widget bookmark JSON updates and Spotlight indexing still happens.
|
||||
|
||||
Search/tag changes:
|
||||
|
||||
- Test linkding search and semantic search separately.
|
||||
- Verify unread filter interactions.
|
||||
- Confirm Tags tab counts include expected linkding and AI tags.
|
||||
|
||||
AI changes:
|
||||
|
||||
- Test enrichment on add, background enrichment, Enrich All, semantic search, Smart Collections, and Summarize intent if affected.
|
||||
- Test Ask on a device/simulator environment where Apple on-device model availability can be observed; handle unavailable states.
|
||||
- Preserve backend privacy expectations: URLs/titles/tags/summaries may be sent to the Marks backend for backend AI paths.
|
||||
|
||||
Share extension changes:
|
||||
|
||||
- Share a new URL, an already-saved URL, and text/HTML/RTF content that contains a URL plus notes.
|
||||
- Verify duplicate-aware update behavior.
|
||||
- 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.
|
||||
|
||||
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:
|
||||
|
||||
- Generate from bookmark row and Browser view.
|
||||
- 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.
|
||||
- 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:
|
||||
|
||||
- Confirm `widget_bookmarks.json` and `widget_podcasts.json` are refreshed by main-app writes.
|
||||
- Test Recent Bookmarks, Random Bookmark, and Recent Podcasts widgets.
|
||||
- Tap bookmark and podcast widget entries and verify `marks://bookmark` and `marks://podcast` routing in `MainContainer`.
|
||||
|
||||
App Intents changes:
|
||||
|
||||
- Run `MarksTests`.
|
||||
- Test Shortcuts/Siri flows for Add Bookmark, Search, Show Unread, Summarize, and Open Bookmark.
|
||||
- Verify entity queries still fetch/resolve bookmarks correctly with a configured linkding server.
|
||||
|
||||
## Configuration and persistence map
|
||||
|
||||
Do not read or document live secret values.
|
||||
|
||||
Main app / standard defaults:
|
||||
|
||||
- `serverConfig` — linkding config saved by `ServerConfig.save()`.
|
||||
- `marksDeviceId`, `marksJWT`, `marksJWTExpiry` — Marks backend auth state from `MarksAuth`.
|
||||
- `aiData` — AI summaries/tags by bookmark ID.
|
||||
- `aiSummaryByURL` — summary mirror used outside the main bookmark array.
|
||||
- `podcastProgress`, `podcastPlaybackSpeed`, `podcastSession` — podcast playback state.
|
||||
- `readingProgress` — in-browser reading progress.
|
||||
|
||||
App-group defaults/files (`group.com.magicive.marks`):
|
||||
|
||||
- `serverConfig` — shared linkding config for extensions.
|
||||
- `pendingPodcastRequests` — share extension to main app podcast queue.
|
||||
- `podcastAutoTags` — tags that auto-enable podcast creation in the share extension.
|
||||
- `widget_bookmarks.json` — recent bookmark widget cache.
|
||||
- `widget_podcasts.json` — recent podcast widget cache.
|
||||
|
||||
Application Support files:
|
||||
|
||||
- `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/index.json` — podcast metadata and played state.
|
||||
|
||||
## Known caveats and stale-doc risks
|
||||
|
||||
- `/Marks/MarksApp.swift` contains a built-in default linkding configuration with a token-like value. Treat it as sensitive and consider replacing it with a safer onboarding/configuration flow before distribution.
|
||||
- `OnboardingView` exists but is not the active first-launch path in `MarksApp` as currently written.
|
||||
- Disconnect behavior currently resets via the default config callback path instead of clearly deleting config and returning to onboarding.
|
||||
- README AI provider text appears older than source: current backend AI/podcast features go through `MarksAuth` + `ClaudeService`, while Ask uses Apple's on-device `FoundationModels`.
|
||||
- `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.
|
||||
- Share-extension podcast queue draining clears requests before generation success, so crash/retry semantics deserve care.
|
||||
- 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
|
||||
|
||||
`/docs/design-wwdc26.md` is a useful source for UI direction. It emphasizes native iOS 26 UI/Liquid Glass behavior, editorial bookmark rows as the brand canvas, Dynamic Type-safe row layout, bottom/reachable search patterns, WidgetKit app-group data, and standard platform affordances. Link to it rather than duplicating the full design brief.
|
||||
85
openwiki/quickstart.md
Normal file
85
openwiki/quickstart.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 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, 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.
|
||||
|
||||
## What this repository contains
|
||||
|
||||
- **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, `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, source storage, widget/podcast data, analytics, and logging.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Design notes** in `/docs/design-wwdc26.md` for iOS 26/Liquid Glass, search, widgets, and row-design guidance.
|
||||
|
||||
## Major documentation pages
|
||||
|
||||
- [Architecture notes](architecture/app-architecture.md) — target layout, state flow, networking, persistence, AI, Spotlight, extensions, widgets, and App Intents.
|
||||
- [Product workflows](workflows/product-workflows.md) — how bookmark browsing, saving, AI enrichment, Ask, smart collections, podcasts, widgets, and intents behave.
|
||||
- [Development and testing](operations/development-and-testing.md) — setup/build/test commands, generated project guidance, storage/config map, verification checklist, and caveats.
|
||||
|
||||
## Build and run
|
||||
|
||||
The README documents the intended local setup:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
xcodegen generate
|
||||
open Marks.xcodeproj
|
||||
```
|
||||
|
||||
Then build and run from Xcode using the `Marks` scheme. `/project.yml` defines Swift 6.0, iOS 26.0 deployment target, the `Marks`, `ShareExtension`, `MarksWidget`, and `MarksTests` targets, and the app-group entitlements used by all app surfaces.
|
||||
|
||||
Useful checks before shipping a change:
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
xcodebuild test -scheme Marks -destination 'platform=iOS Simulator,name=iPhone 16'
|
||||
```
|
||||
|
||||
Adjust the simulator name to what is installed locally.
|
||||
|
||||
## 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, 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. `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.
|
||||
|
||||
## External systems and privacy boundaries
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
## Important caveats for future agents
|
||||
|
||||
- The README says optional AI features use OpenRouter/Gemini directly. Current source routes backend AI/podcast calls through `MarksAuth` + `ClaudeService`; the in-app Ask feature is on-device via `FoundationModels`. Prefer source evidence when changing AI behavior.
|
||||
- `OnboardingView` exists, but current launch flow uses saved-or-default config directly. Do not assume onboarding is active without changing `/Marks/MarksApp.swift`.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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`.
|
||||
- AI/backend changes: `/Marks/Services/ClaudeService.swift`, `/Marks/Services/MarksAuth.swift`, `/Marks/Services/BookmarkAssistant.swift`, `/Marks/Services/BookmarkSearchTool.swift`.
|
||||
- Source ingest and local documents: `/Marks/Views/SourcesView.swift`, `/Marks/Models/IngestedSource.swift`, `/Marks/Services/IngestedSourceStore.swift`, `/Marks/Services/SourceSpotlightIndexer.swift`.
|
||||
- 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`.
|
||||
- App Intents/Siri/Shortcuts: `/Marks/Intents/*`, `/MarksTests/AppIntentsTests.swift`.
|
||||
174
openwiki/workflows/product-workflows.md
Normal file
174
openwiki/workflows/product-workflows.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Product Workflows
|
||||
|
||||
This page describes the user-facing behavior implemented by the app and the source files that own each flow.
|
||||
|
||||
## Browse bookmarks
|
||||
|
||||
Primary sources: `/Marks/Views/BookmarksView.swift`, `/Marks/Views/BookmarkListRow.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Services/LinkdingAPI.swift`.
|
||||
|
||||
The Bookmarks tab loads linkding bookmarks through `BookmarksViewModel.load()`. The view supports loading skeletons, pull-to-refresh, pagination as the user scrolls, an unread filter, and row actions.
|
||||
|
||||
A bookmark row displays title/domain/date context, unread state, available excerpt/AI summary, tags/AI tags, reading progress, and podcast-cache state. Tapping opens `BrowserView`. Swipe/context actions support archive, delete, edit, Safari open, and podcast conversion.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- Keep row actions aligned across `BookmarksView`, `BookmarkListRow`, `BookmarkRow`, and `BrowserView`.
|
||||
- `BookmarksViewModel.saveToCache(_:)` intentionally skips saving when the unread filter is active, so filtered results do not replace the full cache.
|
||||
- Pagination uses linkding's `next` URL through `fetchBookmarksFromUrl(_:)`; do not rebuild next URLs unless the linkding API contract changes.
|
||||
|
||||
## Add and edit bookmarks in app
|
||||
|
||||
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, 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.
|
||||
|
||||
## Search and semantic search
|
||||
|
||||
Primary sources: `/Marks/Views/SearchView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Services/ClaudeService.swift`.
|
||||
|
||||
The Search tab has two paths:
|
||||
|
||||
- normal search through linkding's bookmark API (`q` parameter),
|
||||
- semantic search through `ClaudeService.semanticSearch(query:in:)`, which fetches up to 200 bookmarks and sends a compact list to the backend for ranking.
|
||||
|
||||
`SearchMarksIntent` routes spoken/system search text into `IntentRouter`, `MainContainer` switches to the Search tab, and `SearchView` consumes the router text into its visible search field.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- Search state lives on the shared `BookmarksViewModel`; search and unread filters can affect list state outside Search.
|
||||
- Backend semantic search sends bookmark titles/domains and optional summaries to the Marks backend. Preserve that privacy boundary in UI copy and docs.
|
||||
|
||||
## Tags
|
||||
|
||||
Primary sources: `/Marks/Views/TagsView.swift`, `/Marks/Models/Bookmark.swift`.
|
||||
|
||||
The Tags tab derives tag counts from both linkding tags (`Bookmark.tagNames`) and local AI tags (`Bookmark.aiTags`), sorted by count/name. Tapping a tag shows matching bookmarks and reuses bookmark row/browser flows.
|
||||
|
||||
If changing tag semantics, check in-app Add/Edit, share extension tag parsing, linkding `fetchTags()`, AI suggestions, Tags tab counts, and App Intents bookmark entity fields.
|
||||
|
||||
## AI enrichment and smart collections
|
||||
|
||||
Primary sources: `/Marks/Services/ClaudeService.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/CollectionsView.swift`, `/Marks/Services/AISummaryStore.swift`.
|
||||
|
||||
AI enrichment generates a short summary and tags for a bookmark. The app stores AI metadata locally in `UserDefaults.standard` under `aiData`, keyed by bookmark ID, and mirrors summaries by URL through `AISummaryStore` for podcast/list display.
|
||||
|
||||
Enrichment can happen in three ways:
|
||||
|
||||
- after adding a bookmark,
|
||||
- as a silent background pass over up to five loaded bookmarks without summaries,
|
||||
- manually through Enrich All.
|
||||
|
||||
Smart Collections send up to 150 bookmarks to the backend and render the returned collection names, descriptions, and bookmark IDs in `CollectionsView`.
|
||||
|
||||
Caveat: README language mentions OpenRouter/Gemini directly, but current source uses the Marks backend proxy for these calls.
|
||||
|
||||
## Ask your bookmarks
|
||||
|
||||
Primary sources: `/Marks/Views/AskView.swift`, `/Marks/Services/BookmarkAssistant.swift`, `/Marks/Services/BookmarkSearchTool.swift`, `/Marks/Services/SpotlightBookmarkSearch.swift`, `/Marks/Services/SpotlightIndexer.swift`.
|
||||
|
||||
Ask is an on-device RAG workflow. `BookmarkAssistant` creates a `FoundationModels` language model session with a bookmark search tool. The model instructions say bookmarks are the only source of truth and require the tool for lookup.
|
||||
|
||||
The retrieval corpus comes from Spotlight. `BookmarksViewModel` indexes bookmarks after loading, loading more, adding, and enrichment. Deleted/archived bookmarks are removed from Spotlight.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- If Ask gives stale answers, inspect Spotlight indexing and removal paths first.
|
||||
- If changing indexed fields, update `BookmarkEntity`, `SpotlightIndexer`, search tooling, and tests where relevant.
|
||||
- The Ask path is separate from backend semantic search and does not use `ClaudeService`.
|
||||
|
||||
## Share extension save flow
|
||||
|
||||
Primary sources: `/Marks/Models/IngestPayload.swift`, `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, `/Marks/Services/PodcastRequests.swift`, `/project.yml`.
|
||||
|
||||
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,
|
||||
2. calls `LinkdingAPI.checkBookmark(url:)`,
|
||||
3. pre-fills existing bookmark data or scraped metadata/linkding auto-tags,
|
||||
4. fetches existing tag vocabulary and backend AI tag suggestions,
|
||||
5. lets the user edit tags, notes, read-later state, and Create Podcast.
|
||||
|
||||
On save, existing bookmarks are patched; new bookmarks are created. If Create Podcast is enabled, or if configured auto-tags match, the extension enqueues a `PodcastRequests` item instead of generating audio itself.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
|
||||
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, or a locally ingested source with extracted text, into audio through the Marks backend:
|
||||
|
||||
1. create generation job (`/v1/podcast/generate`),
|
||||
2. poll status every few seconds,
|
||||
3. download audio when done,
|
||||
4. save MP3 under Application Support `podcasts/`,
|
||||
5. upsert `PodcastIndex`,
|
||||
6. reload `PodcastLibrary`,
|
||||
7. write recent podcast metadata for widgets.
|
||||
|
||||
`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.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
Primary sources: `/MarksWidget/WidgetDataStore.swift`, `/MarksWidget/RecentBookmarksWidget.swift`, `/MarksWidget/RandomBookmarkWidget.swift`, `/MarksWidget/RecentPodcastsWidget.swift`, `/Marks/MarksApp.swift`.
|
||||
|
||||
Widgets are offline readers of app-group JSON data:
|
||||
|
||||
- Recent Bookmarks shows up to three recent bookmarks and links rows to `marks://bookmark`.
|
||||
- Random Bookmark chooses a random cached bookmark and links the whole widget to `marks://bookmark`.
|
||||
- Recent Podcasts shows up to three podcasts and links rows to `marks://podcast`.
|
||||
|
||||
The main app handles bookmark links by presenting `BrowserView`; podcast links start playback and show `PodcastPlayerView`.
|
||||
|
||||
## App Intents, Siri, and Shortcuts
|
||||
|
||||
Primary sources: `/Marks/Intents/MarksAppIntents.swift`, `/Marks/Intents/BookmarkEntity.swift`, `/Marks/Intents/IntentSupport.swift`, `/Marks/Intents/BookmarkOnscreen.swift`, `/MarksTests/AppIntentsTests.swift`.
|
||||
|
||||
Implemented intents:
|
||||
|
||||
- Open Bookmark — routes a selected entity URL into the app browser.
|
||||
- Search Marks — system search schema; opens Search tab with criteria text.
|
||||
- Add Bookmark — headless linkding save and widget refresh.
|
||||
- Show Unread Bookmarks — fetches up to ten unread items.
|
||||
- Summarize Bookmark — backend AI summary for a selected bookmark.
|
||||
|
||||
`BookmarkEntity` maps linkding bookmarks into App Intents entities. Browser presentations annotate current bookmarks for on-screen entity references. Tests currently cover entity mapping, router behavior, direct no-network open/search intent perform calls, and shortcut registration.
|
||||
@@ -86,10 +86,13 @@ targets:
|
||||
# Shared with the main app so the extension can talk to linkding + the AI
|
||||
# backend (the save card checks for duplicates and suggests tags).
|
||||
- path: Marks/Models/Bookmark.swift
|
||||
- path: Marks/Models/IngestPayload.swift
|
||||
- path: Marks/Models/ServerConfig.swift
|
||||
- path: Marks/Services/LinkdingAPI.swift
|
||||
- path: Marks/Services/MarksAuth.swift
|
||||
- path: Marks/Services/Log.swift
|
||||
# Cross-process podcast request queue: the extension enqueues, the app runs it.
|
||||
- path: Marks/Services/PodcastRequests.swift
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension
|
||||
|
||||
Reference in New Issue
Block a user