Compare commits

..

13 Commits

Author SHA1 Message Date
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
Krishna Kumar
f8de444f8f feat(podcasts): add a Podcasts tab to list and play on demand
All checks were successful
CI / build-and-deploy (push) Successful in 15s
Surface every podcast in PodcastIndex as a top-level tab instead of a
buried sheet. Repurpose PodcastLibraryView into the tab: play/pause toggle
per episode, a now-playing bar that opens the full player, swipe/Edit to
delete. Uses the shared podcastPlayer so playback state is consistent
across tabs. Removes the redundant Podcasts entry from the bookmarks menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:16:49 -05:00
Krishna Kumar
64a944ab10 style(ui): adopt Liquid Glass on opaque controls
All checks were successful
CI / build-and-deploy (push) Successful in 14s
Swap hardcoded systemGray/Color.blue pill and button fills for the iOS 26
Liquid Glass APIs so controls read native under the new design system:
- BookmarkRow tag chips: glassEffect in a GlassEffectContainer
- Podcast speed button: glassEffect capsule
- Podcast retry + Onboarding Connect: glassProminent button style

Onboarding text-field fills left as systemGray6 (standard adaptive field
fill; glass behind editable text hurts legibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:10:23 -05:00
Krishna Kumar
96ea5fe6f6 feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
All checks were successful
CI / build-and-deploy (push) Successful in 22s
- SpotlightIndexer: actually push BookmarkEntity into the Spotlight index via
  indexAppEntities (IndexedEntity conformance alone indexes nothing). Wired into
  every sync/mutation point. This is what lets Apple Intelligence answer
  free-form Siri questions grounded in the user's bookmarks.
- On-device RAG: BookmarkAssistant (LanguageModelSession) + BookmarkSearchTool
  + SpotlightBookmarkSearch (CSSearchQuery retrieval), surfaced via AskView with
  lightweight Markdown rendering of answers.
- Log: os.Logger facility (com.magicive.marks) replacing ad-hoc print(); shared
  with ShareExtension. ClaudeService now logs server error bodies on non-2xx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:55:32 -05:00
Krishna Kumar
e82e69c5f3 feat(share): dup-aware interactive save card with AI tag suggestions
All checks were successful
CI / build-and-deploy (push) Successful in 20s
Replace the fire-and-forget share toast with an interactive SwiftUI card.
On open it checks linkding's /api/bookmarks/check/ so an already-saved URL
routes to an Update (no more silent duplicates) and a new URL shows a save
form with title, tags, notes, and a read-later toggle.

Tag suggestions are hybrid: fetch the user's existing tag vocabulary
(/api/tags/) and AI tag ideas (/v1/marks/enrich via anonymous device auth),
auto-fill known-vocabulary matches into the field and surface net-new ideas
as tappable chips. Best-effort and non-blocking — failures never block save.

Also:
- Refresh bookmarks when the app returns to the foreground (scenePhase).
- Render bookmark dates as a static relative label instead of the live
  ticking RelativeDateTime timer.
- Share LinkdingAPI/Bookmark/ServerConfig/MarksAuth into the ShareExtension
  target via project.yml (durable across xcodegen regen) and regenerate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:36:46 -05:00
Krishna Kumar
a3059f25fc feat(intents): onscreen entity annotations + App Intents tests
All checks were successful
CI / build-and-deploy (push) Successful in 28s
- bookmarkOnscreen(_:) publishes the viewed bookmark as the on-screen
  entity (NSUserActivity.appEntityIdentifier) so Siri can resolve "this"
  / "summarize this one"; applied to the BrowserView sheets in
  BookmarksView and SearchView
- Add MarksTests (XCTest) covering entity mapping, EntityIdentifier,
  IntentRouter, and the real perform() of SearchMarksIntent and
  OpenBookmarkIntent; wired into the Marks scheme test phase

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:16:46 -05:00
Krishna Kumar
389d615c8b feat(intents): add App Intents + Siri integration
All checks were successful
CI / build-and-deploy (push) Successful in 24s
Expose Marks to Siri, Spotlight, and Shortcuts via App Intents:

- BookmarkEntity (AppEntity + IndexedEntity, keyed on the Linkding
  server id) with a server-backed EntityStringQuery for live lookup
- Intents: Add, Open (OpenIntent), Search, ShowUnread, and an AI
  Summarize intent backed by ClaudeService; AppShortcutsProvider with
  spoken phrases; Siri snippet views and dialog
- SearchMarksIntent conforms to the system.search App Schema
  (@AppIntent(schema:), ShowInAppSearchResultsIntent) so it is
  executable by conversational Siri / Apple Intelligence
- IntentRouter bridges intents (run in the app process) to the SwiftUI
  scene for open/search navigation; widgets refreshed after adds
- Link AppIntents.framework so the metadata processor extracts shortcut
  phrases and validates the assistant schema

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:10:17 -05:00
Krishna Kumar
8a27b692b8 feat(BookmarkRow): show a content excerpt under each bookmark title
All checks were successful
CI / build-and-deploy (push) Successful in 30s
Surface a bit of the page content in the list without fetching/parsing pages
ourselves — linkding already returns the scraped metadata in the bookmark
payload. Add Bookmark.contentExcerpt (website_description, falling back to the
user's note) and render it under the title: AI summary takes priority and stays
italic, otherwise the scraped excerpt shows. Rows with neither degrade to the
existing favicon + title layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:20:28 -05:00
Krishna Kumar
f5c0f0929d feat(BookmarkRow): adopt Dynamic Type, move cache stat off render path, safer delete
Apply WWDC26 design-brief fixes to the bookmark list reading surface:

- Dynamic Type: replace fixed .system(size:) points with text styles
  (title .headline, domain/date .footnote, summary .subheadline, tags
  .caption, podcast glyph .title3). Rows now scale and wrap with the
  user's text-size setting instead of staying fixed.
- Perf: podcastCached was a FileManager.fileExists stat evaluated inside
  body — i.e. per row, per scroll frame. Move it to @State populated by a
  .task(id:) that stats off the main actor, once per appearance.
- Forgiveness: destructive trailing swipe is now allowsFullSwipe: false so
  a single fling can't permanently delete; requires tapping the button.

Adds docs/design-wwdc26.md (consolidated brief + codebase audit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:47:31 -05:00
42 changed files with 2849 additions and 255 deletions

View File

@@ -9,20 +9,34 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; }; 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.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 */; };
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.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 */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; };
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; };
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; 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 */; }; 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
@@ -30,18 +44,29 @@
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; }; AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */; };
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; }; DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; };
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.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 */; }; E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */; };
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7623601C25E481DF58371F2A /* AppIntentsTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -59,6 +84,13 @@
remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F; remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F;
remoteInfo = ShareExtension; remoteInfo = ShareExtension;
}; };
F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
proxyType = 1;
remoteGlobalIDString = 76C272776D8EBCCD3FB6715A;
remoteInfo = Marks;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -83,8 +115,13 @@
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; }; 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.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>"; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; }; 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; };
@@ -93,13 +130,19 @@
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; }; 629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; };
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = "<group>"; };
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = "<group>"; };
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; }; 969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; };
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; }; 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; };
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -107,15 +150,24 @@
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; }; B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
C8B10AC011AF8F242E525440 /* MarksTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MarksTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
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>"; }; 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>"; }; 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>"; }; 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>"; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
FB7728D15C17219ABFF3EFFE /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -125,6 +177,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */, A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -147,6 +200,7 @@
86B2DE40098D2B39FFE1AC36 /* Info.plist */, 86B2DE40098D2B39FFE1AC36 /* Info.plist */,
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */, 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
D92575C7C710347F226EC74A /* MarksApp.swift */, D92575C7C710347F226EC74A /* MarksApp.swift */,
58E8E316BE3F10C5149AADC3 /* Intents */,
3EF3094F53F0378D1BF18508 /* Models */, 3EF3094F53F0378D1BF18508 /* Models */,
7ABBFDF43A1EB773E0A49CFE /* Services */, 7ABBFDF43A1EB773E0A49CFE /* Services */,
AF9CABF75E77168EF1D60F29 /* Views */, AF9CABF75E77168EF1D60F29 /* Views */,
@@ -164,12 +218,34 @@
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
3F5B1508DEFC894F33CBD16C /* MarksTests */ = {
isa = PBXGroup;
children = (
7623601C25E481DF58371F2A /* AppIntentsTests.swift */,
);
path = MarksTests;
sourceTree = "<group>";
};
58E8E316BE3F10C5149AADC3 /* Intents */ = {
isa = PBXGroup;
children = (
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */,
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */,
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */,
D6ACABF0CA940312B4195456 /* IntentSupport.swift */,
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */,
);
path = Intents;
sourceTree = "<group>";
};
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */ = { 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E895C34E4D2A1C4709B25FF1 /* Info.plist */, E895C34E4D2A1C4709B25FF1 /* Info.plist */,
03D800669E343FF305468424 /* ShareExtension.entitlements */, 03D800669E343FF305468424 /* ShareExtension.entitlements */,
9B7A85A23A13D754F6A75E4D /* ShareView.swift */,
ADEAC824576633CC77370262 /* ShareViewController.swift */, ADEAC824576633CC77370262 /* ShareViewController.swift */,
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */,
); );
path = ShareExtension; path = ShareExtension;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -177,11 +253,20 @@
7ABBFDF43A1EB773E0A49CFE /* Services */ = { 7ABBFDF43A1EB773E0A49CFE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
FB7728D15C17219ABFF3EFFE /* Log.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -204,6 +289,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
217E6702DE1210AC38ED16D1 /* AskView.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
@@ -224,6 +310,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3952A081972B444674BEB51B /* Marks */, 3952A081972B444674BEB51B /* Marks */,
3F5B1508DEFC894F33CBD16C /* MarksTests */,
85E717A515682EBF67DD199A /* MarksWidget */, 85E717A515682EBF67DD199A /* MarksWidget */,
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */, 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
F5B3C85424137FA0CE2467B1 /* Frameworks */, F5B3C85424137FA0CE2467B1 /* Frameworks */,
@@ -235,6 +322,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB6C53AB14A38FCD4CC7628D /* Marks.app */, AB6C53AB14A38FCD4CC7628D /* Marks.app */,
C8B10AC011AF8F242E525440 /* MarksTests.xctest */,
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */, 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */, 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
); );
@@ -244,6 +332,7 @@
F5B3C85424137FA0CE2467B1 /* Frameworks */ = { F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */,
093402014727A338745A06DA /* SwiftUI.framework */, 093402014727A338745A06DA /* SwiftUI.framework */,
0552C13335034219DECF4F62 /* WidgetKit.framework */, 0552C13335034219DECF4F62 /* WidgetKit.framework */,
); );
@@ -310,6 +399,24 @@
productReference = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; productReference = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension"; productType = "com.apple.product-type.app-extension";
}; };
F96181AD21F4A6755214E19B /* MarksTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */;
buildPhases = (
1035354FBD99C8ED4ACA93DC /* Sources */,
);
buildRules = (
);
dependencies = (
A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */,
);
name = MarksTests;
packageProductDependencies = (
);
productName = MarksTests;
productReference = C8B10AC011AF8F242E525440 /* MarksTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -331,6 +438,10 @@
DevelopmentTeam = AE5DZKJHGN; DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
F96181AD21F4A6755214E19B = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
}; };
}; };
buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */; buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */;
@@ -348,6 +459,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
76C272776D8EBCCD3FB6715A /* Marks */, 76C272776D8EBCCD3FB6715A /* Marks */,
F96181AD21F4A6755214E19B /* MarksTests */,
61046BF39C48393DD96687A0 /* MarksWidget */, 61046BF39C48393DD96687A0 /* MarksWidget */,
CF31B2655F8F30CF6DF3C26F /* ShareExtension */, CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
); );
@@ -366,11 +478,27 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
1035354FBD99C8ED4ACA93DC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2210C849B430CC6609106C62 /* Sources */ = { 2210C849B430CC6609106C62 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
3528AF5CB690BBCCF337581B /* Bookmark.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 */, 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */,
D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -390,26 +518,41 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */,
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */,
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */, 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */, E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
@@ -424,6 +567,11 @@
target = 61046BF39C48393DD96687A0 /* MarksWidget */; target = 61046BF39C48393DD96687A0 /* MarksWidget */;
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */; targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
}; };
A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 76C272776D8EBCCD3FB6715A /* Marks */;
targetProxy = F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */;
};
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = { A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */; target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
@@ -543,6 +691,23 @@
}; };
name = Release; name = Release;
}; };
68D4EEAD1A7AA570E88EF498 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksTests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Marks.app/Marks";
};
name = Debug;
};
6CDF13C33FDD1141CFE677AD /* Debug */ = { 6CDF13C33FDD1141CFE677AD /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -661,6 +826,23 @@
}; };
name = Release; name = Release;
}; };
E92B937142E9625F95B51699 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksTests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Marks.app/Marks";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -700,6 +882,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
68D4EEAD1A7AA570E88EF498 /* Debug */,
E92B937142E9625F95B51699 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */; rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;

