Compare commits

10 Commits

Author SHA1 Message Date
Krishna Kumar
8e1d94a4ea Add source editing to ingest
All checks were successful
CI / build-and-deploy (push) Successful in 25s
2026-07-02 16:35:02 -05:00
Krishna Kumar
f7735431a8 Update OpenWiki for source ingest
All checks were successful
CI / build-and-deploy (push) Successful in 16s
2026-07-02 01:20:18 -05:00
Krishna Kumar
f8693f4be0 Add local source ingest and podcasts 2026-07-02 01:11:03 -05:00
Krishna Kumar
4d875e12d6 add openwiki docs
All checks were successful
CI / build-and-deploy (push) Successful in 17s
2026-07-02 00:33:49 -05:00
b496e607fc Merge pull request 'feat(podcasts): played/unplayed queue, background generation, share-sheet audio' (#4) from feat/podcast-app-experience into master
All checks were successful
CI / build-and-deploy (push) Successful in 15s
2026-07-01 18:18:56 +00:00
Krishna Kumar
1f49a7dcbe feat(podcasts): #8 follow-ups — AI summary + in-app Open Bookmark
All checks were successful
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (pull_request) Successful in 18s
Resolves the two deviations from the episode detail sheet:

- AI summary now shows in the detail sheet. Added a URL-keyed AISummaryStore
  that BookmarksViewModel populates as it enriches/loads bookmarks, so the
  decoupled Podcasts tab can surface a summary from just the episode URL —
  without recoupling to BookmarksViewModel.
- "Open Bookmark" opens the in-app BrowserView as a nested sheet (generator
  threaded through PodcastLibraryView / EpisodePickerView), alongside the
  existing "Open in Safari" and Share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:16:16 -05:00
Krishna Kumar
a155c15e17 feat(podcasts): #8 episode detail sheet (source, played state, actions)
All checks were successful
CI / build-and-deploy (push) Successful in 17s
CI / build-and-deploy (pull_request) Successful in 17s
- New EpisodeDetailView: title, source domain, created date, played badge,
  and per-episode actions — Play/Pause, Mark Played/Unplayed, Open Source,
  Share, Delete — all routed through the PodcastLibrary store so the badge
  stays in sync.
- Reachable by tapping a row (or "Episode Details" in the context menu) in the
  Podcasts tab, and by tapping a row in the episode picker.

Note: AI summary is intentionally omitted for now — it's keyed by bookmark id
(not URL), so surfacing it would recouple the deliberately-decoupled Podcasts
tab to BookmarksViewModel. Open Source uses Safari rather than the in-app
browser to avoid sheet-stacking. Both are noted as follow-ups on issue #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:11:46 -05:00
Krishna Kumar
d2ccbc94b5 feat(podcasts): #6 quick wins — tab badge, remember speed, sleep timer, persist queue
All checks were successful
CI / build-and-deploy (push) Successful in 17s
CI / build-and-deploy (pull_request) Successful in 18s
- Unread badge on the Podcasts tab via a new observable PodcastLibrary store
  that mirrors PodcastIndex; all played/remove/generation mutations route
  through it so the count stays live.
- Persist playback speed across sessions (UserDefaults), applied on setup.
- Sleep timer (15/30/45 min or end-of-episode) with a moon menu in the full
  player; end-of-episode stops instead of auto-advancing.
- Persist the current episode + Up Next queue and restore a *paused* session on
  launch; save position when backgrounding. Audio session is only claimed on
  actual playback so a restored session never interrupts other audio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:07:31 -05:00
Krishna Kumar
5e69d99996 feat(podcasts): surface mark-played in context menu, full player, and row badge
All checks were successful
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (pull_request) Successful in 18s
- Add a long-press context menu on each library row (Play/Pause,
  Mark as Played/Unplayed, Delete).
- Add a Mark as Played/Unplayed control next to Share Episode in the full
  player, tracking the loaded episode across queue advances.
- Show a green checkmark on played rows, on top of the existing Played
  section + dimmed title, so played state reads at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:01:57 -05:00
Krishna Kumar
af3112530e feat(podcasts): played/unplayed queue, background generation, share-sheet audio
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s
Turns the Podcasts tab into a proper podcast-app experience across three
features that compose on a shared background-generation service.

Background generation (#2)
- New PodcastGenerationManager runs generate→poll→download off the player,
  so producing a new episode never stops current playback.
- Headphone taps never interrupt: idle → generate-and-play in the foreground
  (unchanged); already playing → generate in the background, episode lands in
  the library. A "Generating…" banner surfaces active jobs.

Played/unplayed + queue (#1)
- PodcastEntry gains playedAt (old index.json decodes as unplayed).
- Podcasts tab splits into Up Next / Played with a Play All queue that
  auto-advances; finishing marks played; swipe to toggle played state.

Share-extension audio (#3)
- "Create podcast" toggle on the save card, plus configurable Auto-Podcast
  Tags in Settings. The extension enqueues a request into the App Group; the
  main app drains it on launch/foreground and generates in the background.

Closes #1
Closes #2
Closes #3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:37:42 -05:00
34 changed files with 2680 additions and 123 deletions

10
AGENTS.md Normal file
View 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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
import Foundation
struct IngestPayload: Equatable, Sendable {
let url: String
let title: String?
let notes: String
}
enum IngestPayloadParser {
static func parse(item: Any?, typeIdentifier: String? = nil) -> IngestPayload? {
if let url = item as? URL {
return payload(from: url.absoluteString, notes: "")
}
if let attributed = item as? NSAttributedString {
return payload(from: attributed.string, notes: attributed.string)
}
if let string = item as? String {
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
}
if let data = item as? Data,
let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) {
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
}
return nil
}
private static func payload(from text: String, notes: String) -> IngestPayload? {
guard let url = firstWebURL(in: text) else { return nil }
return IngestPayload(url: url, title: nil, notes: cleanedNotes(notes, excluding: url))
}
static func firstWebURL(in text: String) -> String? {
if let direct = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
isBookmarkable(direct) {
return direct.absoluteString
}
let decoded = decodeHTML(text)
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(decoded.startIndex..<decoded.endIndex, in: decoded)
let match = detector?.matches(in: decoded, range: range).first { result in
guard let url = result.url else { return false }
return isBookmarkable(url)
}
guard let matchRange = match?.range,
let swiftRange = Range(matchRange, in: decoded) else {
return nil
}
let candidate = String(decoded[swiftRange])
.trimmingCharacters(in: CharacterSet(charactersIn: " \n\t\r<>\"'.,;:)]】」"))
guard let url = URL(string: candidate), isBookmarkable(url) else { return nil }
return url.absoluteString
}
private static func isBookmarkable(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
url.host?.isEmpty == false else {
return false
}
return true
}
private static func cleanedNotes(_ notes: String, excluding url: String) -> String {
let trimmed = decodeHTML(notes)
.replacingOccurrences(of: url, with: "")
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
return String(trimmed.prefix(1800))
}
private static func decodeHTML(_ text: String) -> String {
text
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
}
private static func shouldKeepNotes(for typeIdentifier: String?) -> Bool {
guard let typeIdentifier else { return true }
return !typeIdentifier.lowercased().contains("url")
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
enum IngestedSourceKind: String, Codable, Sendable, CaseIterable {
case text
case pdf
case file
var label: String {
switch self {
case .text: "Text"
case .pdf: "PDF"
case .file: "File"
}
}
}
struct IngestedSource: Identifiable, Codable, Sendable, Hashable {
let id: UUID
var kind: IngestedSourceKind
var title: String
var bodyText: String
var originalFilename: String?
var localFilename: String?
var tags: [String]
var createdAt: Date
var modifiedAt: Date
init(
id: UUID = UUID(),
kind: IngestedSourceKind,
title: String,
bodyText: String,
originalFilename: String? = nil,
localFilename: String? = nil,
tags: [String] = [],
createdAt: Date = Date(),
modifiedAt: Date = Date()
) {
self.id = id
self.kind = kind
self.title = title
self.bodyText = bodyText
self.originalFilename = originalFilename
self.localFilename = localFilename
self.tags = tags
self.createdAt = createdAt
self.modifiedAt = modifiedAt
}
var displayTitle: String {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
return originalFilename ?? kind.label
}
var excerpt: String {
bodyText
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\n", with: " ")
}
var podcastURL: String {
"marks-source://\(id.uuidString)"
}
var podcastText: String {
String(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).prefix(100_000))
}
}

View File

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

View File

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

View File

@@ -0,0 +1,212 @@
import Foundation
import Observation
import PDFKit
import UniformTypeIdentifiers
@MainActor
@Observable
final class IngestedSourceLibrary {
private(set) var sources: [IngestedSource] = []
var error: String?
private let store: IngestedSourceStore
init(store: IngestedSourceStore = IngestedSourceStore()) {
self.store = store
}
func load() {
do {
sources = try store.load()
Task { await SourceSpotlightIndexer.index(sources) }
} catch {
self.error = error.localizedDescription
}
}
func addText(title: String, body: String, tags: [String]) {
let source = IngestedSource(
kind: .text,
title: title,
bodyText: body.trimmingCharacters(in: .whitespacesAndNewlines),
tags: tags
)
upsert(source)
}
func importFile(from url: URL, tags: [String]) {
do {
let source = try store.importFile(from: url, tags: tags)
upsert(source)
} catch {
self.error = error.localizedDescription
}
}
func update(_ source: IngestedSource, title: String, bodyText: String, tags: [String]) {
var updated = source
updated.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
updated.bodyText = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
updated.tags = tags
updated.modifiedAt = Date()
upsert(updated)
}
func delete(_ source: IngestedSource) {
do {
try store.delete(source)
sources.removeAll { $0.id == source.id }
Task { await SourceSpotlightIndexer.remove(ids: [source.id]) }
} catch {
self.error = error.localizedDescription
}
}
func fileURL(for source: IngestedSource) -> URL? {
store.fileURL(for: source)
}
func search(_ query: String) -> [IngestedSource] {
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !q.isEmpty else { return sources }
return sources.filter { source in
source.displayTitle.lowercased().contains(q) ||
source.bodyText.lowercased().contains(q) ||
source.tags.contains { $0.lowercased().contains(q) } ||
(source.originalFilename ?? "").lowercased().contains(q)
}
}
private func upsert(_ source: IngestedSource) {
sources.removeAll { $0.id == source.id }
sources.insert(source, at: 0)
do {
try store.save(sources)
Task { await SourceSpotlightIndexer.index([source]) }
} catch {
self.error = error.localizedDescription
}
}
}
struct IngestedSourceStore {
private let rootURL: URL
private let metadataURL: URL
private let filesURL: URL
init(rootURL: URL? = nil) {
let base = rootURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
self.rootURL = base.appendingPathComponent("Sources", isDirectory: true)
self.metadataURL = self.rootURL.appendingPathComponent("sources.json")
self.filesURL = self.rootURL.appendingPathComponent("files", isDirectory: true)
}
func load() throws -> [IngestedSource] {
guard FileManager.default.fileExists(atPath: metadataURL.path) else { return [] }
let data = try Data(contentsOf: metadataURL)
return try JSONDecoder.sourceDecoder.decode([IngestedSource].self, from: data)
.sorted { $0.createdAt > $1.createdAt }
}
func save(_ sources: [IngestedSource]) throws {
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
let data = try JSONEncoder.sourceEncoder.encode(sources)
try data.write(to: metadataURL, options: [.atomic])
}
func importFile(from url: URL, tags: [String]) throws -> IngestedSource {
try FileManager.default.createDirectory(at: filesURL, withIntermediateDirectories: true)
let accessed = url.startAccessingSecurityScopedResource()
defer {
if accessed { url.stopAccessingSecurityScopedResource() }
}
let id = UUID()
let originalFilename = url.lastPathComponent
let ext = url.pathExtension.isEmpty ? "dat" : url.pathExtension
let localFilename = "\(id.uuidString).\(ext)"
let destination = filesURL.appendingPathComponent(localFilename)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.copyItem(at: url, to: destination)
let kind = url.pathExtension.lowercased() == "pdf" ? IngestedSourceKind.pdf : .file
let bodyText = kind == .pdf
? PDFTextExtractor.extractText(from: destination)
: PlainTextExtractor.extractText(from: destination)
return IngestedSource(
id: id,
kind: kind,
title: originalFilename.removingFileExtension,
bodyText: bodyText,
originalFilename: originalFilename,
localFilename: localFilename,
tags: tags
)
}
func delete(_ source: IngestedSource) throws {
if let url = fileURL(for: source), FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
var loaded = try load()
loaded.removeAll { $0.id == source.id }
try save(loaded)
}
func fileURL(for source: IngestedSource) -> URL? {
guard let localFilename = source.localFilename else { return nil }
return filesURL.appendingPathComponent(localFilename)
}
}
private enum PDFTextExtractor {
static func extractText(from url: URL) -> String {
guard let document = PDFDocument(url: url) else { return "" }
var pages: [String] = []
for index in 0..<document.pageCount {
if let text = document.page(at: index)?.string?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
pages.append(text)
}
}
return pages.joined(separator: "\n\n")
}
}
private enum PlainTextExtractor {
static func extractText(from url: URL) -> String {
guard let data = try? Data(contentsOf: url) else { return "" }
return String(data: data, encoding: .utf8)
?? String(data: data, encoding: .ascii)
?? ""
}
}
private extension String {
var removingFileExtension: String {
let ns = self as NSString
let name = ns.deletingPathExtension
return name.isEmpty ? self : name
}
}
private extension JSONEncoder {
static var sourceEncoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
private extension JSONDecoder {
static var sourceDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}

View File

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

View File

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

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

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

View File

@@ -0,0 +1,40 @@
import CoreSpotlight
import Foundation
import UniformTypeIdentifiers
enum SourceSpotlightIndexer {
private static let domainIdentifier = "com.magicive.marks.sources"
static func index(_ sources: [IngestedSource]) async {
guard CSSearchableIndex.isIndexingAvailable(), !sources.isEmpty else { return }
let items = sources.map { source in
let attributes = CSSearchableItemAttributeSet(contentType: source.kind == .pdf ? .pdf : .text)
attributes.title = source.displayTitle
attributes.contentDescription = source.bodyText
attributes.keywords = source.tags + [source.kind.label, source.originalFilename].compactMap { $0 }
attributes.displayName = source.originalFilename ?? source.displayTitle
return CSSearchableItem(
uniqueIdentifier: source.id.uuidString,
domainIdentifier: domainIdentifier,
attributeSet: attributes
)
}
do {
try await CSSearchableIndex.default().indexSearchableItems(items)
Log.spotlight.info("Indexed \(sources.count, privacy: .public) sources")
} catch {
Log.spotlight.error("Source index failed: \(error.localizedDescription, privacy: .public)")
}
}
static func remove(ids: [UUID]) async {
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
do {
try await CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids.map(\.uuidString))
Log.spotlight.info("Removed \(ids.count, privacy: .public) sources")
} catch {
Log.spotlight.error("Source delete failed: \(error.localizedDescription, privacy: .public)")
}
}
}

View File

@@ -6,13 +6,44 @@ struct AddBookmarkView: View {
@State private var url = ""
@State private var 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,440 @@
import QuickLook
import SwiftUI
import UniformTypeIdentifiers
struct SourcesView: View {
@Bindable var library: IngestedSourceLibrary
let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
let claude: ClaudeService
@State private var searchText = ""
@State private var showTextImport = false
@State private var showFileImporter = false
@State private var selectedSource: IngestedSource?
@State private var editingSource: IngestedSource?
@State private var showFullPlayer = false
private var results: [IngestedSource] {
library.search(searchText)
}
var body: some View {
NavigationStack {
List {
ForEach(results) { source in
HStack(spacing: 10) {
Button {
selectedSource = source
} label: {
SourceRow(source: source)
}
.buttonStyle(.plain)
Button {
playOrGeneratePodcast(for: source)
} label: {
Image(systemName: podcastIcon(for: source))
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.disabled(source.podcastText.isEmpty)
}
.swipeActions {
Button {
playOrGeneratePodcast(for: source)
} label: {
Label("Podcast", systemImage: "headphones")
}
.tint(.blue)
Button(role: .destructive) {
library.delete(source)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Sources")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
showTextImport = true
} label: {
Label("Import Text", systemImage: "doc.text")
}
Button {
showFileImporter = true
} label: {
Label("Import File", systemImage: "doc.badge.plus")
}
} label: {
Image(systemName: "plus")
}
}
}
.overlay {
if results.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "No Sources" : "No Results",
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
)
}
}
.searchable(text: $searchText, prompt: "Search sources")
}
.task { library.load() }
.sheet(isPresented: $showTextImport) {
ImportTextSourceView(library: library)
}
.sheet(item: $selectedSource) { source in
SourceDetailView(
source: source,
fileURL: library.fileURL(for: source),
podcastCached: podcastCached(for: source),
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
onEdit: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
editingSource = source
}
},
onPodcast: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
playOrGeneratePodcast(for: source)
}
}
)
}
.sheet(item: $editingSource) { source in
EditSourceView(source: source, library: library)
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: podcastPlayer,
articleUrl: podcastPlayer.currentArticleUrl,
articleTitle: podcastPlayer.currentArticleTitle,
claude: claude,
stopOnDismiss: false
)
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.pdf, .plainText, .text, .data],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
for url in urls {
library.importFile(from: url, tags: [])
}
case .failure(let error):
library.error = error.localizedDescription
}
}
.alert("Source Error", isPresented: Binding(
get: { library.error != nil },
set: { if !$0 { library.error = nil } }
)) {
Button("OK") { library.error = nil }
} message: {
Text(library.error ?? "")
}
}
private func playOrGeneratePodcast(for source: IngestedSource) {
let podcastURL = source.podcastURL
let title = source.displayTitle
let existing = PodcastIndex.find(for: podcastURL).first
if let existing {
podcastPlayer.start(articleUrl: existing.articleUrl, articleTitle: existing.title ?? title, claude: claude)
showFullPlayer = true
return
}
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start(
articleUrl: podcastURL,
articleTitle: title,
claude: claude,
sourceText: source.podcastText,
sourceKind: source.kind.rawValue
)
showFullPlayer = true
} else {
podcastGenerator.enqueue(
articleUrl: podcastURL,
title: title,
sourceText: source.podcastText,
sourceKind: source.kind.rawValue
)
}
}
private func podcastCached(for source: IngestedSource) -> Bool {
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: source.podcastURL).path)
}
private func podcastIcon(for source: IngestedSource) -> String {
if podcastGenerator.isGenerating(source.podcastURL) { return "waveform.circle.fill" }
if podcastCached(for: source) { return "headphones.circle.fill" }
return "headphones.circle"
}
}
private struct SourceRow: View {
let source: IngestedSource
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.blue)
.frame(width: 30, height: 30)
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 5) {
Text(source.displayTitle)
.font(.headline)
.lineLimit(2)
if !source.excerpt.isEmpty {
Text(source.excerpt)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
HStack(spacing: 8) {
Text(source.kind.label)
Text(source.createdAt, style: .date)
if !source.tags.isEmpty {
Text(source.tags.joined(separator: ", "))
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
}
private var iconName: String {
switch source.kind {
case .text: "doc.text"
case .pdf: "doc.richtext"
case .file: "doc"
}
}
}
private struct ImportTextSourceView: View {
@Bindable var library: IngestedSourceLibrary
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var bodyText = ""
@State private var tagsText = ""
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Optional", text: $title)
}
Section("Text") {
TextEditor(text: $bodyText)
.frame(minHeight: 180)
.textInputAutocapitalization(.sentences)
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
}
.navigationTitle("Import Text")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
library.addText(title: title, body: bodyText, tags: tagsText.parsedTags)
dismiss()
}
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct EditSourceView: View {
let source: IngestedSource
@Bindable var library: IngestedSourceLibrary
@Environment(\.dismiss) private var dismiss
@State private var title: String
@State private var bodyText: String
@State private var tagsText: String
init(source: IngestedSource, library: IngestedSourceLibrary) {
self.source = source
self.library = library
_title = State(initialValue: source.title)
_bodyText = State(initialValue: source.bodyText)
_tagsText = State(initialValue: source.tags.joined(separator: ", "))
}
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Optional", text: $title)
}
Section {
TextEditor(text: $bodyText)
.frame(minHeight: 220)
.textInputAutocapitalization(.sentences)
} header: {
Text(source.kind == .text ? "Text" : "Extracted Text")
} footer: {
if source.kind != .text {
Text("Editing extracted text does not modify the original file.")
}
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
}
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
library.update(source, title: title, bodyText: bodyText, tags: tagsText.parsedTags)
dismiss()
}
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct SourceDetailView: View {
let source: IngestedSource
let fileURL: URL?
let podcastCached: Bool
let podcastGenerating: Bool
let onEdit: () -> Void
let onPodcast: () -> Void
@State private var previewURL: URL?
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(source.displayTitle)
.font(.title2.weight(.semibold))
Text(source.kind.label)
.font(.subheadline)
.foregroundStyle(.secondary)
}
if !source.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(source.tags, id: \.self) { tag in
Text(tag)
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.blue.opacity(0.12), in: Capsule())
}
}
}
}
if let fileURL {
HStack {
Button {
previewURL = fileURL
} label: {
Label("Open Original", systemImage: "doc.viewfinder")
}
.buttonStyle(.bordered)
Button {
onPodcast()
} label: {
Label(podcastTitle, systemImage: "headphones")
}
.buttonStyle(.borderedProminent)
.disabled(source.podcastText.isEmpty || podcastGenerating)
}
} else {
Button {
onPodcast()
} label: {
Label(podcastTitle, systemImage: "headphones")
}
.buttonStyle(.borderedProminent)
.disabled(source.podcastText.isEmpty || podcastGenerating)
}
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
.font(.body)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
.navigationTitle("Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
}
}
}
.quickLookPreview($previewURL)
}
private var podcastTitle: String {
if podcastGenerating { return "Generating" }
if podcastCached { return "Play Podcast" }
return "Create Podcast"
}
}