View File

@@ -67,6 +67,17 @@
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F96181AD21F4A6755214E19B"
BuildableName = "MarksTests.xctest"
BlueprintName = "MarksTests"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
<CommandLineArguments> <CommandLineArguments>
</CommandLineArguments> </CommandLineArguments>

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1630"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -40,7 +41,8 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -65,15 +67,6 @@
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A" BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
@@ -81,7 +74,9 @@
BlueprintName = "Marks" BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj"> ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@@ -0,0 +1,122 @@
import Foundation
import AppIntents
import CoreSpotlight
/// A Linkding bookmark exposed to Siri, Spotlight, Shortcuts, and the
/// Apple-Intelligence "Use Model" action.
///
/// Conforms to `IndexedEntity` so its title / description / tags become
/// Spotlight-searchable, which is what lets Siri answer "find my bookmark
/// about Swift concurrency". The identifier is the Linkding server id (`Int`),
/// which is stable across devices.
struct BookmarkEntity: AppEntity, IndexedEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Bookmark")
let id: Int
@Property(title: "Title")
var title: String
@Property(title: "URL")
var url: URL
@Property(title: "Website")
var host: String
@Property(title: "Description")
var details: String
@Property(title: "Tags")
var tags: [String]
@Property(title: "Unread")
var unread: Bool
/// AI-generated summary (when one has been produced). Exposed so the
/// Shortcuts "Use Model" action and Siri can reason over it.
@Property(title: "Summary")
var summary: String?
static let defaultQuery = BookmarkQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(host)",
image: .init(systemName: "bookmark.fill")
)
}
/// Rich Spotlight attributes so semantic search has real content to match.
var attributeSet: CSSearchableItemAttributeSet {
let attrs = CSSearchableItemAttributeSet(contentType: .url)
attrs.title = title
attrs.contentDescription = details.isEmpty ? summary : details
attrs.keywords = tags
attrs.url = url
return attrs
}
}
extension BookmarkEntity {
init(from b: Bookmark) {
self.id = b.id
self.title = b.displayTitle
self.url = URL(string: b.url) ?? URL(string: "https://example.invalid")!
self.host = b.domain
self.details = b.contentExcerpt ?? ""
self.tags = b.tagNames
self.unread = b.unread
self.summary = b.aiSummary
}
/// Reconstruct a minimal `Bookmark` from the entity enough for the
/// AI enrichment call, which only reads url/title/tags.
func makeBookmark() -> Bookmark {
Bookmark(
id: id,
url: url.absoluteString,
title: title,
description: details,
tagNames: tags,
dateAdded: Date(),
dateModified: Date(),
isArchived: false,
unread: unread,
shared: false,
websiteTitle: nil,
websiteDescription: nil,
faviconUrl: nil,
previewImageUrl: nil,
aiSummary: summary,
aiTags: nil
)
}
}
/// Looks bookmarks up by id and resolves free-text queries by hitting the
/// Linkding server live the recommended path for data that lives on a
/// server and changes too often to pre-index.
struct BookmarkQuery: EntityStringQuery {
func entities(for identifiers: [Int]) async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
// Linkding has no batch-by-id endpoint; pull a page and filter.
let response = try await api.fetchBookmarks(limit: 200)
let wanted = Set(identifiers)
return response.results
.filter { wanted.contains($0.id) }
.map(BookmarkEntity.init(from:))
}
func entities(matching string: String) async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(search: string, limit: 25)
return response.results.map(BookmarkEntity.init(from:))
}
func suggestedEntities() async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(limit: 10)
return response.results.map(BookmarkEntity.init(from:))
}
}

View File

@@ -0,0 +1,19 @@
import SwiftUI
import AppIntents
extension View {
/// Marks `bookmark` as the on-screen entity via an `NSUserActivity`, so
/// Siri / Apple Intelligence can resolve references like "summarize this"
/// or "open this one" while the bookmark is being viewed.
///
/// iOS 26.0 ships no SwiftUI `.appEntityIdentifier` modifier, so this uses
/// the supported path: the standard `.userActivity` modifier plus
/// `NSUserActivity.appEntityIdentifier` (from `AppEntityAnnotatable`).
func bookmarkOnscreen(_ bookmark: Bookmark) -> some View {
userActivity("com.magicive.marks.viewBookmark", element: bookmark.id) { id, activity in
activity.title = bookmark.displayTitle
activity.appEntityIdentifier = EntityIdentifier(for: BookmarkEntity.self, identifier: id)
activity.isEligibleForSearch = true
}
}
}

View File

@@ -0,0 +1,53 @@
import SwiftUI
/// Compact card Siri shows after saving / opening a bookmark.
struct BookmarkSnippetView: View {
let entity: BookmarkEntity
var body: some View {
HStack(spacing: 12) {
Image(systemName: "bookmark.fill")
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text(entity.title)
.font(.headline)
.lineLimit(2)
Text(entity.host)
.font(.subheadline)
.foregroundStyle(.secondary)
if !entity.tags.isEmpty {
Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding()
}
}
/// Card Siri shows for an AI summary result.
struct SummarySnippetView: View {
let title: String
let host: String
let summary: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "sparkles").foregroundStyle(.tint)
Text(title).font(.headline).lineLimit(2)
}
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
Text(summary)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
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
}
/// Bridges App Intents (which run in the main app process, since there is no
/// separate AppIntents extension target) to the live SwiftUI scene.
///
/// Intents mutate this shared, observable singleton; `MainContainer` and
/// `SearchView` observe it and react (present a browser, switch to the search
/// tab, etc.). This is the standard "intent in-app navigation" pattern when
/// perform() runs in-process.
@MainActor
@Observable
final class IntentRouter {
static let shared = IntentRouter()
private init() {}
/// A bookmark URL an intent asked to open in the in-app browser.
var openBookmarkURL: String?
/// A query an intent asked the app to search for.
var searchRequest: String?
func openBookmark(url: String) { openBookmarkURL = url }
func search(_ query: String) { searchRequest = query }
}
/// Errors surfaced to Siri / Shortcuts when an intent can't run.
enum MarksIntentError: Error, CustomLocalizedStringResourceConvertible {
case notConfigured
case invalidURL
var localizedStringResource: LocalizedStringResource {
switch self {
case .notConfigured: "Open Marks and connect to your Linkding server first."
case .invalidURL: "That doesn't look like a valid link."
}
}
}
/// Shared plumbing for the intents: build an API client from saved config and
/// keep the widgets in sync after mutations.
enum MarksIntent {
/// Builds a `LinkdingAPI` from the saved server config, or throws a
/// user-facing error if the app has never been connected.
static func api() throws -> LinkdingAPI {
guard let config = ServerConfig.load() else { throw MarksIntentError.notConfigured }
return LinkdingAPI(config: config)
}
/// Prepend a freshly-saved bookmark into the widget cache so the Recent
/// widget reflects it immediately (mirrors `BookmarksViewModel.load`).
static func refreshWidgets(adding b: Bookmark) {
var items = WidgetDataStore.loadBookmarks()
items.removeAll { $0.url == b.url }
items.insert(
WidgetBookmark(url: b.url, title: b.displayTitle, domain: b.domain, faviconUrl: b.faviconUrl),
at: 0
)
WidgetDataStore.saveBookmarks(Array(items.prefix(20)))
}
}

View File

@@ -0,0 +1,184 @@
import Foundation
import AppIntents
// MARK: - Open
/// Opens a bookmark in the in-app browser. Conforms to `OpenIntent` the
/// system-blessed "open" type Siri understands. (iOS 26 has no separate
/// `system.open` App Schema; `OpenIntent` with an `AppEntity` target *is* the
/// supported mechanism, so no schema macro is needed here.)
struct OpenBookmarkIntent: OpenIntent {
static let title: LocalizedStringResource = "Open Bookmark"
static let openAppWhenRun = true
@Parameter(title: "Bookmark")
var target: BookmarkEntity
@MainActor
func perform() async throws -> some IntentResult {
IntentRouter.shared.openBookmark(url: target.url.absoluteString)
return .result()
}
}
// MARK: - Search
/// Searches Marks and shows the results in the app's search tab.
///
/// Conforms to the formal `system.search` App Schema (`ShowInAppSearchResultsIntent`),
/// which is what makes it executable by conversational Siri / Apple Intelligence:
/// Siri hands over the spoken term as `criteria` and the app re-runs it in its
/// own search UI. `searchScopes` defaults to `[.general]` for string criteria.
@AppIntent(schema: .system.search)
struct SearchMarksIntent {
static let title: LocalizedStringResource = "Search Marks"
static let description = IntentDescription("Search your Marks bookmarks.")
@Parameter(title: "Search", requestValueDialog: "What do you want to search for?")
var criteria: StringSearchCriteria
@MainActor
func perform() async throws -> some IntentResult {
IntentRouter.shared.search(criteria.term)
return .result()
}
}
// MARK: - Add
/// Saves a link to Marks without opening the app. Mirrors the Share Extension's
/// headless save, but goes through the typed `LinkdingAPI`.
struct AddBookmarkIntent: AppIntent {
static let title: LocalizedStringResource = "Add Bookmark to Marks"
static let description = IntentDescription("Save a link to your Marks bookmarks.")
static let openAppWhenRun = false
@Parameter(title: "URL")
var url: URL
@Parameter(title: "Title")
var name: String?
@Parameter(title: "Tags")
var tags: [String]?
static var parameterSummary: some ParameterSummary {
Summary("Add \(\.$url) to Marks") {
\.$name
\.$tags
}
}
func perform() async throws -> some IntentResult & ReturnsValue<BookmarkEntity> & ProvidesDialog & ShowsSnippetView {
let api = try MarksIntent.api()
let create = BookmarkCreate(
url: url.absoluteString,
title: name ?? "",
tagNames: tags ?? [],
isArchived: false,
unread: true,
shared: false
)
let bookmark = try await api.createBookmark(create)
let entity = BookmarkEntity(from: bookmark)
MarksIntent.refreshWidgets(adding: bookmark)
return .result(
value: entity,
dialog: IntentDialog("Saved \(entity.title) to Marks."),
view: BookmarkSnippetView(entity: entity)
)
}
}
// MARK: - Show unread
/// Returns the user's unread bookmarks "what's unread in Marks?"
struct ShowUnreadIntent: AppIntent {
static let title: LocalizedStringResource = "Show Unread Bookmarks"
static let description = IntentDescription("List the bookmarks you haven't read yet.")
func perform() async throws -> some IntentResult & ReturnsValue<[BookmarkEntity]> & ProvidesDialog {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(limit: 10, unread: true)
let entities = response.results.map(BookmarkEntity.init(from:))
let dialog: IntentDialog = entities.isEmpty
? "You have no unread bookmarks."
: "You have \(entities.count) unread bookmark\(entities.count == 1 ? "" : "s")."
return .result(value: entities, dialog: dialog)
}
}
// MARK: - Summarize (AI)
/// Produces an AI summary of a bookmark via the bundled ClaudeService.
struct SummarizeBookmarkIntent: AppIntent {
static let title: LocalizedStringResource = "Summarize Bookmark"
static let description = IntentDescription("Get an AI summary of a saved bookmark.")
@Parameter(title: "Bookmark")
var target: BookmarkEntity
static var parameterSummary: some ParameterSummary {
Summary("Summarize \(\.$target)")
}
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog & ShowsSnippetView {
let claude = ClaudeService()
let (summary, _) = try await claude.enrich(bookmark: target.makeBookmark())
return .result(
value: summary,
dialog: IntentDialog("\(summary)"),
view: SummarySnippetView(title: target.title, host: target.host, summary: summary)
)
}
}
// Note: there is intentionally no "Ask" AppIntent. Apple Intelligence already
// answers free-form questions over the user's bookmarks by querying the
// Spotlight index of `BookmarkEntity` (an `IndexedEntity`) directly verified
// on device (Campo CoreSpotlight on-device model). A named intent would be
// redundant, and "Ask <app>" collides with Siri's built-in "Ask". The in-app
// `AskView` remains as a manual on-device-RAG surface.
// MARK: - App Shortcuts
struct MarksShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: AddBookmarkIntent(),
phrases: [
"Add a bookmark to \(.applicationName)",
"Save this link to \(.applicationName)"
],
shortTitle: "Add Bookmark",
systemImageName: "bookmark"
)
AppShortcut(
intent: SearchMarksIntent(),
phrases: [
"Search \(.applicationName)",
"Search my bookmarks in \(.applicationName)"
],
shortTitle: "Search",
systemImageName: "magnifyingglass"
)
AppShortcut(
intent: ShowUnreadIntent(),
phrases: [
"Show unread in \(.applicationName)",
"What's unread in \(.applicationName)"
],
shortTitle: "Unread",
systemImageName: "envelope.badge"
)
AppShortcut(
intent: SummarizeBookmarkIntent(),
phrases: [
"Summarize a bookmark in \(.applicationName)",
"Summarize this with \(.applicationName)"
],
shortTitle: "Summarize",
systemImageName: "sparkles"
)
}
}

View File

@@ -36,6 +36,10 @@ struct MainContainer: View {
@State private var viewModel: BookmarksViewModel @State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL? @State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false @State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared
@State private var library = PodcastLibrary.shared
@Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) { init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config self.config = config
@@ -45,22 +49,46 @@ struct MainContainer: View {
} }
var body: some View { var body: some View {
TabView { TabView(selection: $selectedTab) {
Tab("Bookmarks", systemImage: "bookmark") { Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect) BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
} }
Tab("Tags", systemImage: "tag") { Tab("Tags", systemImage: "tag", value: AppTab.tags) {
TagsView(viewModel: viewModel) TagsView(viewModel: viewModel)
} }
Tab(role: .search) { 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) SearchView(viewModel: viewModel)
} }
} }
.onOpenURL { url in .onOpenURL { url in
handleDeepLink(url) handleDeepLink(url)
} }
.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 .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) { .sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView( PodcastPlayerView(
@@ -73,6 +101,27 @@ struct MainContainer: View {
} }
} }
/// Reacts to requests an App Intent placed on `IntentRouter` (opening a
/// bookmark, or searching). Also runs once at launch so cold-starts via an
/// intent are honored.
private func applyPendingIntent() {
if let urlString = router.openBookmarkURL {
router.openBookmarkURL = nil
if let url = URL(string: urlString) {
deepLinkBrowser = IdentifiableURL(url: url)
}
}
if let query = router.searchRequest {
selectedTab = .search
Task {
viewModel.searchQuery = query
await viewModel.search()
}
// SearchView consumes the text from the router on appear; leave it
// set until then, then SearchView clears it.
}
}
private func handleDeepLink(_ url: URL) { private func handleDeepLink(_ url: URL) {
guard url.scheme == "marks", guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),

View File

@@ -41,6 +41,17 @@ struct Bookmark: Codable, Identifiable, Sendable, Hashable {
var domain: String { var domain: String {
URL(string: url)?.host ?? url URL(string: url)?.host ?? url
} }
/// A short content snippet for list previews: the page's scraped meta
/// description, falling back to the user's own note. nil when neither exists.
var contentExcerpt: String? {
for candidate in [websiteDescription, description] {
if let c = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
return c
}
}
return nil
}
} }
struct BookmarkResponse: Codable, Sendable { struct BookmarkResponse: Codable, Sendable {
@@ -50,6 +61,24 @@ struct BookmarkResponse: Codable, Sendable {
let results: [Bookmark] let results: [Bookmark]
} }
/// Response of linkding's `GET /api/bookmarks/check/?url=` tells us whether the
/// URL is already saved, plus scraped metadata and suggested tags for new saves.
struct BookmarkCheck: Codable, Sendable {
let bookmark: Bookmark?
let metadata: Metadata?
let autoTags: [String]?
struct Metadata: Codable, Sendable {
let title: String?
let description: String?
}
enum CodingKeys: String, CodingKey {
case bookmark, metadata
case autoTags = "auto_tags"
}
}
struct BookmarkCreate: Codable, Sendable { struct BookmarkCreate: Codable, Sendable {
let url: String let url: String
let title: String let title: String
@@ -79,6 +108,14 @@ struct BookmarkUpdate: Codable, Sendable {
} }
} }
struct LinkdingTag: Codable, Sendable {
let name: String
}
struct TagResponse: Codable, Sendable {
let results: [LinkdingTag]
}
struct SmartCollection: Identifiable, Sendable { struct SmartCollection: Identifiable, Sendable {
let id = UUID() let id = UUID()
let name: String let name: String

View File

@@ -25,6 +25,13 @@ extension ServerConfig {
return try? JSONDecoder().decode(ServerConfig.self, from: data) return try? JSONDecoder().decode(ServerConfig.self, from: data)
} }
/// Loads config from the shared app group used by extensions, which have no
/// access to the main app's `UserDefaults.standard`.
static func loadShared() -> ServerConfig? {
guard let data = UserDefaults(suiteName: appGroupId)?.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(ServerConfig.self, from: data)
}
func save() { func save() {
guard let data = try? JSONEncoder().encode(self) else { return } guard let data = try? JSONEncoder().encode(self) else { return }
UserDefaults.standard.set(data, forKey: ServerConfig.key) UserDefaults.standard.set(data, forKey: ServerConfig.key)

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

@@ -18,10 +18,10 @@ enum Analytics {
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status < 200 || status > 299 { if status < 200 || status > 299 {
print("[Analytics] \(event) → HTTP \(status)") Log.analytics.error("\(event, privacy: .public) → HTTP \(status, privacy: .public)")
} }
} catch { } catch {
print("[Analytics] \(event) failed: \(error.localizedDescription)") Log.analytics.error("\(event, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
} }
} }
} }

View File

@@ -0,0 +1,81 @@
import Foundation
import Observation
import FoundationModels
/// Generation half of on-device RAG. Wraps a `LanguageModelSession` configured
/// with `BookmarkSearchTool`, so every question is answered by Apple's
/// on-device model grounded in the user's own bookmarks no network, no
/// third-party LLM. Mirrors the `ClaudeService` role, but fully on-device.
@MainActor
@Observable
final class BookmarkAssistant {
enum Status: Equatable {
case checking
case ready
case unavailable(String)
}
private(set) var status: Status = .checking
private var session: LanguageModelSession?
init() { configure() }
private func configure() {
switch SystemLanguageModel.default.availability {
case .available:
session = LanguageModelSession(
tools: [BookmarkSearchTool()],
instructions: """
You are an assistant inside a bookmarks app. The user's saved bookmarks \
are your only source of truth. To answer any question, call the \
searchBookmarks tool to look things up — never rely on outside knowledge \
or invent links. Ground every answer in the returned bookmarks, refer to \
them by title, and include their URLs when relevant. If nothing relevant \
is found, say so plainly. Keep answers concise.
"""
)
status = .ready
Log.assistant.info("On-device model available; session ready")
case .unavailable(let reason):
status = .unavailable(Self.message(for: reason))
Log.assistant.notice("On-device model unavailable: \(String(describing: reason), privacy: .public)")
}
}
/// Ask a question; the model retrieves from Spotlight via the tool as needed.
func ask(_ question: String) async throws -> String {
guard let session else {
Log.assistant.error("ask() with no session (model unavailable)")
throw AssistantError.unavailable
}
Log.assistant.info("Ask: \(question, privacy: .public)")
let start = Date()
do {
let answer = try await session.respond(to: question).content
let ms = Int(Date().timeIntervalSince(start) * 1000)
Log.assistant.info("Answered in \(ms, privacy: .public)ms, \(answer.count, privacy: .public) chars")
return answer
} catch {
Log.assistant.error("Ask failed: \(error.localizedDescription, privacy: .public)")
throw error
}
}
private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason {
case .deviceNotEligible:
return "This device doesn't support Apple Intelligence."
case .appleIntelligenceNotEnabled:
return "Turn on Apple Intelligence in Settings to ask your bookmarks."
case .modelNotReady:
return "The on-device model is still downloading. Try again shortly."
@unknown default:
return "On-device intelligence isn't available right now."
}
}
enum AssistantError: LocalizedError {
case unavailable
var errorDescription: String? { "On-device intelligence is unavailable." }
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
import FoundationModels
/// The tool the on-device model calls to look things up in the user's library.
/// It is the bridge between `LanguageModelSession` (generation) and the
/// Spotlight index (retrieval) together they form on-device RAG over the
/// private bookmark corpus.
struct BookmarkSearchTool: Tool {
let name = "searchBookmarks"
let description = """
Search the user's saved bookmarks. Returns the most relevant bookmarks with \
their title, website, description, and URL. Call this for any question about \
what the user has saved, read, or wants to find again.
"""
@Generable
struct Arguments {
@Guide(description: "Keywords or a short phrase describing what to look for in the saved bookmarks")
var query: String
}
func call(arguments: Arguments) async throws -> String {
Log.tool.info("searchBookmarks invoked query=\(arguments.query, privacy: .public)")
let hits = await SpotlightBookmarkSearch.run(query: arguments.query, limit: 8)
Log.tool.info("searchBookmarks returned \(hits.count, privacy: .public) bookmarks")
guard !hits.isEmpty else {
return "No bookmarks matched \"\(arguments.query)\"."
}
return hits.enumerated().map { idx, b in
var line = "\(idx + 1). \(b.title)"
if !b.host.isEmpty { line += "\(b.host)" }
if !b.description.isEmpty { line += "\n \(b.description)" }
if !b.url.isEmpty { line += "\n \(b.url)" }
return line
}.joined(separator: "\n")
}
}

View File

@@ -95,12 +95,13 @@ struct ClaudeService: Sendable {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
print("[AI] POST \(path)") Log.network.debug("POST \(path, privacy: .public)")
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0
print("[AI] POST \(path)\(status)") Log.network.info("POST \(path, privacy: .public)\(status, privacy: .public)")
guard (200...299).contains(status) else { guard (200...299).contains(status) else {
print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")") let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
Log.network.error("POST \(path, privacy: .public)\(status, privacy: .public) body=\(bodyText, privacy: .public)")
throw APIError.badStatus(status) throw APIError.badStatus(status)
} }
return data return data
@@ -111,11 +112,13 @@ struct ClaudeService: Sendable {
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
print("[AI] GET \(path)") Log.network.debug("GET \(path, privacy: .public)")
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0
print("[AI] GET \(path)\(status)") Log.network.info("GET \(path, privacy: .public)\(status, privacy: .public)")
guard (200...299).contains(status) else { guard (200...299).contains(status) else {
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
Log.network.error("GET \(path, privacy: .public)\(status, privacy: .public) body=\(bodyText, privacy: .public)")
throw APIError.badStatus(status) throw APIError.badStatus(status)
} }
return data return data

View File

@@ -78,6 +78,28 @@ actor LinkdingAPI {
return try decoder.decode(BookmarkResponse.self, from: data) return try decoder.decode(BookmarkResponse.self, from: data)
} }
/// Checks whether `url` is already bookmarked. Returns the existing bookmark
/// (or nil), scraped metadata, and suggested tags for a fresh save.
func checkBookmark(url urlString: String) async throws -> BookmarkCheck {
let url = try makeUrl(path: "/api/bookmarks/check/", queryItems: [.init(name: "url", value: urlString)])
let (data, response) = try await session.data(for: authorizedRequest(url: url))
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try decoder.decode(BookmarkCheck.self, from: data)
}
/// All tag names the user already uses the existing vocabulary we bias
/// AI tag suggestions toward.
func fetchTags(limit: Int = 1000) async throws -> [String] {
let url = try makeUrl(path: "/api/tags/", queryItems: [.init(name: "limit", value: "\(limit)")])
let (data, response) = try await session.data(for: authorizedRequest(url: url))
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try decoder.decode(TagResponse.self, from: data).results.map(\.name)
}
func createBookmark(_ create: BookmarkCreate) async throws -> Bookmark { func createBookmark(_ create: BookmarkCreate) async throws -> Bookmark {
let body = try JSONEncoder().encode(create) let body = try JSONEncoder().encode(create)
let url = try makeUrl(path: "/api/bookmarks/") let url = try makeUrl(path: "/api/bookmarks/")

43
Marks/Services/Log.swift Normal file
View File

@@ -0,0 +1,43 @@
import Foundation
import os
/// Organized, category-based logging for Marks, backed by `os.Logger`.
///
/// Unlike `print()` which only reaches stdout and is invisible to
/// `log collect` / Console.app these entries are captured under the
/// `com.magicive.marks` subsystem and can be filtered by category off-device.
///
/// Pull logs from a connected device:
/// ```
/// log collect --device-udid <UDID> --last 5m --output marks.logarchive
/// log show marks.logarchive --info --debug \
/// --predicate 'subsystem == "com.magicive.marks"'
/// # one category:
/// log show marks.logarchive --predicate \
/// 'subsystem == "com.magicive.marks" AND category == "assistant"'
/// ```
///
/// Privacy: `os.Logger` redacts interpolations to `<private>` by default. The
/// on-device RAG path (`assistant`, `tool`, `spotlight`) logs queries/counts as
/// `.public` so the feature is debuggable; bookmark *content* in the cloud
/// enrichment path stays default-private.
enum Log {
private static let subsystem = "com.magicive.marks"
/// On-device RAG: model availability, questions asked, answers produced.
static let assistant = Logger(subsystem: subsystem, category: "assistant")
/// The `searchBookmarks` tool the on-device model calls.
static let tool = Logger(subsystem: subsystem, category: "tool")
/// Spotlight retrieval (`CSSearchQuery`) and indexing (`indexAppEntities`).
static let spotlight = Logger(subsystem: subsystem, category: "spotlight")
/// Cloud AI enrichment (summaries, tags) via the backend.
static let ai = Logger(subsystem: subsystem, category: "ai")
/// Bookmark sync / disk cache.
static let sync = Logger(subsystem: subsystem, category: "sync")
/// Backend HTTP primitives (requests, status codes, error bodies).
static let network = Logger(subsystem: subsystem, category: "network")
/// Anonymous device auth / token registration.
static let auth = Logger(subsystem: subsystem, category: "auth")
/// Product analytics event delivery.
static let analytics = Logger(subsystem: subsystem, category: "analytics")
}

View File

@@ -46,7 +46,7 @@ enum MarksAuth {
let resp = try JSONDecoder().decode(Resp.self, from: data) let resp = try JSONDecoder().decode(Resp.self, from: data)
UserDefaults.standard.set(resp.access_token, forKey: tokenKey) UserDefaults.standard.set(resp.access_token, forKey: tokenKey)
UserDefaults.standard.set(Date().addingTimeInterval(TimeInterval(resp.expires_in)), forKey: tokenExpiryKey) UserDefaults.standard.set(Date().addingTimeInterval(TimeInterval(resp.expires_in)), forKey: tokenExpiryKey)
print("[Auth] Registered \(id.prefix(8))") Log.auth.info("Registered device \(id.prefix(8), privacy: .public)")
return resp.access_token return resp.access_token
} }
} }