View File

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

View File

@@ -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&amp;y=2\">article</a></p>"
let payload = IngestPayloadParser.parse(item: html, typeIdentifier: "public.html")
XCTAssertEqual(payload?.url, "https://example.com/a?x=1&y=2")
}
// MARK: - Local sources
func testSourceStoreSavesAndLoadsMetadata() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let store = IngestedSourceStore(rootURL: root)
let source = IngestedSource(kind: .text, title: "Meeting Notes", bodyText: "Follow up on paper receipts.", tags: ["receipts"])
try store.save([source])
let loaded = try store.load()
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded.first?.id, source.id)
XCTAssertEqual(loaded.first?.kind, .text)
XCTAssertEqual(loaded.first?.title, "Meeting Notes")
XCTAssertEqual(loaded.first?.bodyText, "Follow up on paper receipts.")
XCTAssertEqual(loaded.first?.tags, ["receipts"])
}
func testSourceStoreImportsPlainTextFile() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let input = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt")
defer {
try? FileManager.default.removeItem(at: root)
try? FileManager.default.removeItem(at: input)
}
try Data("Offline clipping text".utf8).write(to: input)
let store = IngestedSourceStore(rootURL: root)
let source = try store.importFile(from: input, tags: ["offline"])
XCTAssertEqual(source.kind, .file)
XCTAssertEqual(source.bodyText, "Offline clipping text")
XCTAssertEqual(source.tags, ["offline"])
XCTAssertNotNil(store.fileURL(for: source))
}
@MainActor
func testSourceLibraryUpdatesMetadataAndSearch() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let library = IngestedSourceLibrary(store: IngestedSourceStore(rootURL: root))
library.addText(title: "Draft", body: "Original extracted text", tags: ["raw"])
let source = try XCTUnwrap(library.sources.first)
library.update(source, title: "Cleaned Notes", bodyText: "Cleaned source body", tags: ["clean", "notes"])
let updated = try XCTUnwrap(library.sources.first)
XCTAssertEqual(updated.title, "Cleaned Notes")
XCTAssertEqual(updated.bodyText, "Cleaned source body")
XCTAssertEqual(updated.tags, ["clean", "notes"])
XCTAssertGreaterThanOrEqual(updated.modifiedAt, source.modifiedAt)
XCTAssertEqual(library.search("clean").first?.id, source.id)
let reloaded = IngestedSourceStore(rootURL: root)
XCTAssertEqual(try reloaded.load().first?.title, "Cleaned Notes")
}
}

View File

@@ -5,6 +5,8 @@ import SwiftUI
/// read-later flag before saving (new) or updating (existing) never a duplicate.
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)

View File

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

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

View 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.

View 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
View 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`.

View 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.

View File

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