View File

@@ -0,0 +1,120 @@
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,
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)
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? var title: String?
let createdAt: Date let createdAt: Date
var parentBookmarkUrl: String? 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 { enum PodcastIndex {
@@ -49,6 +54,18 @@ enum PodcastIndex {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl } 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) { static func remove(articleUrl: String) {
var entries = all() var entries = all()
entries.removeAll { $0.articleUrl == articleUrl } 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,70 @@
import Foundation
import CoreSpotlight
/// A bookmark retrieved from the Spotlight index, reduced to the fields the
/// on-device model needs to ground an answer.
struct RetrievedBookmark: Sendable {
let title: String
let host: String
let description: String
let url: String
}
/// Retrieval half of on-device RAG: queries the Spotlight index that
/// `SpotlightIndexer` populated and returns the best-matching bookmarks. The
/// search runs entirely on-device against the app's own indexed entities.
enum SpotlightBookmarkSearch {
static func run(query rawQuery: String, limit: Int) async -> [RetrievedBookmark] {
guard CSSearchableIndex.isIndexingAvailable() else {
Log.spotlight.notice("Search skipped: indexing unavailable")
return []
}
let queryString = makeQueryString(from: rawQuery)
guard !queryString.isEmpty else { return [] }
Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)")
let context = CSSearchQueryContext()
context.fetchAttributes = ["title", "contentDescription", "keywords", "url"]
let query = CSSearchQuery(queryString: queryString, queryContext: context)
var out: [RetrievedBookmark] = []
do {
for try await result in query.results {
let a = result.item.attributeSet
out.append(RetrievedBookmark(
title: a.title ?? "Untitled",
host: a.url?.host() ?? "",
description: a.contentDescription ?? "",
url: a.url?.absoluteString ?? ""
))
if out.count >= limit { break }
}
Log.spotlight.info("Search \"\(rawQuery, privacy: .public)\"\(out.count, privacy: .public) hits")
} catch {
Log.spotlight.error("Search failed: \(error.localizedDescription, privacy: .public)")
}
query.cancel()
return out
}
/// Build a CoreSpotlight query string that OR-matches each significant token
/// across title / description / keywords, case- and diacritic-insensitive.
private static func makeQueryString(from raw: String) -> String {
let tokens = raw
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.map { $0.lowercased() }
.filter { $0.count >= 3 }
let terms = (tokens.isEmpty ? [raw] : tokens).map(sanitize).filter { !$0.isEmpty }
let fields = ["title", "contentDescription", "keywords"]
let clauses = terms.flatMap { term in
fields.map { "\($0) == \"*\(term)*\"cd" }
}
guard !clauses.isEmpty else { return "" }
return "(" + clauses.joined(separator: " || ") + ")"
}
/// Keep only alphanumerics so a token can't break the query string syntax.
private static func sanitize(_ s: String) -> String {
String(String.UnicodeScalarView(s.unicodeScalars.filter(CharacterSet.alphanumerics.contains)))
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
import CoreSpotlight
import AppIntents
/// Pushes Linkding bookmarks into the system Spotlight index via the
/// `IndexedEntity` conformance on `BookmarkEntity`.
///
/// This is the step that actually makes bookmarks discoverable: conforming to
/// `IndexedEntity` only describes *how* an entity would be indexed (its
/// `attributeSet`) nothing reaches Spotlight until the entities are handed to
/// `CSSearchableIndex`. Once indexed, bookmarks show up in Spotlight search and
/// are reachable by Siri / Apple Intelligence.
///
/// All calls are best-effort: failures are logged, never surfaced. If indexing
/// is unavailable or fails, search degrades but nothing else breaks.
enum SpotlightIndexer {
/// Insert or update the given bookmarks in the Spotlight index.
static func index(_ bookmarks: [Bookmark]) async {
guard CSSearchableIndex.isIndexingAvailable(), !bookmarks.isEmpty else { return }
do {
try await CSSearchableIndex.default()
.indexAppEntities(bookmarks.map(BookmarkEntity.init(from:)))
Log.spotlight.info("Indexed \(bookmarks.count, privacy: .public) bookmarks")
} catch {
Log.spotlight.error("Index failed: \(error.localizedDescription, privacy: .public)")
}
}
/// Remove bookmarks from the index by id (e.g. after delete / archive).
static func remove(ids: [Int]) async {
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
do {
try await CSSearchableIndex.default()
.deleteAppEntities(identifiedBy: ids, ofType: BookmarkEntity.self)
Log.spotlight.info("Removed \(ids.count, privacy: .public) bookmarks")
} catch {
Log.spotlight.error("Delete failed: \(error.localizedDescription, privacy: .public)")
}
}
}

152
Marks/Views/AskView.swift Normal file
View File

@@ -0,0 +1,152 @@
import SwiftUI
/// "Ask Your Bookmarks" the on-device RAG surface. Questions are answered by
/// Apple's on-device model, grounded in the user's bookmarks via Spotlight.
struct AskView: View {
@State private var assistant = BookmarkAssistant()
@State private var question = ""
@State private var answer = ""
@State private var isLoading = false
@State private var errorText: String?
@Environment(\.dismiss) private var dismiss
@FocusState private var focused: Bool
var body: some View {
NavigationStack {
Group {
switch assistant.status {
case .checking:
ProgressView()
case .unavailable(let message):
ContentUnavailableView(
"Unavailable",
systemImage: "sparkles.slash",
description: Text(message)
)
case .ready:
ready
}
}
.navigationTitle("Ask Your Bookmarks")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
private var ready: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if answer.isEmpty && !isLoading && errorText == nil {
ContentUnavailableView {
Label("Ask anything", systemImage: "sparkles")
} description: {
Text("Answers come from your saved bookmarks, generated on-device.")
}
.padding(.top, 40)
}
if isLoading {
HStack(spacing: 8) {
ProgressView()
Text("Searching your bookmarks…").foregroundStyle(.secondary)
}
}
if !answer.isEmpty {
MarkdownAnswer(text: answer)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let errorText {
Text(errorText).foregroundStyle(.red)
}
}
.padding()
}
HStack(spacing: 10) {
TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
.lineLimit(1...4)
.focused($focused)
.submitLabel(.send)
.onSubmit(send)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill").font(.title2)
}
.disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
}
.padding()
.background(.bar)
}
.onAppear { focused = true }
}
private func send() {
let q = question.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty, !isLoading else { return }
question = ""
answer = ""
errorText = nil
isLoading = true
Task {
do {
answer = try await assistant.ask(q)
} catch {
errorText = error.localizedDescription
}
isLoading = false
}
}
}
/// Renders the model's answer as lightweight Markdown. SwiftUI's `Text` only
/// parses *inline* Markdown (bold/italic/code/links), so we split into block
/// elements headings, bullet/numbered lists, paragraphs and lay them out,
/// applying inline parsing per line.
private struct MarkdownAnswer: View {
let text: String
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block {
case .heading(let line):
inline(line).font(.headline)
case .bullet(let line):
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("").foregroundStyle(.secondary)
inline(line)
}
case .paragraph(let line):
inline(line)
}
}
}
}
private var blocks: [Block] {
text.split(separator: "\n", omittingEmptySubsequences: true).map { raw in
let line = raw.trimmingCharacters(in: .whitespaces)
if let r = line.range(of: "^#{1,6}\\s+", options: .regularExpression) {
return .heading(String(line[r.upperBound...]))
}
if let r = line.range(of: "^([-*+]|\\d+\\.)\\s+", options: .regularExpression) {
return .bullet(String(line[r.upperBound...]))
}
return .paragraph(line)
}
}
private func inline(_ s: String) -> Text {
let opts = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attr = try? AttributedString(markdown: s, options: opts) {
return Text(attr)
}
return Text(s)
}
}

View File

@@ -23,7 +23,9 @@ struct BookmarkListRow: View {
.onTapGesture { onOpen() } .onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible) .listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { // Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) { Button(role: .destructive) {
Task { await viewModel.delete(bookmark) } Task { await viewModel.delete(bookmark) }
} label: { } label: {
@@ -83,7 +85,7 @@ struct BookmarkListRow: View {
) )
} }
.sheet(item: $episodePickerBookmark) { b in .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)
} }
} }
@@ -98,12 +100,7 @@ struct BookmarkListRow: View {
claude: viewModel.claude claude: viewModel.claude
) )
showFullPlayer = true showFullPlayer = true
} else { } else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true showFullPlayer = true
} }
} }

View File

@@ -6,6 +6,16 @@ struct BookmarkRow: View {
var onPodcast: (() -> Void)? = nil var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0 @State private var podcastTapCount = 0
@State private var podcastCached = false
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -23,7 +33,7 @@ struct BookmarkRow: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle) Text(bookmark.displayTitle)
.font(.system(size: 17, weight: .semibold)) .font(.headline)
.foregroundStyle(.primary) .foregroundStyle(.primary)
.lineLimit(2) .lineLimit(2)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -31,9 +41,9 @@ struct BookmarkRow: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Text(bookmark.domain) Text(bookmark.domain)
Text("·") Text("·")
Text(bookmark.dateAdded, style: .relative) Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date()))
} }
.font(.system(size: 13)) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -45,7 +55,7 @@ struct BookmarkRow: View {
onPodcast() onPodcast()
} label: { } label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 20)) .font(.title3)
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) .foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -54,10 +64,10 @@ struct BookmarkRow: View {
} }
} }
if let summary = bookmark.aiSummary { if let excerpt = rowExcerpt {
Text(summary) Text(excerpt.text)
.font(.system(size: 14)) .font(.subheadline)
.italic() .italic(excerpt.isAI)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
.padding(.leading, 44) .padding(.leading, 44)
@@ -65,15 +75,16 @@ struct BookmarkRow: View {
if !effectiveTags.isEmpty { if !effectiveTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
GlassEffectContainer(spacing: 6) {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in ForEach(effectiveTags, id: \.self) { tag in
Text(tag) Text(tag)
.font(.system(size: 12, weight: .medium)) .font(.caption.weight(.medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 3) .padding(.vertical, 3)
.background(Color(.systemGray6)) .glassEffect(in: Capsule())
.clipShape(Capsule()) }
} }
} }
} }
@@ -97,10 +108,24 @@ struct BookmarkRow: View {
.frame(height: 2) .frame(height: 2)
} }
} }
.task(id: bookmark.url) {
// Stat the podcast cache off the render path: once per appearance
// (and when the URL changes), not on every `body` recomputation.
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
}
} }
private var podcastCached: Bool { /// Excerpt shown under the title: prefer the AI summary (italic), else the
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path) /// page's scraped description / user note. nil hides the line entirely.
private var rowExcerpt: (text: String, isAI: Bool)? {
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
return (s, true)
}
if let e = bookmark.contentExcerpt {
return (e, false)
}
return nil
} }
private var effectiveTags: [String] { private var effectiveTags: [String] {

View File

@@ -58,7 +58,7 @@ struct BookmarksView: View {
@State private var editingBookmark: Bookmark? @State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark? @State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var showPodcastLibrary = false @State private var showAsk = false
@State private var readingProgress: [String: Double] = ReadingProgress.all() @State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View { var body: some View {
@@ -95,6 +95,11 @@ struct BookmarksView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Menu { Menu {
Button {
showAsk = true
} label: {
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
}
Button { Button {
Task { await viewModel.generateSmartCollections() } Task { await viewModel.generateSmartCollections() }
showCollections = true showCollections = true
@@ -106,11 +111,6 @@ struct BookmarksView: View {
} label: { } label: {
Label("Enrich All with AI", systemImage: "wand.and.stars") Label("Enrich All with AI", systemImage: "wand.and.stars")
} }
Button {
showPodcastLibrary = true
} label: {
Label("Podcasts", systemImage: "headphones")
}
} label: { } label: {
Image(systemName: "sparkles") Image(systemName: "sparkles")
} }
@@ -146,6 +146,10 @@ struct BookmarksView: View {
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
VStack(spacing: 8) { VStack(spacing: 8) {
if viewModel.podcastGenerator.hasActive {
podcastGeneratingBanner
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty { if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) { MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true 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.podcastPlayer.currentArticleUrl.isEmpty)
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
.padding(.bottom, 8) .padding(.bottom, 8)
} }
.sensoryFeedback(.selection, trigger: browsingBookmark?.id) .sensoryFeedback(.selection, trigger: browsingBookmark?.id)
@@ -167,6 +172,9 @@ struct BookmarksView: View {
.sheet(isPresented: $showCollections) { .sheet(isPresented: $showCollections) {
CollectionsView(viewModel: viewModel) CollectionsView(viewModel: viewModel)
} }
.sheet(isPresented: $showAsk) {
AskView()
}
.sheet(isPresented: $showAddBookmark) { .sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel) AddBookmarkView(viewModel: viewModel)
} }
@@ -175,9 +183,10 @@ struct BookmarksView: View {
} }
.sheet(item: $browsingBookmark) { bookmark in .sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) { 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) await viewModel.archive(bookmark)
} }
.bookmarkOnscreen(bookmark)
} }
} }
.onChange(of: browsingBookmark) { _, new in .onChange(of: browsingBookmark) { _, new in
@@ -192,9 +201,6 @@ struct BookmarksView: View {
stopOnDismiss: false stopOnDismiss: false
) )
} }
.sheet(isPresented: $showPodcastLibrary) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
.refreshable { .refreshable {
await viewModel.load() await viewModel.load()
} }
@@ -229,6 +235,40 @@ struct BookmarksView: View {
.padding(.horizontal, 16) .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) { private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id, guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }

View File

@@ -18,6 +18,7 @@ final class BookmarksViewModel {
private let api: LinkdingAPI private let api: LinkdingAPI
let claude = ClaudeService() let claude = ClaudeService()
let podcastPlayer = PodcastPlayerViewModel() let podcastPlayer = PodcastPlayerViewModel()
let podcastGenerator = PodcastGenerationManager()
private var enrichTask: Task<Void, Never>? private var enrichTask: Task<Void, Never>?
private let cacheFileUrl: URL private let cacheFileUrl: URL
@@ -29,6 +30,32 @@ final class BookmarksViewModel {
self.cacheFileUrl = dir.appendingPathComponent("\(name).json") 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 { func load(useCache: Bool = true) async {
if useCache { loadFromCache() } if useCache { loadFromCache() }
isLoading = true isLoading = true
@@ -42,6 +69,7 @@ final class BookmarksViewModel {
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map { WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl) WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
}) })
Task { await SpotlightIndexer.index(bookmarks) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -57,6 +85,8 @@ final class BookmarksViewModel {
bookmarks.append(contentsOf: response.results) bookmarks.append(contentsOf: response.results)
nextPageUrl = response.next nextPageUrl = response.next
restoreAIData() restoreAIData()
let added = response.results
Task { await SpotlightIndexer.index(added) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -102,21 +132,24 @@ final class BookmarksViewModel {
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false) let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
let bookmark = try await api.createBookmark(create) let bookmark = try await api.createBookmark(create)
bookmarks.insert(bookmark, at: 0) bookmarks.insert(bookmark, at: 0)
print("[AI] addBookmark: saved id=\(bookmark.id)") Task { await SpotlightIndexer.index([bookmark]) }
Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)")
Task { Task {
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)") Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)")
do { do {
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark) let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)") Log.ai.info("addBookmark enrich ok id=\(bookmark.id, privacy: .public) tags=\(aiTags.count, privacy: .public)")
guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else {
print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich") Log.ai.notice("addBookmark id=\(bookmark.id, privacy: .public) gone before enrich applied")
return return
} }
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = aiTags bookmarks[i].aiTags = aiTags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
} catch { } catch {
print("[AI] addBookmark: enrich FAILED \(error)") Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
self.error = "AI enrichment failed: \(error.localizedDescription)" self.error = "AI enrichment failed: \(error.localizedDescription)"
} }
} }
@@ -126,6 +159,7 @@ final class BookmarksViewModel {
do { do {
try await api.deleteBookmark(id: bookmark.id) try await api.deleteBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id } bookmarks.removeAll { $0.id == bookmark.id }
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -135,6 +169,7 @@ final class BookmarksViewModel {
do { do {
try await api.archiveBookmark(id: bookmark.id) try await api.archiveBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id } bookmarks.removeAll { $0.id == bookmark.id }
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -180,6 +215,8 @@ final class BookmarksViewModel {
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
enrichmentProgress = Double(done + 1) / Double(toEnrich.count) enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
} catch { } catch {
break break
@@ -196,23 +233,25 @@ final class BookmarksViewModel {
enrichTask = Task { [weak self] in enrichTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) } let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich") Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich")
for i in indices { for i in indices {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
let claude = await MainActor.run(body: { self.claude }) let claude = await MainActor.run(body: { self.claude })
do { do {
let bm = await MainActor.run { bookmarks[i] } let bm = await MainActor.run { bookmarks[i] }
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)") Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
let (summary, tags) = try await claude.enrich(bookmark: bm) let (summary, tags) = try await claude.enrich(bookmark: bm)
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))") Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)")
await MainActor.run { await MainActor.run {
guard i < bookmarks.count else { return } guard i < bookmarks.count else { return }
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
} }
} catch { } catch {
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)") Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
break break
} }
} }
@@ -239,7 +278,7 @@ final class BookmarksViewModel {
let data = try Self.cacheEncoder.encode(list) let data = try Self.cacheEncoder.encode(list)
try data.write(to: cacheFileUrl, options: .atomic) try data.write(to: cacheFileUrl, options: .atomic)
} catch { } catch {
print("[Cache] saveToCache failed: \(error)") Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
} }
} }
@@ -260,6 +299,8 @@ final class BookmarksViewModel {
"tags": bookmark.aiTags ?? [] "tags": bookmark.aiTags ?? []
] ]
UserDefaults.standard.set(store, forKey: "aiData") 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() { private func restoreAIData() {
@@ -272,6 +313,8 @@ final class BookmarksViewModel {
if let d = store["\(list[i].id)"] { if let d = store["\(list[i].id)"] {
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 } list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
list[i].aiTags = d["tags"] as? [String] 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 initialTitle: String
let claude: ClaudeService let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
var onArchive: (() async -> Void)? = nil var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -232,11 +233,12 @@ struct BrowserView: View {
@State private var navForwardCount = 0 @State private var navForwardCount = 0
@State private var toolbarHidden = false @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.initialURL = url
self.initialTitle = title self.initialTitle = title
self.claude = claude self.claude = claude
self.podcastPlayer = podcastPlayer self.podcastPlayer = podcastPlayer
self.podcastGenerator = podcastGenerator
self.onArchive = onArchive self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url)) self._state = State(initialValue: BrowserState(url: url))
} }
@@ -375,6 +377,9 @@ struct BrowserView: View {
Button { Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle 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( podcastPlayer.start(
articleUrl: currentUrl, articleUrl: currentUrl,
articleTitle: currentTitle, articleTitle: currentTitle,
@@ -382,6 +387,13 @@ struct BrowserView: View {
parentBookmarkUrl: initialURL.absoluteString parentBookmarkUrl: initialURL.absoluteString
) )
showPodcast = true showPodcast = true
} else {
podcastGenerator.enqueue(
articleUrl: currentUrl,
title: currentTitle,
parentBookmarkUrl: initialURL.absoluteString
)
}
} label: { } label: {
Image(systemName: "headphones") Image(systemName: "headphones")
.frame(width: 44, height: 44) .frame(width: 44, height: 44)

View File

@@ -89,10 +89,9 @@ struct OnboardingView: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 52) .frame(height: 52)
.background(canConnect ? Color.primary : Color(.systemGray4))
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
.clipShape(RoundedRectangle(cornerRadius: 14))
} }
.buttonStyle(.glassProminent)
.buttonBorderShape(.roundedRectangle(radius: 14))
.disabled(!canConnect || isConnecting) .disabled(!canConnect || isConnecting)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.top, 24) .padding(.top, 24)

View File

@@ -42,10 +42,21 @@ final class PodcastPlayerViewModel {
var duration: Double = 1 var duration: Double = 1
var currentArticleUrl: String = "" var currentArticleUrl: String = ""
var currentArticleTitle: 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] 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 { var isGenerating: Bool {
switch phase { switch phase {
case .generating, .downloading: return true case .generating, .downloading: return true
@@ -53,7 +64,13 @@ 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 pollingTask: Task<Void, Never>?
private var sleepTask: Task<Void, Never>?
private var timeObserver: Any? private var timeObserver: Any?
private var endObserver: Any? private var endObserver: Any?
private var interruptionObserver: Any? private var interruptionObserver: Any?
@@ -66,6 +83,7 @@ final class PodcastPlayerViewModel {
// Teardown previous episode (saves its position) // Teardown previous episode (saves its position)
if player != nil { stop() } if player != nil { stop() }
lastClaude = claude
currentArticleUrl = articleUrl currentArticleUrl = articleUrl
currentArticleTitle = articleTitle currentArticleTitle = articleTitle
@@ -112,7 +130,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() { func stop() {
queue = []
clearSession()
sleepTask?.cancel(); sleepTask = nil
sleepTimer = .off
if !currentArticleUrl.isEmpty && currentTime > 10 { if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration) PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
} }
@@ -146,6 +177,7 @@ final class PodcastPlayerViewModel {
if isPlaying { if isPlaying {
player.pause() player.pause()
} else { } else {
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
player.rate = playbackSpeed player.rate = playbackSpeed
} }
isPlaying.toggle() isPlaying.toggle()
@@ -168,16 +200,90 @@ final class PodcastPlayerViewModel {
func setPlaybackSpeed(_ speed: Float) { func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed playbackSpeed = speed
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
if isPlaying { player?.rate = speed } if isPlaying { player?.rate = speed }
updateNowPlaying() 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 currentPodcastTitle = title
// A freshly generated episode may have just landed in the index.
PodcastLibrary.shared.reload()
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
options: [.allowBluetoothA2DP]) 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 item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item) let p = AVPlayer(playerItem: item)
@@ -196,16 +302,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( endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in ) { [weak self] _ in
guard let self else { return } 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.isPlaying = false
self.currentTime = 0 self.currentTime = 0
self.player?.seek(to: .zero) self.player?.seek(to: .zero)
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
self.updateNowPlaying() self.updateNowPlaying()
self.persistSession()
}
} }
// Pause on phone calls / Siri; resume when interruption ends // Pause on phone calls / Siri; resume when interruption ends
@@ -245,14 +371,20 @@ final class PodcastPlayerViewModel {
setupRemoteCommands() setupRemoteCommands()
phase = .ready(title: title) phase = .ready(title: title)
updateNowPlaying() updateNowPlaying()
if autoPlay {
p.rate = playbackSpeed p.rate = playbackSpeed
isPlaying = true isPlaying = true
} else {
isPlaying = false
}
persistSession()
} }
private func setupRemoteCommands() { private func setupRemoteCommands() {
let c = MPRemoteCommandCenter.shared() let c = MPRemoteCommandCenter.shared()
c.playCommand.isEnabled = true c.playCommand.isEnabled = true
c.playCommand.addTarget { [weak self] _ in c.playCommand.addTarget { [weak self] _ in
try? AVAudioSession.sharedInstance().setActive(true)
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
} }
c.pauseCommand.isEnabled = true c.pauseCommand.isEnabled = true
@@ -308,7 +440,7 @@ final class PodcastPlayerViewModel {
return MPMediaItemArtwork(boundsSize: size) { _ in image } return MPMediaItemArtwork(boundsSize: size) { _ in image }
}() }()
private static func statusLabel(_ status: String) -> String { static func statusLabel(_ status: String) -> String {
switch status { switch status {
case "queued": return "Queued…" case "queued": return "Queued…"
case "fetching": return "Fetching article…" case "fetching": return "Fetching article…"
@@ -330,6 +462,33 @@ struct PodcastPlayerView: View {
var stopOnDismiss: Bool = false var stopOnDismiss: Bool = false
@Environment(\.dismiss) private var dismiss @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 { var body: some View {
NavigationStack { NavigationStack {
@@ -357,6 +516,10 @@ struct PodcastPlayerView: View {
} }
.onAppear { .onAppear {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onChange(of: vm.currentArticleUrl) { _, _ in
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
} }
.onDisappear { .onDisappear {
if stopOnDismiss { vm.stop() } if stopOnDismiss { vm.stop() }
@@ -445,6 +608,7 @@ struct PodcastPlayerView: View {
} }
} }
HStack(spacing: 12) {
Menu { Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button { Button {
@@ -464,10 +628,38 @@ struct PodcastPlayerView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 7) .padding(.vertical, 7)
.background(Color(.systemGray5)) .glassEffect(in: Capsule())
.clipShape(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) { if let url = URL(string: articleUrl) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up") Label("Share Episode", systemImage: "square.and.arrow.up")
@@ -477,6 +669,7 @@ struct PodcastPlayerView: View {
} }
} }
} }
}
private func failedView(message: String) -> some View { private func failedView(message: String) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
@@ -494,12 +687,10 @@ struct PodcastPlayerView: View {
} label: { } label: {
Label("Retry", systemImage: "arrow.clockwise") Label("Retry", systemImage: "arrow.clockwise")
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color.blue)
.clipShape(Capsule())
} }
.buttonStyle(.glassProminent)
.tint(.blue)
.buttonBorderShape(.capsule)
.padding(.top, 4) .padding(.top, 4)
} }
} }
@@ -600,48 +791,130 @@ struct MiniPlayerView: View {
// MARK: - Podcast library // MARK: - Podcast library
/// The Podcasts tab: lists every podcast generated in the app (from
/// `PodcastIndex`) and plays them on demand through the shared player, with a
/// now-playing bar that opens the full player.
struct PodcastLibraryView: View { struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel let vm: PodcastPlayerViewModel
let claude: ClaudeService let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@State private var entries: [PodcastEntry] = [] @State private var library = PodcastLibrary.shared
@Environment(\.dismiss) private var dismiss @State private var showFullPlayer = false
@State private var detailEntry: PodcastEntry?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
if entries.isEmpty { if library.entries.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"No Podcasts", "No Podcasts",
systemImage: "headphones", systemImage: "headphones",
description: Text("Podcasts you generate will appear here.") description: Text("Podcasts you generate from bookmarks will appear here.")
) )
} else { } else {
List { List {
ForEach(entries) { entry in if !unplayed.isEmpty {
Section("Up Next") {
ForEach(unplayed) { row(for: $0) }
}
}
if !played.isEmpty {
Section("Played") {
ForEach(played) { row(for: $0) }
}
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Podcasts")
.toolbar {
if !unplayed.isEmpty {
ToolbarItem(placement: .topBarLeading) {
Button {
vm.playQueue(unplayed, claude: claude)
} label: {
Label("Play All", systemImage: "play.fill")
}
}
}
}
.overlay(alignment: .bottom) {
if !vm.currentArticleUrl.isEmpty {
MiniPlayerView(vm: vm) { showFullPlayer = true }
.padding(.horizontal)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.spring(duration: 0.3), value: vm.currentArticleUrl.isEmpty)
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: vm,
articleUrl: vm.currentArticleUrl,
articleTitle: vm.currentArticleTitle,
claude: claude,
stopOnDismiss: false
)
}
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
}
}
.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) { HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl) Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
.lineLimit(2) .lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.accessibilityLabel("Played")
}
Button { Button {
vm.start(articleUrl: entry.articleUrl, play(entry)
articleTitle: entry.title ?? "",
claude: claude)
dismiss()
} label: { } label: {
Image(systemName: "play.circle.fill") Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36)) .font(.system(size: 36))
.foregroundStyle(.blue) .foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .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) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { Button(role: .destructive) {
deleteEntry(entry) deleteEntry(entry)
@@ -649,28 +922,54 @@ struct PodcastLibraryView: View {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
} }
.contextMenu {
Button {
play(entry)
} label: {
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
} }
.onDelete { indexSet in Button {
indexSet.forEach { deleteEntry(entries[$0]) } 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")
} }
} }
.listStyle(.plain) Divider()
} Button(role: .destructive) {
} deleteEntry(entry)
.navigationTitle("Podcasts") } label: {
.navigationBarTitleDisplayMode(.inline) Label("Delete", systemImage: "trash")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
} }
} }
} }
.onAppear { entries = PodcastIndex.all() }
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
library.setPlayed(entry.articleUrl, played)
}
private func isCurrent(_ entry: PodcastEntry) -> Bool {
vm.currentArticleUrl == entry.articleUrl
}
private func play(_ entry: PodcastEntry) {
if isCurrent(entry) {
vm.togglePlayPause()
} else {
vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? "", claude: claude)
}
} }
private func deleteEntry(_ entry: PodcastEntry) { private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl) library.remove(entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
} }
} }
@@ -681,8 +980,10 @@ struct EpisodePickerView: View {
let bookmark: Bookmark let bookmark: Bookmark
let vm: PodcastPlayerViewModel let vm: PodcastPlayerViewModel
let claude: ClaudeService let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@State private var entries: [PodcastEntry] = [] @State private var entries: [PodcastEntry] = []
@State private var detailEntry: PodcastEntry?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -718,6 +1019,8 @@ struct EpisodePickerView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: { Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
@@ -733,6 +1036,9 @@ struct EpisodePickerView: View {
Button("Done") { dismiss() } Button("Done") { dismiss() }
} }
} }
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
}
} }
.onAppear { entries = PodcastIndex.find(for: bookmark.url) } .onAppear { entries = PodcastIndex.find(for: bookmark.url) }
} }
@@ -743,3 +1049,131 @@ struct EpisodePickerView: View {
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } 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,15 +48,26 @@ struct SearchView: View {
url: url, url: url,
title: bookmark.displayTitle, title: bookmark.displayTitle,
claude: viewModel.claude, claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator
) { ) {
await viewModel.archive(bookmark) await viewModel.archive(bookmark)
} }
.bookmarkOnscreen(bookmark)
} }
} }
} }
.searchable(text: $searchText, prompt: "Titles, URLs, tags…") .searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() } .onChange(of: searchText) { _, _ in scheduleSearch() }
.onAppear { consumeIntentSearch() }
.onChange(of: IntentRouter.shared.searchRequest) { _, _ in consumeIntentSearch() }
}
/// Pulls a query handed over by `SearchMarksIntent` into the search field.
private func consumeIntentSearch() {
guard let query = IntentRouter.shared.searchRequest else { return }
searchText = query
IntentRouter.shared.searchRequest = nil
} }
private var results: [Bookmark] { private var results: [Bookmark] {

View File

@@ -6,6 +6,8 @@ struct SettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
@@ -15,6 +17,21 @@ struct SettingsView: View {
.foregroundStyle(.secondary) .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") { Section("Server") {
Button(role: .destructive) { Button(role: .destructive) {
dismiss() dismiss()

View File

@@ -76,7 +76,8 @@ struct TagBookmarksView: View {
url: url, url: url,
title: bookmark.displayTitle, title: bookmark.displayTitle,
claude: viewModel.claude, claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator
) { ) {
await viewModel.archive(bookmark) await viewModel.archive(bookmark)
} }

View File

@@ -0,0 +1,107 @@
import XCTest
import AppIntents
@testable import Marks
/// Tests for the App Intents layer.
///
/// Note: WWDC26's dedicated `AppIntentsTesting` framework (out-of-process
/// `IntentDefinitions(bundleIdentifier:)` / `.run()`) is not present in this
/// Xcode, so these are standard XCTest cases. They work because Marks has no
/// AppIntents extension intents run in the app process, so `perform()` can be
/// called directly and asserted against `IntentRouter`.
final class AppIntentsTests: XCTestCase {
private func sampleBookmark(
id: Int = 42,
url: String = "https://example.com/article",
title: String = "Concurrency in Swift",
tags: [String] = ["swift", "concurrency"],
unread: Bool = true,
aiSummary: String? = "A short summary."
) -> Bookmark {
Bookmark(
id: id, url: url, title: title, description: "desc",
tagNames: tags, dateAdded: Date(), dateModified: Date(),
isArchived: false, unread: unread, shared: false,
websiteTitle: nil, websiteDescription: nil,
faviconUrl: nil, previewImageUrl: nil,
aiSummary: aiSummary, aiTags: nil
)
}
// MARK: - Entity mapping
func testEntityMapsFromBookmark() {
let entity = BookmarkEntity(from: sampleBookmark())
XCTAssertEqual(entity.id, 42)
XCTAssertEqual(entity.title, "Concurrency in Swift")
XCTAssertEqual(entity.url, URL(string: "https://example.com/article"))
XCTAssertEqual(entity.host, "example.com")
XCTAssertEqual(entity.tags, ["swift", "concurrency"])
XCTAssertTrue(entity.unread)
XCTAssertEqual(entity.summary, "A short summary.")
}
func testEntityUsesDisplayTitleFallback() {
// empty title falls back to url (Bookmark.displayTitle)
let entity = BookmarkEntity(from: sampleBookmark(title: ""))
XCTAssertFalse(entity.title.isEmpty)
}
func testMakeBookmarkRoundTripsCoreFields() {
let entity = BookmarkEntity(from: sampleBookmark())
let back = entity.makeBookmark()
XCTAssertEqual(back.id, 42)
XCTAssertEqual(back.url, "https://example.com/article")
XCTAssertEqual(back.tagNames, ["swift", "concurrency"])
}
func testEntityIdentifierEncodesServerId() {
let entity = BookmarkEntity(from: sampleBookmark(id: 7))
let eid = EntityIdentifier(for: entity)
XCTAssertEqual(eid.identifier, "7")
XCTAssertTrue(eid.entityType == BookmarkEntity.self)
}
// MARK: - Router
@MainActor
func testRouterSearch() {
IntentRouter.shared.searchRequest = nil
IntentRouter.shared.search("hello")
XCTAssertEqual(IntentRouter.shared.searchRequest, "hello")
}
@MainActor
func testRouterOpenBookmark() {
IntentRouter.shared.openBookmarkURL = nil
IntentRouter.shared.openBookmark(url: "https://example.com")
XCTAssertEqual(IntentRouter.shared.openBookmarkURL, "https://example.com")
}
// MARK: - Intent perform() wiring (no network)
@MainActor
func testSearchIntentRoutesCriteriaTerm() async throws {
IntentRouter.shared.searchRequest = nil
let intent = SearchMarksIntent()
intent.criteria = StringSearchCriteria(term: "graphql")
_ = try await intent.perform()
XCTAssertEqual(IntentRouter.shared.searchRequest, "graphql")
}
@MainActor
func testOpenIntentRoutesURL() async throws {
IntentRouter.shared.openBookmarkURL = nil
let intent = OpenBookmarkIntent()
intent.target = BookmarkEntity(from: sampleBookmark())
_ = try await intent.perform()
XCTAssertEqual(IntentRouter.shared.openBookmarkURL, "https://example.com/article")
}
// MARK: - App Shortcuts
func testAppShortcutsAreRegistered() {
XCTAssertGreaterThanOrEqual(MarksShortcuts.appShortcuts.count, 4)
}
}

View File

@@ -0,0 +1,320 @@
import SwiftUI
/// Interactive save card shown by the share extension. On open it checks whether
/// the URL is already bookmarked, then lets the user set/edit tags, notes, and a
/// read-later flag before saving (new) or updating (existing) never a duplicate.
struct ShareView: View {
let url: String
/// Called when the extension should dismiss. `saved` is true after a successful
/// write, false on cancel.
let onClose: (_ saved: Bool) -> Void
@State private var phase: Phase = .loading
@State private var existing: Bookmark?
@State private var title = ""
@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)
@State private var suggesting = false
@State private var errorMessage = ""
@FocusState private var tagsFocused: Bool
private let api: LinkdingAPI?
init(url: String, onClose: @escaping (_ saved: Bool) -> Void) {
self.url = url
self.onClose = onClose
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
}
private enum Phase { case loading, editing, saving, done, failed }
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
VStack {
Spacer()
card
.frame(maxWidth: 460)
.padding(.horizontal, 20)
Spacer()
}
.task { await check() }
}
@ViewBuilder
private var card: some View {
VStack(alignment: .leading, spacing: 16) {
switch phase {
case .loading:
HStack(spacing: 12) {
ProgressView()
Text("Checking…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
case .editing, .saving:
header
editor
actions
case .done:
statusCard(icon: "checkmark.circle.fill", tint: .green,
text: existing != nil ? "Updated" : "Saved")
case .failed:
statusCard(icon: "xmark.circle.fill", tint: .red,
text: errorMessage.isEmpty ? "Something went wrong" : errorMessage)
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
}
// MARK: - Sections
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
if let existing {
Label {
Text(existing.dateAdded == .distantPast
? "Already saved"
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
.font(.subheadline.weight(.semibold))
} else {
Text("Save bookmark").font(.headline)
}
Text(title.isEmpty ? url : title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
Text(domain)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var editor: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
TextField("Tags (space separated)", text: $tagText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($tagsFocused)
.textFieldStyle(.roundedBorder)
if suggesting && aiSuggestions.isEmpty {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Suggesting tags…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !suggestionChips.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(suggestionChips, id: \.tag) { item in
Button { addTag(item.tag) } label: {
Text(item.isAI ? "\(item.tag)" : "+ \(item.tag)")
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
.foregroundStyle(item.isAI ? Color.purple : .blue)
}
.buttonStyle(.plain)
}
}
}
}
}
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(1...3)
.textFieldStyle(.roundedBorder)
Toggle("Read later", isOn: $readLater)
.font(.subheadline)
Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones")
}
.font(.subheadline)
}
}
private var actions: some View {
HStack(spacing: 12) {
Button("Cancel") { onClose(false) }
.buttonStyle(.bordered)
.frame(maxWidth: .infinity)
Button {
Task { await commit() }
} label: {
Group {
if phase == .saving { ProgressView().tint(.white) }
else { Text(existing != nil ? "Update" : "Save") }
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(phase == .saving)
}
.padding(.top, 2)
}
private func statusCard(icon: String, tint: Color, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title3).foregroundStyle(tint)
Text(text).font(.headline)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
}
// MARK: - Derived
private var domain: String { URL(string: url)?.host ?? url }
/// Tappable suggestion chips: net-new AI ideas () followed by linkding's
/// auto_tags (+), excluding anything already in the field, de-duplicated.
private var suggestionChips: [(tag: String, isAI: Bool)] {
let current = Set(parsedTags.map { $0.lowercased() })
var seen = Set<String>()
var out: [(tag: String, isAI: Bool)] = []
for tag in aiSuggestions + suggestedTags {
let key = tag.lowercased()
let isAI = aiSuggestions.contains { $0.lowercased() == key }
if !current.contains(key) && seen.insert(key).inserted {
out.append((tag, isAI))
}
}
return out
}
private var parsedTags: [String] {
tagText.split(whereSeparator: { $0 == " " || $0 == "," })
.map { String($0) }
.filter { !$0.isEmpty }
}
private func addTag(_ tag: String) {
let trimmed = tagText.trimmingCharacters(in: .whitespaces)
tagText = trimmed.isEmpty ? tag : trimmed + " " + tag
tagText += " "
}
// MARK: - Networking
private func check() async {
guard let api else {
errorMessage = "Open Marks first"
phase = .failed
return
}
do {
let result = try await api.checkBookmark(url: url)
if let bookmark = result.bookmark {
existing = bookmark
title = bookmark.displayTitle
tagText = bookmark.tagNames.joined(separator: " ")
notes = bookmark.description
readLater = bookmark.unread
} else {
title = result.metadata?.title ?? ""
suggestedTags = result.autoTags ?? []
}
// Pre-enable the toggle when the current tags match the auto-rule.
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
phase = .editing
await loadSuggestions()
} catch {
errorMessage = error.localizedDescription
phase = .failed
}
}
/// Fetches the user's existing tag vocabulary and AI tag ideas in parallel,
/// then applies the hybrid behavior: auto-fill suggestions that match the
/// existing vocabulary (new bookmarks only), surface the rest as chips.
/// Best-effort never throws, never blocks saving.
private func loadSuggestions() async {
guard let api else { return }
suggesting = true
defer { suggesting = false }
async let vocabTask = api.fetchTags()
let ai = await TagSuggester.suggest(url: url, title: title)
let vocab = (try? await vocabTask) ?? []
knownTags = Set(vocab.map { $0.lowercased() })
if existing == nil {
// Auto-fill AI tags the user already uses; high confidence, low surprise.
let current = Set(parsedTags.map { $0.lowercased() })
for tag in ai where knownTags.contains(tag.lowercased()) && !current.contains(tag.lowercased()) {
addTag(tag)
}
}
// Everything not in the vocabulary becomes a tappable chip.
aiSuggestions = ai.filter { !knownTags.contains($0.lowercased()) }
}
private func commit() async {
guard let api else { return }
phase = .saving
do {
if let existing {
let update = BookmarkUpdate(
url: existing.url,
title: existing.title,
description: notes,
tagNames: parsedTags,
unread: readLater,
shared: existing.shared
)
_ = try await api.updateBookmark(id: existing.id, update: update)
} else {
let create = BookmarkCreate(
url: url,
title: "",
tagNames: parsedTags,
isArchived: false,
unread: readLater,
shared: false
)
_ = 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)
} catch {
errorMessage = error.localizedDescription
phase = .failed
try? await Task.sleep(nanoseconds: 1_600_000_000)
onClose(false)
}
}
}

View File

@@ -1,25 +1,23 @@
import UIKit import UIKit
import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@objc(ShareViewController) @objc(ShareViewController)
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
private let appGroupId = "group.com.magicive.marks"
private let configKey = "serverConfig"
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
blur.frame = view.bounds blur.frame = view.bounds
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(blur) view.addSubview(blur)
extractURLAndSave() extractURL()
} }
private func extractURLAndSave() { private func extractURL() {
guard let item = extensionContext?.inputItems.first as? NSExtensionItem, guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments else { let attachments = item.attachments else {
showResult(success: false, message: "Nothing to save") present(url: nil)
return return
} }
@@ -31,99 +29,36 @@ class ShareViewController: UIViewController {
let urlString = typeId == UTType.url.identifier let urlString = typeId == UTType.url.identifier
? ((item as? URL)?.absoluteString ?? (item as? String)) ? ((item as? URL)?.absoluteString ?? (item as? String))
: (item as? String) : (item as? String)
DispatchQueue.main.async { DispatchQueue.main.async { self?.present(url: urlString) }
if let url = urlString { self?.save(url: url) }
else { self?.showResult(success: false, message: "No URL found") }
}
} }
return true return true
} }
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) { if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
showResult(success: false, message: "No URL found") present(url: nil)
} }
} }
private func save(url: String) { /// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
guard let groupDefaults = UserDefaults(suiteName: appGroupId), private func present(url: String?) {
let data = groupDefaults.data(forKey: configKey), let root = ShareView(url: url ?? "") { [weak self] _ in
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any], self?.close()
let host = config["host"] as? String,
let token = config["token"] as? String,
let useHttps = config["useHttps"] as? Bool else {
showResult(success: false, message: "Open Marks first")
return
} }
let host = UIHostingController(rootView: root)
host.view.backgroundColor = .clear
addChild(host)
host.view.frame = view.bounds
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(host.view)
host.didMove(toParent: self)
var base = (useHttps ? "https" : "http") + "://" + host if url == nil {
if let port = config["port"] as? Int { base += ":\(port)" } // No URL to work with let the card render its failure state briefly.
if let path = config["path"] as? String, !path.isEmpty { base += path } DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() }
guard let apiUrl = URL(string: "\(base)/api/bookmarks/") else {
showResult(success: false, message: "Bad server URL")
return
}
var req = URLRequest(url: apiUrl)
req.httpMethod = "POST"
req.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"url": url, "is_archived": false, "unread": false, "shared": false
])
URLSession.shared.dataTask(with: req) { [weak self] _, response, error in
DispatchQueue.main.async {
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
let ok = error == nil && (code == 200 || code == 201)
self?.showResult(success: ok, message: ok ? "Bookmarked" : "Failed to save")
}
}.resume()
}
private func showResult(success: Bool, message: String) {
let card = UIView()
card.backgroundColor = success ? .systemGreen : .systemRed
card.layer.cornerRadius = 16
card.layer.cornerCurve = .continuous
card.translatesAutoresizingMaskIntoConstraints = false
card.alpha = 0
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 10
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
let icon = UIImageView(image: UIImage(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill"))
icon.tintColor = .white
icon.preferredSymbolConfiguration = .init(pointSize: 22, weight: .semibold)
let label = UILabel()
label.text = message
label.textColor = .white
label.font = .systemFont(ofSize: 17, weight: .semibold)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(label)
card.addSubview(stack)
view.addSubview(card)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 24),
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
card.heightAnchor.constraint(equalToConstant: 56),
card.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
])
UIView.animate(withDuration: 0.2) { card.alpha = 1 }
DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.8 : 2.0)) { [weak self] in
UIView.animate(withDuration: 0.15, animations: { card.alpha = 0 }) { _ in
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
} }
private func close() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
} }

View File

@@ -0,0 +1,30 @@
import Foundation
/// Lightweight, extension-local client for AI tag suggestions. Reuses the same
/// backend + anonymous device auth as the main app's `ClaudeService.enrich`, but
/// without pulling the whole service (and its podcast/collection deps) into the
/// extension. The backend fetches the page itself, so suggestions reflect the
/// actual web page content not just the URL.
enum TagSuggester {
/// Asks the backend for tags for `url`. Returns [] on any failure tag
/// suggestion is best-effort and must never block saving.
static func suggest(url: String, title: String) async -> [String] {
struct Response: Decodable { let tags: [String] }
do {
let token = try await MarksAuth.validToken()
guard let endpoint = URL(string: MarksAuth.baseURL + "/v1/marks/enrich") else { return [] }
var req = URLRequest(url: endpoint)
req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"url": url, "title": title, "tags": [String](),
])
let (data, response) = try await URLSession.shared.data(for: req)
guard (200...299).contains((response as? HTTPURLResponse)?.statusCode ?? 0) else { return [] }
return try JSONDecoder().decode(Response.self, from: data).tags
} catch {
return []
}
}
}

161
docs/design-wwdc26.md Normal file
View File

@@ -0,0 +1,161 @@
# Marks — WWDC26 Design Brief
Source: WWDC26 session transcripts (`~/master/scratch/claude-setup/wwdc26/transcripts`).
Sessions digested: Principles of Great Design, Communicate Your Brand Identity on iOS,
What's New in SwiftUI, Dive into Lazy Stacks and Scrolling, Compose Advanced Graphics
Effects, Design Intuitive Search Experiences, WidgetKit Foundations, Create UI Prototypes
Using Agents in Xcode.
Everything below is mapped to Marks (SwiftUI bookmarks / read-later app, iOS 26 target,
with a home-screen widget and a share extension).
---
## The mental model: two layers (iOS 26 Liquid Glass)
- **UI layer** — tab bar, toolbars, search field → keep **native**. Don't restyle them.
- **Content layer** — the bookmark rows → **this is the canvas.** All editorial brand lives here.
The editorial-row direction is correct: it's the sanctioned place for personality. Corollary:
**move any header/brand color into the scroll content**, not onto the toolbar. The Liquid Glass
bar then picks up the tint dynamically as rows scroll under it, and content goes edge-to-edge
instead of being letterboxed by a colored bar.
> "Think of your app as two distinct layers: the UI layer … and the content layer … the content
> layer is the best opportunity to express your brand identity."
---
## Bookmark list (highest leverage)
- **Row hierarchy** answers three questions at a glance: *what do I look at, what can I tap, how?*
Title most prominent; favicon + domain + reading-time/date as the "context that makes it
simpler" (a bare title is *less* usable, not more). Build hierarchy with **order, spacing,
contrast** — not by stripping.
- **No truncation under Dynamic Type — wrap to multiple lines.** Mandatory to test if shipping
any custom font (free only with system fonts). New York (system serif) gives an editorial feel
with Dynamic Type for free.
- **Swipe actions = forgiveness, not confirmation.** Undo over alerts; consistent positions;
standard metaphors only (no creative-liberty trash/archive icons).
- **Tint = meaning only:** unread/synced status, selection, primary save. Everything else neutral.
- **Wordmark only on the main list, fade on scroll** (NYT Cooking pattern). "People don't need to
be reminded which app they're using."
> "When your hierarchy is strong, the most important item on the screen is always the most obvious one."
---
## SwiftUI wins (two are nearly free on iOS 26 once built with Xcode 27)
1. **`AsyncImage` now HTTP-caches by default** → favicons/thumbnails survive scroll-away/back.
Biggest free win for an image-heavy list. Add a sized `URLCache` via `.asyncImageURLSession(_:)`
if loading many.
2. **`@State` is now a lazy macro** → row/view models stop re-allocating on every parent update
(back-ports to iOS 17+). *One source-break to grep for:* a `@State` var with both a default
value **and** an `init` assignment now errors.
3. `toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar)` to reclaim reading space.
4. Drag-to-reorder via `reorderable`/`reorderContainer`; swipe actions work outside `List` via
`swipeActionsContainer` if rows move to `LazyVStack`.
### Scroll-perf traps that bite editorial lists specifically
- **Don't put conditional/dynamic content in `ForEach` leaf rows** (`if env { Row() }`) — keeps
off-screen rows alive and re-evaluating. **Filter at the data level** (SwiftData `#Predicate`
on `@Query`).
- **Configure rows in their initializer, not `onAppear`** (and `onAppear` isn't guaranteed in lazy
stacks — fine for paging, wrong for setup).
- Scroll-driven effects: use **scale/opacity**, never a transform that moves the row's frame, or
lazy rows vanish early.
- Read **`onScrollTargetVisibilityChange`** (visibility), not content-offset, for
"jump to top/unread" buttons.
---
## Search
- Put search in the **bottom toolbar** (most reachable; animates up over the keyboard) unless a
sheet blocks the bottom. `.searchable(text:placement:prompt:)`.
- Add a **scope bar** (`.searchScopes`) for All / Unread / tag; optionally layer **search tokens**
on top — but pair tokens with the visible scope bar (tokens alone aren't discoverable).
- **`ContentUnavailableView.search(text:)`** for no-results, echoing the query so typos are obvious.
---
## Widget (MarksWidget)
- **`AppIntentConfiguration`** so users pick a tag/collection (12 params, sensible default, don't
force config). Reuse the existing app group (`group.com.magicive.marks`).
- Reload policy **`.never`** + call **`WidgetCenter.shared.reloadTimelines(ofKind:)` when the app
*and the share extension* background after a save** — content changes on save, not on a clock.
- **`.widgetAccentedRenderingMode(.fullColor)` on favicons/thumbnails** — otherwise they render as
white rectangles in tinted/clear Home Screen modes. Test full-color / tinted / clear.
- Carry the app's editorial palette for instant recognizability; consider iOS 27's new
`.systemExtraLargePortrait` for a richer list. Deep-link rows with `.widgetURL`.
---
## Prototyping workflow
The "create UI prototypes with agents in Xcode" flow fits well: ask for *many named SwiftUI
previews* of BookmarkListRow variants against **realistic sample data + edge cases** (long titles,
missing thumbnails, hundreds of rows), then remix the ones you like. Output is real native SwiftUI
you keep. Best targets: BookmarkListRow layouts, the search results + empty states, the row→detail
transition, and the widget's families.
---
## Cross-cutting principles (Principles of Great Design)
- **Agency / forgiveness:** undo over confirmation for destructive actions; only interrupt for
genuinely big mistakes (bulk delete).
- **Familiarity:** standard symbols/metaphors; things that look the same behave the same.
- **Simplicity = removing friction**, not minimalism. "You'll know you've arrived at simplicity
when you have exactly enough."
- **Craft = materials:** good fonts, adaptive colors, crisp icons, responsive animation. Watch the
anti-patterns: jittery scrolling, misaligned icons, layout breaking on rotation, taps with no
immediate response — these are the trust-killers in a scrolling list.
- **Dark Mode is non-negotiable.**
- **Delight is the result of getting everything else right** — pick the target emotion (calm,
editorial, focused) and reinforce it everywhere.
---
## Suggested punch-list order
1. `AsyncImage` caching + `.widgetAccentedRenderingMode(.fullColor)` (free correctness/perf).
2. Audit `@State` view models for the lazy-init source-break before bumping SDK.
3. Move search to bottom toolbar + scope bar + `ContentUnavailableView.search`.
4. Row Dynamic Type wrapping audit; confirm no conditional content in `ForEach` leaves.
5. Widget → `AppIntentConfiguration` + `.never` reload + reload-on-save from app & share extension.
---
## Codebase audit (current vs. brief)
### Already compliant
- Native UI layer; brand lives in rows; no toolbar color letterboxing (`BookmarksView.swift`).
- Standard swipe metaphors — trash/archivebox/pencil (`BookmarkListRow.swift`).
- Dark-mode-safe: semantic colors throughout.
- `ContentUnavailableView.search(text:)` already used (`SearchView.swift`).
- Deep links via `Link(destination: .marksBookmark(...))` (`RecentBookmarksWidget.swift`).
- Reload-on-save already wired (`WidgetDataStore.saveBookmarks``reloadTimelines`).
- `onAppear` paging — the sanctioned lazy-list use (`BookmarksView.swift`).
### Fixed (this pass)
- **P1 Dynamic Type** — `BookmarkRow` converted from fixed `.system(size:)` points to text
styles: title `.headline`, domain/date `.footnote`, summary `.subheadline`, tags
`.caption`, podcast glyph `.title3`. Title already wraps (`lineLimit(2)` + `fixedSize`).
- **P2 disk I/O in `body`** — `podcastCached` was `FileManager.fileExists` evaluated during
render (per row, per scroll frame). Moved to `@State` populated by a `.task(id: bookmark.url)`
that stats off the main actor — once per appearance, not per render.
- **P3 unrecoverable delete** — destructive trailing swipe changed to `allowsFullSwipe: false`
so a single fling no longer deletes instantly; requires tapping the revealed button.
### Still open (optional)
- Favicon `URLCache` sizing via `.asyncImageURLSession(_:)` (iOS 26 already caches by default).
- True **undo** for delete (snackbar + soft-delete) — current fix only removes the accidental
full-swipe; an explicit tap still deletes immediately.
- Widget fonts still use fixed sizes (left as-is — widgets clamp Dynamic Type; lower value).
- Search scope bar (`.searchScopes`) for All / Unread / tag.
- Widget `AppIntentConfiguration`, more families incl. `.systemExtraLargePortrait`, interactive
"mark read" `AppIntent`.

View File

@@ -26,6 +26,8 @@ schemes:
config: Debug config: Debug
test: test:
config: Debug config: Debug
targets:
- MarksTests
profile: profile:
config: Release config: Release
analyze: analyze:
@@ -74,12 +76,22 @@ targets:
- target: ShareExtension - target: ShareExtension
- target: MarksWidget - target: MarksWidget
- sdk: WidgetKit.framework - sdk: WidgetKit.framework
- sdk: AppIntents.framework
ShareExtension: ShareExtension:
type: app-extension type: app-extension
platform: iOS platform: iOS
sources: sources:
- path: ShareExtension - path: ShareExtension
# 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/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: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension
@@ -88,6 +100,18 @@ targets:
SKIP_INSTALL: YES SKIP_INSTALL: YES
APPLICATION_EXTENSION_API_ONLY: YES APPLICATION_EXTENSION_API_ONLY: YES
MarksTests:
type: bundle.unit-test
platform: iOS
sources:
- path: MarksTests
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksTests
GENERATE_INFOPLIST_FILE: YES
dependencies:
- target: Marks
MarksWidget: MarksWidget:
type: app-extension type: app-extension
platform: iOS platform: iOS