Compare commits

...

35 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
Krishna Kumar
a5cd9b1d03 fix(BookmarkListRow): prevent swipe actions from triggering URL open
All checks were successful
CI / build-and-deploy (push) Successful in 34s
Replace Button wrapper with onTapGesture so swipe gestures don't compete
with the row tap recognizer and accidentally open bookmarks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:49:20 -05:00
Krishna Kumar
b7ae15ae87 Polish BookmarkRow to match approved HTML mock
All checks were successful
CI / build-and-deploy (push) Successful in 44s
- Meta line .tertiary → .secondary (more readable domain/date line)
- Tag font 11pt → 12pt
- Reading progress bar: full-width, green, pinned to row bottom edge via overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:24:32 -05:00
Krishna Kumar
199fc2d043 Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s
- Save serverConfig to App Group on every app launch so ShareExtension
  can always find credentials without requiring a disconnect/reconnect flow
- Add MarksWidget target with RandomBookmarkWidget (random saved bookmark,
  reloads hourly) using file-based App Group storage to avoid CFPreferences issues
- Extract BookmarkListRow as shared component with editorial typography tweaks
  (17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary,
  20pt podcast icon, 16pt horizontal insets)
- Add MarksWidget and ShareExtension Xcode schemes for direct run/debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:11:05 -05:00
Krishna Kumar
d87500a7dc experiment(BookmarksView): row horizontal insets 20pt → 16pt for tighter editorial margins 2026-05-23 01:14:18 -05:00
Krishna Kumar
3de93ac61d experiment(BookmarkRow): podcast icon 26pt → 20pt, less visual noise in list 2026-05-23 01:13:15 -05:00
Krishna Kumar
ee9a7c018e experiment(BookmarkRow): show dateAdded relative time inline with domain — 'domain · 2 days ago' 2026-05-23 01:12:48 -05:00
Krishna Kumar
89bcecebab experiment(BookmarkRow): AI summary 15pt → 14pt italic for editorial reading-app feel 2026-05-23 01:12:23 -05:00
Krishna Kumar
baadaf77a3 experiment(BookmarkRow): favicon 26×26 → 32×32, corner radius 5→7, align offsets to 44 2026-05-23 01:11:54 -05:00
Krishna Kumar
20f06c8440 experiment(BookmarkRow): tag pills blue fill → neutral systemGray6, secondary text 2026-05-23 01:11:03 -05:00
Krishna Kumar
18f1564d3b experiment(BookmarkRow): domain font 16pt → 13pt, tighter metadata line 2026-05-23 01:10:45 -05:00
Krishna Kumar
7c5ffbf69b experiment(BookmarkRow): title font 22pt medium → 17pt semibold for reading app scale 2026-05-23 01:10:24 -05:00
Krishna Kumar
15e2801b87 chore: gitignore autoresearch-results.tsv 2026-05-23 01:10:05 -05:00
Krishna Kumar
5c52693d4d experiment(PodcastPlayerView): add ShareLink button to full player for sharing episode URL 2026-05-23 00:56:48 -05:00
Krishna Kumar
2a0cb2d648 experiment(PodcastPlayerView): add MPMediaItemArtwork to Now Playing — waveform rendered image 2026-05-23 00:55:54 -05:00
Krishna Kumar
f61179e568 experiment(PodcastPlayerView): add swipe-to-delete in PodcastLibraryView, stops player if active 2026-05-23 00:55:04 -05:00
Krishna Kumar
09430e8e66 experiment(PodcastPlayerView): handle AVAudioSession interruptions — pause on call, resume after 2026-05-23 00:54:12 -05:00
Krishna Kumar
140c761850 experiment(PodcastPlayerView): add Retry button to failedView, clear pollingTask on failure 2026-05-23 00:52:55 -05:00
Krishna Kumar
f7d5c129fc experiment(PodcastPlayerView): add playbackSpeed control (0.75×/1×/1.25×/1.5×/2×) with menu 2026-05-23 00:51:23 -05:00
Krishna Kumar
be4a4c3a5f experiment(PodcastPlayerView): add lock screen skipBackwardCommand/skipForwardCommand (15s) 2026-05-23 00:50:07 -05:00
Krishna Kumar
fc0f98f45a experiment(PodcastPlayerView): add skip ±15s buttons to full player controls 2026-05-23 00:48:46 -05:00
Krishna Kumar
ea8a425c21 experiment(BookmarkRow): add sensoryFeedback haptic on podcast trigger button 2026-05-23 00:47:40 -05:00
Krishna Kumar
ab72611d7b Podcast UX baseline: mini player during generation, end-of-track reset, library view
- Mini player shows as soon as generation starts (not just when audio ready)
- Idempotency: tapping headphones again during generation is a no-op
- Playback resets to position 0 on completion (standard player behavior)
- Stop button in full player visible during generation
- PodcastLibraryView lists all generated podcasts
- Sparkles menu gains Podcasts entry
- Fix AnalyticsService Sendable error (serialize before Task.detached boundary)
2026-05-23 00:46:46 -05:00
52 changed files with 4462 additions and 343 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ Pods/
# VS Code
.vscode/
autoresearch-results.tsv

View File

@@ -7,33 +7,76 @@
objects = {
/* Begin PBXBuildFile section */
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 */; };
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; };
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; };
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
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 */; };
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 */; };
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
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 */; };
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, ); }; };
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.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 */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.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 */; };
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */; };
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
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 */
/* Begin PBXContainerItemProxy section */
22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
proxyType = 1;
remoteGlobalIDString = 61046BF39C48393DD96687A0;
remoteInfo = MarksWidget;
};
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
@@ -41,6 +84,13 @@
remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F;
remoteInfo = ShareExtension;
};
F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
proxyType = 1;
remoteGlobalIDString = 76C272776D8EBCCD3FB6715A;
remoteInfo = Marks;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -51,6 +101,7 @@
dstSubfolderSpec = 13;
files = (
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -59,18 +110,39 @@
/* Begin PBXFileReference section */
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
55D369E6D2B8B036685BBD13 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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>"; };
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>"; };
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>"; };
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; };
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>"; };
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>"; };
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; };
@@ -78,17 +150,48 @@
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>"; };
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>"; };
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>"; };
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; };
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = "<group>"; };
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
6B2547BA0E17C2564677B34A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A1E820C4B9604F8F05055D3E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */,
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
3952A081972B444674BEB51B /* Marks */ = {
isa = PBXGroup;
@@ -97,6 +200,7 @@
86B2DE40098D2B39FFE1AC36 /* Info.plist */,
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
D92575C7C710347F226EC74A /* MarksApp.swift */,
58E8E316BE3F10C5149AADC3 /* Intents */,
3EF3094F53F0378D1BF18508 /* Models */,
7ABBFDF43A1EB773E0A49CFE /* Services */,
AF9CABF75E77168EF1D60F29 /* Views */,
@@ -114,12 +218,34 @@
path = Models;
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 */ = {
isa = PBXGroup;
children = (
E895C34E4D2A1C4709B25FF1 /* Info.plist */,
03D800669E343FF305468424 /* ShareExtension.entitlements */,
9B7A85A23A13D754F6A75E4D /* ShareView.swift */,
ADEAC824576633CC77370262 /* ShareViewController.swift */,
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */,
);
path = ShareExtension;
sourceTree = "<group>";
@@ -127,19 +253,44 @@
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */,
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
FB7728D15C17219ABFF3EFFE /* Log.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
);
path = Services;
sourceTree = "<group>";
};
85E717A515682EBF67DD199A /* MarksWidget */ = {
isa = PBXGroup;
children = (
55D369E6D2B8B036685BBD13 /* Info.plist */,
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */,
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */,
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */,
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */,
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */,
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */,
);
path = MarksWidget;
sourceTree = "<group>";
};
AF9CABF75E77168EF1D60F29 /* Views */ = {
isa = PBXGroup;
children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
217E6702DE1210AC38ED16D1 /* AskView.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
@@ -159,7 +310,10 @@
isa = PBXGroup;
children = (
3952A081972B444674BEB51B /* Marks */,
3F5B1508DEFC894F33CBD16C /* MarksTests */,
85E717A515682EBF67DD199A /* MarksWidget */,
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
F5B3C85424137FA0CE2467B1 /* Frameworks */,
C6978BB2E0A71E9A349D5EB6 /* Products */,
);
sourceTree = "<group>";
@@ -168,26 +322,58 @@
isa = PBXGroup;
children = (
AB6C53AB14A38FCD4CC7628D /* Marks.app */,
C8B10AC011AF8F242E525440 /* MarksTests.xctest */,
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
isa = PBXGroup;
children = (
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */,
093402014727A338745A06DA /* SwiftUI.framework */,
0552C13335034219DECF4F62 /* WidgetKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
61046BF39C48393DD96687A0 /* MarksWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */;
buildPhases = (
2B3C202ACE4B7248E24601BF /* Sources */,
A1E820C4B9604F8F05055D3E /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = MarksWidget;
packageProductDependencies = (
);
productName = MarksWidget;
productReference = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
76C272776D8EBCCD3FB6715A /* Marks */ = {
isa = PBXNativeTarget;
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
buildPhases = (
81AF93AC59791A6311B9055B /* Sources */,
D56AC7DEA48961F856A6CEF6 /* Resources */,
6B2547BA0E17C2564677B34A /* Frameworks */,
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */,
);
name = Marks;
packageProductDependencies = (
@@ -213,6 +399,24 @@
productReference = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */;
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 */
/* Begin PBXProject section */
@@ -222,6 +426,10 @@
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1630;
TargetAttributes = {
61046BF39C48393DD96687A0 = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
76C272776D8EBCCD3FB6715A = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
@@ -230,6 +438,10 @@
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
F96181AD21F4A6755214E19B = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */;
@@ -247,6 +459,8 @@
projectRoot = "";
targets = (
76C272776D8EBCCD3FB6715A /* Marks */,
F96181AD21F4A6755214E19B /* MarksTests */,
61046BF39C48393DD96687A0 /* MarksWidget */,
CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
);
};
@@ -264,11 +478,39 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1035354FBD99C8ED4ACA93DC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2210C849B430CC6609106C62 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
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 */,
D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2B3C202ACE4B7248E24601BF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */,
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */,
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */,
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */,
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -276,33 +518,60 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */,
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */,
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */,
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 61046BF39C48393DD96687A0 /* MarksWidget */;
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
};
A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 76C272776D8EBCCD3FB6715A /* Marks */;
targetProxy = F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */;
};
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
@@ -422,6 +691,40 @@
};
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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
INFOPLIST_FILE = MarksWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7AD80830605CAD3492E03039 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -506,6 +809,40 @@
};
name = Debug;
};
D8AB62E7173CC10C6EB15373 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
INFOPLIST_FILE = MarksWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
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 */
/* Begin XCConfigurationList section */
@@ -536,6 +873,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6CDF13C33FDD1141CFE677AD /* Debug */,
D8AB62E7173CC10C6EB15373 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
68D4EEAD1A7AA570E88EF498 /* Debug */,
E92B937142E9625F95B51699 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;

View File

@@ -21,6 +21,20 @@
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
@@ -53,6 +67,17 @@
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F96181AD21F4A6755214E19B"
BuildableName = "MarksTests.xctest"
BlueprintName = "MarksTests"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -28,5 +28,14 @@
<array>
<string>audio</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>marks</string>
</array>
</dict>
</array>
</dict>
</plist>

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

@@ -18,16 +18,28 @@ struct MarksApp: App {
defaultConfig.save()
serverConfig = defaultConfig
}
.task { serverConfig.save() }
}
}
}
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
// Holds the API + ViewModel so they survive re-renders
struct MainContainer: View {
let config: ServerConfig
let onDisconnect: () -> Void
@State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL?
@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) {
self.config = config
@@ -37,16 +49,101 @@ struct MainContainer: View {
}
var body: some View {
TabView {
Tab("Bookmarks", systemImage: "bookmark") {
TabView(selection: $selectedTab) {
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
}
Tab("Tags", systemImage: "tag") {
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
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)
}
}
.onOpenURL { url in
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
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
}
.sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
}
/// 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) {
guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
else { return }
switch url.host {
case "bookmark":
if let browserURL = URL(string: articleUrl) {
deepLinkBrowser = IdentifiableURL(url: browserURL)
}
case "podcast":
let podcasts = WidgetDataStore.loadPodcasts()
let found = podcasts.first { $0.articleUrl == articleUrl }
viewModel.podcastPlayer.start(
articleUrl: articleUrl,
articleTitle: found?.title ?? "",
claude: viewModel.claude
)
showDeepLinkPlayer = true
default:
break
}
}
}

View File

@@ -41,6 +41,17 @@ struct Bookmark: Codable, Identifiable, Sendable, Hashable {
var domain: String {
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 {
@@ -50,6 +61,24 @@ struct BookmarkResponse: Codable, Sendable {
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 {
let url: 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 {
let id = UUID()
let name: String

View File

@@ -25,6 +25,13 @@ extension ServerConfig {
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() {
guard let data = try? JSONEncoder().encode(self) else { return }
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

@@ -2,6 +2,10 @@ import Foundation
enum Analytics {
static func track(_ event: String, _ properties: [String: Any] = [:]) {
// Serialize before crossing the task boundary Data is Sendable, [String: Any] is not
var body: [String: Any] = ["event": event]
if !properties.isEmpty { body["properties"] = properties }
guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return }
Task.detached {
do {
let token = try await MarksAuth.validToken()
@@ -10,16 +14,14 @@ enum Analytics {
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["event": event]
if !properties.isEmpty { body["properties"] = properties }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.httpBody = payload
let (_, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status < 200 || status > 299 {
print("[Analytics] \(event) → HTTP \(status)")
Log.analytics.error("\(event, privacy: .public) → HTTP \(status, privacy: .public)")
}
} 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

@@ -67,13 +67,13 @@ struct ClaudeService: Sendable {
return try JSONDecoder().decode(PodcastStatus.self, from: data)
}
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil) async throws -> URL {
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try await get("/v1/podcast/audio/\(jobId)")
try data.write(to: dest, options: .atomic)
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title)
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
return dest
}
@@ -95,12 +95,13 @@ struct ClaudeService: Sendable {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
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 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 {
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)
}
return data
@@ -111,11 +112,13 @@ struct ClaudeService: Sendable {
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
var request = URLRequest(url: url)
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 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 {
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)
}
return data

View File

@@ -78,6 +78,28 @@ actor LinkdingAPI {
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 {
let body = try JSONEncoder().encode(create)
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)
UserDefaults.standard.set(resp.access_token, forKey: tokenKey)
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
}
}

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

@@ -6,6 +6,12 @@ struct PodcastEntry: Codable, Identifiable {
let filename: String
var title: String?
let createdAt: Date
var parentBookmarkUrl: String?
/// When the episode was finished. `nil` means unplayed/new. Absent in
/// pre-existing index files, which decode as `nil` (i.e. unplayed).
var playedAt: Date? = nil
var isPlayed: Bool { playedAt != nil }
}
enum PodcastIndex {
@@ -35,11 +41,28 @@ enum PodcastIndex {
return entries.sorted { $0.createdAt > $1.createdAt }
}
static func upsert(articleUrl: String, filename: String, title: String?) {
static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
var entries = all()
entries.removeAll { $0.articleUrl == articleUrl }
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
title: title, createdAt: Date()), at: 0)
title: title, createdAt: Date(),
parentBookmarkUrl: parentBookmarkUrl), at: 0)
save(entries)
}
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
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)
}
@@ -52,5 +75,8 @@ enum PodcastIndex {
private static func save(_ entries: [PodcastEntry]) {
guard let data = try? encoder.encode(entries) else { return }
try? data.write(to: indexURL, options: .atomic)
WidgetDataStore.savePodcasts(entries.prefix(10).map {
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
})
}
}

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

@@ -0,0 +1,107 @@
import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
struct BookmarkListRow: View {
let bookmark: Bookmark
let viewModel: BookmarksViewModel
var readingProgress: Double = 0
let onOpen: () -> Void
var onEdit: (() -> Void)? = nil
@Environment(\.openURL) private var openURL
@State private var showFullPlayer = false
@State private var episodePickerBookmark: Bookmark?
var body: some View {
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
.contentShape(Rectangle())
.onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
// 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) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
.contextMenu {
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
}
Button { onOpen() } label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button { handlePodcast() } label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
.sheet(item: $episodePickerBookmark) { b in
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
}
}
private func handlePodcast() {
let episodes = PodcastIndex.find(for: bookmark.url)
if episodes.count >= 2 {
episodePickerBookmark = bookmark
} else if let ep = episodes.first {
viewModel.podcastPlayer.start(
articleUrl: ep.articleUrl,
articleTitle: ep.title ?? bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
} else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
showFullPlayer = true
}
}
}

View File

@@ -5,6 +5,18 @@ struct BookmarkRow: View {
var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil
@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 {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 11) {
@@ -21,76 +33,99 @@ struct BookmarkRow: View {
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle)
.font(.system(size: 22, weight: .medium))
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 4) {
Text(bookmark.domain)
.font(.system(size: 16))
.foregroundStyle(.tertiary)
Text("·")
Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date()))
}
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
if let onPodcast {
Button(action: onPodcast) {
Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 26))
.font(.title3)
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
}
.buttonStyle(.plain)
.padding(.top, 1)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
}
}
if let summary = bookmark.aiSummary {
Text(summary)
.font(.system(size: 15))
if let excerpt = rowExcerpt {
Text(excerpt.text)
.font(.subheadline)
.italic(excerpt.isAI)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, 40)
.padding(.leading, 44)
}
if !effectiveTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
GlassEffectContainer(spacing: 6) {
HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.blue)
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.glassEffect(in: Capsule())
}
}
}
.padding(.leading, 40)
}
.padding(.leading, 44)
}
// Reading progress indicator
}
.padding(.vertical, 14)
.contentShape(Rectangle())
.overlay(alignment: .bottom) {
if readingProgress > 0.02 {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color(.systemGray5))
Rectangle()
.fill(Color.blue.opacity(0.55))
.fill(Color(.systemGreen))
.frame(width: geo.size.width * min(readingProgress, 1))
}
}
.frame(height: 2)
.clipShape(Capsule())
.padding(.leading, 40)
}
}
.padding(.vertical, 14)
.contentShape(Rectangle())
.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 {
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path)
/// Excerpt shown under the title: prefer the AI summary (italic), else the
/// 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] {
@@ -119,16 +154,16 @@ struct FaviconView: View {
placeholder
}
}
.frame(width: 26, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
private var placeholder: some View {
RoundedRectangle(cornerRadius: 4)
RoundedRectangle(cornerRadius: 6)
.fill(Color(.systemGray5))
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 9, weight: .medium))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
}

View File

@@ -1,98 +1,85 @@
import SwiftUI
// MARK: - Skeleton loading row
private struct SkeletonRow: View {
var delay: Double = 0
@State private var dimmed = false
var body: some View {
HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 7)
.fill(Color(.systemGray5))
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Color(.systemGray5))
.frame(maxWidth: .infinity)
.frame(height: 13)
Capsule()
.fill(Color(.systemGray6))
.frame(width: 140, height: 10)
}
.padding(.top, 4)
Spacer()
}
.padding(.vertical, 14)
.opacity(dimmed ? 0.45 : 1)
.onAppear {
withAnimation(
.easeInOut(duration: 0.9)
.repeatForever(autoreverses: true)
.delay(delay)
) { dimmed = true }
}
}
}
// MARK: - Row press ButtonStyle
struct RowPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
}
}
// MARK: - BookmarksView
struct BookmarksView: View {
@Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void
@Environment(\.openURL) private var openURL
@State private var showSettings = false
@State private var showCollections = false
@State private var showAddBookmark = false
@State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false
@State private var showAsk = false
@State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View {
NavigationStack {
List {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress[bookmark.url] ?? 0,
onPodcast: {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
}
)
.onTapGesture {
browsingBookmark = bookmark
}
.onAppear { maybeLoadMore(bookmark) }
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ForEach(0..<3, id: \.self) { i in
SkeletonRow(delay: Double(i) * 0.13)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
Button {
editingBookmark = bookmark
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
.contextMenu {
Button {
editingBookmark = bookmark
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
browsingBookmark = bookmark
} label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
} else {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
showFullPlayer = true
} label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
.onAppear { maybeLoadMore(bookmark) }
}
}
@@ -102,11 +89,17 @@ struct BookmarksView: View {
}
}
.listStyle(.plain)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Button {
showAsk = true
} label: {
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
}
Button {
Task { await viewModel.generateSmartCollections() }
showCollections = true
@@ -133,6 +126,7 @@ struct BookmarksView: View {
Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
}
Button { showSettings = true } label: {
Image(systemName: "gearshape")
@@ -141,16 +135,22 @@ struct BookmarksView: View {
}
}
.overlay {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView()
}
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
ContentUnavailableView(
"No Bookmarks",
systemImage: "bookmark",
description: Text("Bookmarks you save will appear here.")
)
.transition(.opacity)
}
}
.overlay(alignment: .bottom) {
VStack(spacing: 8) {
if viewModel.podcastPlayer.player != nil {
if viewModel.podcastGenerator.hasActive {
podcastGeneratingBanner
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true
}
@@ -160,9 +160,11 @@ struct BookmarksView: View {
enrichmentBanner
}
}
.animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil)
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
.padding(.bottom, 8)
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
@@ -170,6 +172,9 @@ struct BookmarksView: View {
.sheet(isPresented: $showCollections) {
CollectionsView(viewModel: viewModel)
}
.sheet(isPresented: $showAsk) {
AskView()
}
.sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel)
}
@@ -178,7 +183,10 @@ struct BookmarksView: View {
}
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
await viewModel.archive(bookmark)
}
.bookmarkOnscreen(bookmark)
}
}
.onChange(of: browsingBookmark) { _, new in
@@ -227,6 +235,40 @@ struct BookmarksView: View {
.padding(.horizontal, 16)
}
private var podcastGeneratingBanner: some View {
let jobs = viewModel.podcastGenerator.activeJobs
return HStack(spacing: 10) {
Image(systemName: "waveform")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
.font(.system(size: 13, weight: .medium))
if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.frame(width: 44)
} else {
ProgressView()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
}
private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }

View File

@@ -18,6 +18,7 @@ final class BookmarksViewModel {
private let api: LinkdingAPI
let claude = ClaudeService()
let podcastPlayer = PodcastPlayerViewModel()
let podcastGenerator = PodcastGenerationManager()
private var enrichTask: Task<Void, Never>?
private let cacheFileUrl: URL
@@ -29,6 +30,32 @@ final class BookmarksViewModel {
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
}
/// True while the player is showing an episode (playing or generating one).
var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty }
/// Handle a headphone tap without ever interrupting current playback.
/// If the player is idle, generate-and-play in the foreground and return
/// `true` (caller should present the full player). If the player is busy,
/// generate in the background the finished episode lands in the library
/// and return `false`.
@discardableResult
func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool {
if isPlayerBusy {
podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl)
return false
}
podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl)
return true
}
/// Drain podcast requests queued by the share extension and start generating
/// them in the background. Safe to call repeatedly (the queue is cleared).
func processPendingPodcastRequests() {
for req in PodcastRequests.drain() {
podcastGenerator.enqueue(articleUrl: req.url, title: req.title)
}
}
func load(useCache: Bool = true) async {
if useCache { loadFromCache() }
isLoading = true
@@ -39,6 +66,10 @@ final class BookmarksViewModel {
nextPageUrl = response.next
restoreAIData()
saveToCache(bookmarks)
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
})
Task { await SpotlightIndexer.index(bookmarks) }
} catch {
self.error = error.localizedDescription
}
@@ -54,6 +85,8 @@ final class BookmarksViewModel {
bookmarks.append(contentsOf: response.results)
nextPageUrl = response.next
restoreAIData()
let added = response.results
Task { await SpotlightIndexer.index(added) }
} catch {
self.error = error.localizedDescription
}
@@ -99,21 +132,24 @@ final class BookmarksViewModel {
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
let bookmark = try await api.createBookmark(create)
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 {
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)")
do {
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 {
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
}
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = aiTags
saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
} catch {
print("[AI] addBookmark: enrich FAILED \(error)")
Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
self.error = "AI enrichment failed: \(error.localizedDescription)"
}
}
@@ -123,6 +159,7 @@ final class BookmarksViewModel {
do {
try await api.deleteBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id }
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
} catch {
self.error = error.localizedDescription
}
@@ -132,6 +169,7 @@ final class BookmarksViewModel {
do {
try await api.archiveBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id }
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
} catch {
self.error = error.localizedDescription
}
@@ -177,6 +215,8 @@ final class BookmarksViewModel {
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
} catch {
break
@@ -193,23 +233,25 @@ final class BookmarksViewModel {
enrichTask = Task { [weak self] in
guard let self else { return }
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 {
guard !Task.isCancelled else { return }
let claude = await MainActor.run(body: { self.claude })
do {
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)
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))")
Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)")
await MainActor.run {
guard i < bookmarks.count else { return }
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
}
} catch {
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)")
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
break
}
}
@@ -236,7 +278,7 @@ final class BookmarksViewModel {
let data = try Self.cacheEncoder.encode(list)
try data.write(to: cacheFileUrl, options: .atomic)
} catch {
print("[Cache] saveToCache failed: \(error)")
Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -257,6 +299,8 @@ final class BookmarksViewModel {
"tags": bookmark.aiTags ?? []
]
UserDefaults.standard.set(store, forKey: "aiData")
// Mirror by URL so the decoupled Podcasts tab can show summaries.
AISummaryStore.set(bookmark.aiSummary, for: bookmark.url)
}
private func restoreAIData() {
@@ -269,6 +313,8 @@ final class BookmarksViewModel {
if let d = store["\(list[i].id)"] {
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
list[i].aiTags = d["tags"] as? [String]
// Keep the URL-keyed summary cache populated for existing bookmarks.
if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) }
}
}
}

View File

@@ -25,6 +25,29 @@ enum ReadingProgress {
}
}
// MARK: - Indeterminate page load bar
private struct PageLoadBar: View {
@State private var phase: CGFloat = 0
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width * 0.38
Capsule()
.fill(.blue.opacity(0.75))
.frame(width: barWidth, height: 3)
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
}
.frame(height: 3)
.clipped()
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
// MARK: - BrowserState
@Observable
@@ -196,18 +219,27 @@ struct BrowserView: View {
let initialURL: URL
let initialTitle: String
let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var state: BrowserState
@State private var readerMode = false
@State private var showPodcast = false
@State private var podcastVM = PodcastPlayerViewModel()
@State private var archiveTapCount = 0
@State private var navBackCount = 0
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService) {
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self.podcastPlayer = podcastPlayer
self.podcastGenerator = podcastGenerator
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url))
}
@@ -221,16 +253,17 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.7))
.fill(Color.blue.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress)
}
// Page loading sweep bar (replaces center spinner)
if state.isLoading {
ProgressView()
.padding(.top, 8)
PageLoadBar()
.transition(.opacity)
}
}
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
@@ -247,42 +280,26 @@ struct BrowserView: View {
Image(systemName: "safari")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Button { state.webView.goBack() } label: {
Image(systemName: "chevron.left")
}
.disabled(!state.canGoBack)
Spacer()
Button { state.webView.goForward() } label: {
Image(systemName: "chevron.right")
}
.disabled(!state.canGoForward)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
}
.disabled(state.isLoading)
Spacer()
Button { showPodcast = true } label: {
Image(systemName: "headphones")
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomBar
.offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
}
.onChange(of: state.readingProgress) { old, new in
let delta = new - old
withAnimation(.spring(duration: 0.3, bounce: 0)) {
if delta > 0.012 && new > 0.07 {
toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
toolbarHidden = false
}
}
}
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.onDisappear {
@@ -292,12 +309,111 @@ struct BrowserView: View {
}
.sheet(isPresented: $showPodcast) {
PodcastPlayerView(
vm: podcastVM,
vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude,
stopOnDismiss: true
stopOnDismiss: false
)
}
}
private var bottomBar: some View {
HStack {
Button {
navBackCount += 1
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoBack)
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
Spacer()
Button {
navForwardCount += 1
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoForward)
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
if onArchive != nil { Spacer() }
if let onArchive {
Button {
archiveTapCount += 1
Task { await onArchive() }
dismiss()
} label: {
Image(systemName: "archivebox")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
}
Spacer()
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
// Never interrupt current playback: play-and-show only when idle,
// otherwise generate in the background.
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} else {
podcastGenerator.enqueue(
articleUrl: currentUrl,
title: currentTitle,
parentBookmarkUrl: initialURL.absoluteString
)
}
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.overlay(alignment: .top) { Divider() }
}
}

View File

@@ -89,10 +89,9 @@ struct OnboardingView: View {
}
.frame(maxWidth: .infinity)
.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)
.padding(.horizontal, 24)
.padding(.top, 24)

View File

@@ -42,18 +42,48 @@ final class PodcastPlayerViewModel {
var duration: Double = 1
var currentArticleUrl: String = ""
var currentArticleTitle: String = ""
var playbackSpeed: Float = PodcastPlayerViewModel.savedSpeed
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
/// Sleep-timer selection. `.off` when disabled.
enum SleepTimer: Hashable { case off, minutes(Int), endOfEpisode }
var sleepTimer: SleepTimer = .off
private static let speedKey = "podcastPlaybackSpeed"
private static let sessionKey = "podcastSession"
static var savedSpeed: Float {
let v = UserDefaults.standard.double(forKey: speedKey)
return v > 0 ? Float(v) : 1.0
}
var isGenerating: Bool {
switch phase {
case .generating, .downloading: return true
default: return false
}
}
/// Remaining episodes to auto-play after the current one finishes.
private(set) var queue: [PodcastEntry] = []
/// Retained so queued episodes can be started from the end-of-item observer.
private var lastClaude: ClaudeService?
private var pollingTask: Task<Void, Never>?
private var sleepTask: Task<Void, Never>?
private var timeObserver: Any?
private var endObserver: Any?
private var interruptionObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast"
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
// Idempotent don't restart the same article if already playing
if currentArticleUrl == articleUrl && player != nil { return }
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
// Idempotent don't restart if already generating or playing this article
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
// Teardown previous episode (saves its position)
if player != nil { stop() }
lastClaude = claude
currentArticleUrl = articleUrl
currentArticleTitle = articleTitle
@@ -78,7 +108,7 @@ final class PodcastPlayerViewModel {
)
if job.isDone {
phase = .downloading
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title)
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return
}
@@ -86,6 +116,7 @@ final class PodcastPlayerViewModel {
let errMsg = job.error ?? "Generation failed"
Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg])
phase = .failed(errMsg)
pollingTask = nil
return
}
try await Task.sleep(for: .seconds(3))
@@ -94,17 +125,35 @@ final class PodcastPlayerViewModel {
} catch {
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
phase = .failed(error.localizedDescription)
pollingTask = nil
}
}
}
/// Play `entries` in order, auto-advancing through the queue as each finishes.
func playQueue(_ entries: [PodcastEntry], claude: ClaudeService) {
guard let first = entries.first else { return }
// `start` calls `stop`, which clears the queue set it afterwards.
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
queue = Array(entries.dropFirst())
persistSession()
}
func stop() {
queue = []
clearSession()
sleepTask?.cancel(); sleepTask = nil
sleepTimer = .off
if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
}
pollingTask?.cancel()
player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) }
if let obs = endObserver { NotificationCenter.default.removeObserver(obs) }
endObserver = nil
if let obs = interruptionObserver { NotificationCenter.default.removeObserver(obs) }
interruptionObserver = nil
player = nil
isPlaying = false
currentTime = 0
@@ -118,12 +167,19 @@ final class PodcastPlayerViewModel {
c.playCommand.removeTarget(nil)
c.pauseCommand.removeTarget(nil)
c.changePlaybackPositionCommand.removeTarget(nil)
c.skipBackwardCommand.removeTarget(nil)
c.skipForwardCommand.removeTarget(nil)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func togglePlayPause() {
guard let player else { return }
if isPlaying { player.pause() } else { player.play() }
if isPlaying {
player.pause()
} else {
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
player.rate = playbackSpeed
}
isPlaying.toggle()
updateNowPlaying()
}
@@ -134,12 +190,100 @@ final class PodcastPlayerViewModel {
updateNowPlaying()
}
private func setupPlayer(url: URL, title: String) {
func skipForward15() {
seek(to: min(currentTime + 15, duration))
}
func skipBackward15() {
seek(to: max(currentTime - 15, 0))
}
func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
if isPlaying { player?.rate = speed }
updateNowPlaying()
}
// MARK: - Sleep timer
func setSleepTimer(_ timer: SleepTimer) {
sleepTimer = timer
sleepTask?.cancel(); sleepTask = nil
guard case .minutes(let m) = timer else { return } // .endOfEpisode handled at finish
sleepTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(Double(m) * 60))
guard !Task.isCancelled else { return }
self?.fireSleepTimer()
}
}
private func fireSleepTimer() {
player?.pause()
isPlaying = false
sleepTimer = .off
sleepTask = nil
updateNowPlaying()
}
// MARK: - Session persistence (survives app relaunch)
private struct SavedSession: Codable {
let currentUrl: String
let currentTitle: String
let podcastTitle: String
let queue: [PodcastEntry]
}
/// Persist the current episode + queue so relaunch can restore a paused session.
func persistSession() {
guard !currentArticleUrl.isEmpty else { clearSession(); return }
let session = SavedSession(currentUrl: currentArticleUrl,
currentTitle: currentArticleTitle,
podcastTitle: currentPodcastTitle,
queue: queue)
if let data = try? JSONEncoder().encode(session) {
UserDefaults.standard.set(data, forKey: Self.sessionKey)
}
}
private func clearSession() {
UserDefaults.standard.removeObject(forKey: Self.sessionKey)
}
/// Save the current playback position + session (call when backgrounding).
func saveProgress() {
if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
}
persistSession()
}
/// Restore a previously-playing episode into a **paused** player on launch,
/// with its queue intact. No-op if nothing is saved or the audio is gone.
func restoreSession(claude: ClaudeService) {
guard currentArticleUrl.isEmpty, player == nil else { return }
guard let data = UserDefaults.standard.data(forKey: Self.sessionKey),
let session = try? JSONDecoder().decode(SavedSession.self, from: data) else { return }
let cached = ClaudeService.cachedPodcastURL(for: session.currentUrl)
guard FileManager.default.fileExists(atPath: cached.path) else { clearSession(); return }
lastClaude = claude
currentArticleUrl = session.currentUrl
currentArticleTitle = session.currentTitle
queue = session.queue
setupPlayer(url: cached, title: session.podcastTitle, autoPlay: false)
}
private func setupPlayer(url: URL, title: String, autoPlay: Bool = true) {
currentPodcastTitle = title
// A freshly generated episode may have just landed in the index.
PodcastLibrary.shared.reload()
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
options: [.allowBluetoothA2DP])
try? AVAudioSession.sharedInstance().setActive(true)
// Only claim the audio session when actually playing a restored, paused
// session on launch must not interrupt the user's other audio.
if autoPlay { try? AVAudioSession.sharedInstance().setActive(true) }
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
@@ -158,6 +302,60 @@ final class PodcastPlayerViewModel {
}
}
// On finish: mark the episode played, then auto-advance to the next
// queued episode, or reset to the start like a standard media player.
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
guard let self else { return }
let finished = self.currentArticleUrl
PodcastIndex.setPlayed(articleUrl: finished, true)
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
PodcastLibrary.shared.reload()
// Sleep timer set to "end of episode" stops here no auto-advance.
let stopForSleep = self.sleepTimer == .endOfEpisode
if stopForSleep { self.sleepTimer = .off }
if !stopForSleep, !self.queue.isEmpty {
let next = self.queue.removeFirst()
let remaining = self.queue
let claude = self.lastClaude ?? ClaudeService()
self.start(articleUrl: next.articleUrl,
articleTitle: next.title ?? "", claude: claude)
self.queue = remaining // `start` cleared it via `stop`
self.persistSession()
} else {
self.isPlaying = false
self.currentTime = 0
self.player?.seek(to: .zero)
self.updateNowPlaying()
self.persistSession()
}
}
// Pause on phone calls / Siri; resume when interruption ends
interruptionObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
) { [weak self] notification in
guard let self,
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
if type == .began {
self.player?.pause()
self.isPlaying = false
self.updateNowPlaying()
} else if type == .ended {
let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
self.player?.rate = self.playbackSpeed
self.isPlaying = true
self.updateNowPlaying()
}
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
guard let self else { return }
@@ -173,14 +371,20 @@ final class PodcastPlayerViewModel {
setupRemoteCommands()
phase = .ready(title: title)
updateNowPlaying()
p.play()
if autoPlay {
p.rate = playbackSpeed
isPlaying = true
} else {
isPlaying = false
}
persistSession()
}
private func setupRemoteCommands() {
let c = MPRemoteCommandCenter.shared()
c.playCommand.isEnabled = true
c.playCommand.addTarget { [weak self] _ in
try? AVAudioSession.sharedInstance().setActive(true)
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
}
c.pauseCommand.isEnabled = true
@@ -194,19 +398,49 @@ final class PodcastPlayerViewModel {
self?.currentTime = e.positionTime
return .success
}
c.skipBackwardCommand.isEnabled = true
c.skipBackwardCommand.preferredIntervals = [15]
c.skipBackwardCommand.addTarget { [weak self] _ in
self?.skipBackward15(); return .success
}
c.skipForwardCommand.isEnabled = true
c.skipForwardCommand.preferredIntervals = [15]
c.skipForwardCommand.addTarget { [weak self] _ in
self?.skipForward15(); return .success
}
}
private func updateNowPlaying() {
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentPodcastTitle
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? Double(playbackSpeed) : 0.0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
if let artwork = Self.nowPlayingArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private static func statusLabel(_ status: String) -> String {
private static let nowPlayingArtwork: MPMediaItemArtwork? = {
let size = CGSize(width: 300, height: 300)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
UIColor.systemBlue.withAlphaComponent(0.15).setFill()
ctx.fill(CGRect(origin: .zero, size: size))
let config = UIImage.SymbolConfiguration(pointSize: 140, weight: .regular)
if let symbol = UIImage(systemName: "waveform.circle.fill", withConfiguration: config) {
let tinted = symbol.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
let origin = CGPoint(x: (size.width - tinted.size.width) / 2,
y: (size.height - tinted.size.height) / 2)
tinted.draw(at: origin)
}
}
return MPMediaItemArtwork(boundsSize: size) { _ in image }
}()
static func statusLabel(_ status: String) -> String {
switch status {
case "queued": return "Queued…"
case "fetching": return "Fetching article…"
@@ -228,6 +462,33 @@ struct PodcastPlayerView: View {
var stopOnDismiss: Bool = false
@Environment(\.dismiss) private var dismiss
@State private var isPlayed = false
/// The episode currently loaded in the player (follows queue advances),
/// falling back to the URL this sheet was opened with.
private var activeUrl: String {
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl
}
private var sleepActive: Bool { vm.sleepTimer != .off }
private var sleepLabel: String {
switch vm.sleepTimer {
case .off: return "Sleep"
case .minutes(let m): return "\(m) min"
case .endOfEpisode: return "End"
}
}
@ViewBuilder
private func sleepButton(_ title: String, _ option: PodcastPlayerViewModel.SleepTimer) -> some View {
Button { vm.setSleepTimer(option) } label: {
if vm.sleepTimer == option {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
}
var body: some View {
NavigationStack {
@@ -243,7 +504,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
if vm.player != nil {
if !vm.currentArticleUrl.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle")
@@ -255,6 +516,10 @@ struct PodcastPlayerView: View {
}
.onAppear {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onChange(of: vm.currentArticleUrl) { _, _ in
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onDisappear {
if stopOnDismiss { vm.stop() }
@@ -325,11 +590,84 @@ struct PodcastPlayerView: View {
.foregroundStyle(.tertiary)
}
HStack(spacing: 36) {
Button { vm.skipBackward15() } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
}
Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
}
HStack(spacing: 12) {
Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button {
vm.setPlaybackSpeed(speed)
} label: {
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
if speed == vm.playbackSpeed {
Label(label, systemImage: "checkmark")
} else {
Text(label)
}
}
}
} label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
}
Menu {
sleepButton("Off", .off)
sleepButton("15 Minutes", .minutes(15))
sleepButton("30 Minutes", .minutes(30))
sleepButton("45 Minutes", .minutes(45))
sleepButton("End of Episode", .endOfEpisode)
} label: {
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(sleepActive ? Color.blue : .secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
}
}
HStack(spacing: 28) {
Button {
isPlayed.toggle()
PodcastLibrary.shared.setPlayed(activeUrl, isPlayed)
} label: {
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(isPlayed ? Color.green : .secondary)
}
.buttonStyle(.plain)
.contentTransition(.symbolEffect(.replace))
if let url = URL(string: articleUrl) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
}
}
}
}
}
@@ -344,6 +682,16 @@ struct PodcastPlayerView: View {
.font(.system(size: 14))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
.font(.system(size: 15, weight: .semibold))
}
.buttonStyle(.glassProminent)
.tint(.blue)
.buttonBorderShape(.capsule)
.padding(.top, 4)
}
}
@@ -365,7 +713,7 @@ struct MiniPlayerView: View {
Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
@@ -380,6 +728,10 @@ struct MiniPlayerView: View {
Spacer()
if vm.isGenerating {
ProgressView()
.frame(width: 36, height: 36)
} else {
Button {
vm.togglePlayPause()
} label: {
@@ -389,6 +741,7 @@ struct MiniPlayerView: View {
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
Button {
vm.stop()
@@ -435,3 +788,392 @@ struct MiniPlayerView: View {
return mins > 0 ? "\(mins) min left" : "Almost done"
}
}
// 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 {
let vm: PodcastPlayerViewModel
let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@State private var library = PodcastLibrary.shared
@State private var showFullPlayer = false
@State private var detailEntry: PodcastEntry?
var body: some View {
NavigationStack {
Group {
if library.entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate from bookmarks will appear here.")
)
} else {
List {
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) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.accessibilityLabel("Played")
}
Button {
play(entry)
} label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .leading) {
Button {
setPlayed(entry, !entry.isPlayed)
} label: {
if entry.isPlayed {
Label("Unplayed", systemImage: "circle")
} else {
Label("Played", systemImage: "checkmark.circle")
}
}
.tint(entry.isPlayed ? .gray : .green)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
.contextMenu {
Button {
play(entry)
} label: {
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
}
Button {
detailEntry = entry
} label: {
Label("Episode Details", systemImage: "info.circle")
}
Button {
setPlayed(entry, !entry.isPlayed)
} label: {
if entry.isPlayed {
Label("Mark as Unplayed", systemImage: "circle")
} else {
Label("Mark as Played", systemImage: "checkmark.circle")
}
}
Divider()
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
library.setPlayed(entry.articleUrl, played)
}
private func isCurrent(_ entry: PodcastEntry) -> Bool {
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) {
library.remove(entry.articleUrl)
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
}
}
// MARK: - Episode picker (multiple episodes per bookmark)
struct EpisodePickerView: View {
let bookmark: Bookmark
let vm: PodcastPlayerViewModel
let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@State private var entries: [PodcastEntry] = []
@State private var detailEntry: PodcastEntry?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(entries) { entry in
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
if entry.articleUrl != bookmark.url {
Text(entry.articleUrl)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? bookmark.displayTitle,
claude: claude)
dismiss()
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Episodes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
}
}
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
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

@@ -2,30 +2,22 @@ import SwiftUI
struct SearchView: View {
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var searchText = ""
@State private var useSemanticSearch = false
@State private var semanticResults: [Bookmark]? = nil
@State private var searchTask: Task<Void, Never>?
@State private var browsingBookmark: Bookmark?
var body: some View {
NavigationStack {
List {
ForEach(results) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
onOpen: { browsingBookmark = bookmark }
)
}
}
.listStyle(.plain)
@@ -49,9 +41,33 @@ struct SearchView: View {
ProgressView()
}
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(
url: url,
title: bookmark.displayTitle,
claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator
) {
await viewModel.archive(bookmark)
}
.bookmarkOnscreen(bookmark)
}
}
}
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.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] {

View File

@@ -6,6 +6,8 @@ struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
var body: some View {
NavigationStack {
List {
@@ -15,6 +17,21 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
Section {
TextField("e.g. listen podcast", text: $autoTagText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: autoTagText) { _, new in
PodcastRequests.autoTags = new
.split(whereSeparator: { $0 == " " || $0 == "," })
.map(String.init)
}
} header: {
Text("Auto-Podcast Tags")
} footer: {
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
}
Section("Server") {
Button(role: .destructive) {
dismiss()

View File

@@ -48,32 +48,17 @@ struct TagsView: View {
struct TagBookmarksView: View {
let tag: String
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var browsingBookmark: Bookmark?
var body: some View {
List {
ForEach(filteredBookmarks) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
onOpen: { browsingBookmark = bookmark }
)
}
}
.listStyle(.plain)
@@ -84,6 +69,20 @@ struct TagBookmarksView: View {
ContentUnavailableView("No Bookmarks", systemImage: "tag")
}
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(
url: url,
title: bookmark.displayTitle,
claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator
) {
await viewModel.archive(bookmark)
}
}
}
}
private var filteredBookmarks: [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)
}
}

25
MarksWidget/Info.plist Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>MarksWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.magicive.marks</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
import WidgetKit
import SwiftUI
@main
struct MarksWidgetBundle: WidgetBundle {
var body: some Widget {
RecentBookmarksWidget()
RandomBookmarkWidget()
RecentPodcastsWidget()
}
}

View File

@@ -0,0 +1,98 @@
import WidgetKit
import SwiftUI
struct RandomBookmarkEntry: TimelineEntry {
let date: Date
let bookmark: WidgetBookmark?
static let placeholder = RandomBookmarkEntry(
date: .now,
bookmark: WidgetBookmark(url: "https://example.com", title: "A Saved Article Worth Revisiting", domain: "example.com", faviconUrl: nil)
)
}
struct RandomBookmarkProvider: TimelineProvider {
func placeholder(in context: Context) -> RandomBookmarkEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RandomBookmarkEntry) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
completion(RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RandomBookmarkEntry>) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
let entry = RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement())
let next = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RandomBookmarkWidgetView: View {
let entry: RandomBookmarkEntry
var body: some View {
Group {
if let bookmark = entry.bookmark {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Rediscover")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "sparkles")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
Spacer()
RoundedRectangle(cornerRadius: 7)
.fill(Color.blue.opacity(0.1))
.frame(width: 38, height: 38)
.overlay {
Image(systemName: "bookmark.fill")
.font(.system(size: 15))
.foregroundStyle(.blue)
}
Spacer().frame(height: 10)
Text(bookmark.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
Text(bookmark.domain)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(.marksBookmark(bookmark.url))
} else {
VStack(spacing: 8) {
Image(systemName: "bookmark")
.font(.system(size: 28))
.foregroundStyle(.secondary)
Text("Open Marks to\nload bookmarks.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct RandomBookmarkWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RandomBookmark",
provider: RandomBookmarkProvider()
) { entry in
RandomBookmarkWidgetView(entry: entry)
}
.configurationDisplayName("Rediscover")
.description("A random saved bookmark to revisit.")
.supportedFamilies([.systemSmall])
}
}

View File

@@ -0,0 +1,108 @@
import WidgetKit
import SwiftUI
struct RecentBookmarksEntry: TimelineEntry {
let date: Date
let bookmarks: [WidgetBookmark]
static let placeholder = RecentBookmarksEntry(date: .now, bookmarks: [
WidgetBookmark(url: "https://example.com/a", title: "The Future of AI in 2025", domain: "example.com", faviconUrl: nil),
WidgetBookmark(url: "https://news.com/b", title: "Why Swift Concurrency Matters", domain: "news.com", faviconUrl: nil),
WidgetBookmark(url: "https://blog.dev/c", title: "Building Great iOS Apps", domain: "blog.dev", faviconUrl: nil),
])
}
struct RecentBookmarksProvider: TimelineProvider {
func placeholder(in context: Context) -> RecentBookmarksEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RecentBookmarksEntry) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
completion(RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3))))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentBookmarksEntry>) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
let entry = RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RecentBookmarksWidgetView: View {
let entry: RecentBookmarksEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Recent")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "bookmark.fill")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
.padding(.bottom, 8)
if entry.bookmarks.isEmpty {
Spacer()
Text("Save bookmarks to see them here.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(Array(entry.bookmarks.enumerated()), id: \.element.id) { idx, bookmark in
if idx > 0 { Divider().padding(.vertical, 5) }
Link(destination: .marksBookmark(bookmark.url)) {
WidgetBookmarkRow(bookmark: bookmark)
}
}
Spacer(minLength: 0)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WidgetBookmarkRow: View {
let bookmark: WidgetBookmark
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 5)
.fill(Color(.systemGray5))
.frame(width: 26, height: 26)
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
VStack(alignment: .leading, spacing: 1) {
Text(bookmark.title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(bookmark.domain)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
}
struct RecentBookmarksWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RecentBookmarks",
provider: RecentBookmarksProvider()
) { entry in
RecentBookmarksWidgetView(entry: entry)
}
.configurationDisplayName("Recent Bookmarks")
.description("Your latest saved bookmarks.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,111 @@
import WidgetKit
import SwiftUI
struct RecentPodcastsEntry: TimelineEntry {
let date: Date
let podcasts: [WidgetPodcast]
static let placeholder = RecentPodcastsEntry(date: .now, podcasts: [
WidgetPodcast(articleUrl: "https://example.com/a", title: "The Future of AI", createdAt: .now),
WidgetPodcast(articleUrl: "https://news.com/b", title: "Swift Concurrency Deep Dive", createdAt: .now.addingTimeInterval(-86400)),
WidgetPodcast(articleUrl: "https://blog.dev/c", title: "Building Great iOS Apps", createdAt: .now.addingTimeInterval(-172800)),
])
}
struct RecentPodcastsProvider: TimelineProvider {
func placeholder(in context: Context) -> RecentPodcastsEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RecentPodcastsEntry) -> Void) {
let podcasts = WidgetDataStore.loadPodcasts()
completion(RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3))))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentPodcastsEntry>) -> Void) {
let podcasts = WidgetDataStore.loadPodcasts()
let entry = RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RecentPodcastsWidgetView: View {
let entry: RecentPodcastsEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Podcasts")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "headphones")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
.padding(.bottom, 8)
if entry.podcasts.isEmpty {
Spacer()
Text("Generate podcasts from your bookmarks to see them here.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(Array(entry.podcasts.enumerated()), id: \.element.id) { idx, podcast in
if idx > 0 { Divider().padding(.vertical, 5) }
Link(destination: .marksPodcast(podcast.articleUrl)) {
WidgetPodcastRow(podcast: podcast)
}
}
Spacer(minLength: 0)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WidgetPodcastRow: View {
let podcast: WidgetPodcast
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 5)
.fill(Color.blue.opacity(0.1))
.frame(width: 26, height: 26)
.overlay {
Image(systemName: "waveform")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.blue)
}
VStack(alignment: .leading, spacing: 1) {
Text(podcast.title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(podcast.createdAt, style: .relative)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "play.circle.fill")
.font(.system(size: 18))
.foregroundStyle(.blue.opacity(0.8))
}
}
}
struct RecentPodcastsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RecentPodcasts",
provider: RecentPodcastsProvider()
) { entry in
RecentPodcastsWidgetView(entry: entry)
}
.configurationDisplayName("Recent Podcasts")
.description("Tap to play recently generated podcasts.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import OSLog
#if canImport(WidgetKit)
import WidgetKit
#endif
private let log = Logger(subsystem: "com.magicive.marks", category: "WidgetDataStore")
struct WidgetBookmark: Codable, Identifiable, Sendable {
var id: String { url }
let url: String
let title: String
let domain: String
let faviconUrl: String?
}
struct WidgetPodcast: Codable, Identifiable, Sendable {
var id: String { articleUrl }
let articleUrl: String
let title: String
let createdAt: Date
}
enum WidgetDataStore {
static let groupID = "group.com.magicive.marks"
private static var containerURL: URL? {
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)
if url == nil { log.error("App Group container not found — group ID: \(groupID)") }
return url
}
private static func fileURL(_ name: String) -> URL? {
containerURL?.appendingPathComponent(name)
}
private static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
private static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
// MARK: - Bookmarks
static func saveBookmarks(_ bookmarks: [WidgetBookmark]) {
guard let url = fileURL("widget_bookmarks.json"),
let data = try? encoder.encode(bookmarks) else { return }
try? data.write(to: url, options: .atomic)
reloadTimelines()
}
static func loadBookmarks() -> [WidgetBookmark] {
guard let url = fileURL("widget_bookmarks.json") else {
log.error("loadBookmarks: no container URL")
return []
}
guard let data = try? Data(contentsOf: url) else {
log.warning("loadBookmarks: file not found at \(url.path)")
return []
}
guard let items = try? decoder.decode([WidgetBookmark].self, from: data) else {
log.error("loadBookmarks: decode failed")
return []
}
log.info("loadBookmarks: loaded \(items.count) bookmarks")
return items
}
// MARK: - Podcasts
static func savePodcasts(_ podcasts: [WidgetPodcast]) {
guard let url = fileURL("widget_podcasts.json"),
let data = try? encoder.encode(podcasts) else { return }
try? data.write(to: url, options: .atomic)
reloadTimelines()
}
static func loadPodcasts() -> [WidgetPodcast] {
guard let url = fileURL("widget_podcasts.json"),
let data = try? Data(contentsOf: url),
let items = try? decoder.decode([WidgetPodcast].self, from: data)
else { return [] }
return items
}
private static func reloadTimelines() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}
// MARK: - Deep link URL helpers
extension URL {
static func marksBookmark(_ url: String) -> URL {
var c = URLComponents()
c.scheme = "marks"
c.host = "bookmark"
c.queryItems = [URLQueryItem(name: "url", value: url)]
return c.url ?? URL(string: "marks://bookmark")!
}
static func marksPodcast(_ url: String) -> URL {
var c = URLComponents()
c.scheme = "marks"
c.host = "podcast"
c.queryItems = [URLQueryItem(name: "url", value: url)]
return c.url ?? URL(string: "marks://podcast")!
}
}

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 SwiftUI
import UniformTypeIdentifiers
@objc(ShareViewController)
class ShareViewController: UIViewController {
private let appGroupId = "group.com.magicive.marks"
private let configKey = "serverConfig"
override func viewDidLoad() {
super.viewDidLoad()
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
blur.frame = view.bounds
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(blur)
extractURLAndSave()
extractURL()
}
private func extractURLAndSave() {
private func extractURL() {
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments else {
showResult(success: false, message: "Nothing to save")
present(url: nil)
return
}
@@ -31,99 +29,36 @@ class ShareViewController: UIViewController {
let urlString = typeId == UTType.url.identifier
? ((item as? URL)?.absoluteString ?? (item as? String))
: (item as? String)
DispatchQueue.main.async {
if let url = urlString { self?.save(url: url) }
else { self?.showResult(success: false, message: "No URL found") }
}
DispatchQueue.main.async { self?.present(url: urlString) }
}
return true
}
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) {
guard let groupDefaults = UserDefaults(suiteName: appGroupId),
let data = groupDefaults.data(forKey: configKey),
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
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
/// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
private func present(url: String?) {
let root = ShareView(url: url ?? "") { [weak self] _ in
self?.close()
}
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 let port = config["port"] as? Int { base += ":\(port)" }
if let path = config["path"] as? String, !path.isEmpty { base += path }
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)
if url == nil {
// No URL to work with let the card render its failure state briefly.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() }
}
}
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

@@ -21,10 +21,13 @@ schemes:
targets:
Marks: all
ShareExtension: all
MarksWidget: all
run:
config: Debug
test:
config: Debug
targets:
- MarksTests
profile:
config: Release
analyze:
@@ -32,12 +35,37 @@ schemes:
archive:
config: Release
MarksWidget:
build:
targets:
Marks: all
MarksWidget: all
run:
config: Debug
analyze:
config: Debug
archive:
config: Release
ShareExtension:
build:
targets:
Marks: all
ShareExtension: all
run:
config: Debug
analyze:
config: Debug
archive:
config: Release
targets:
Marks:
type: application
platform: iOS
sources:
- path: Marks
- path: MarksWidget/WidgetDataStore.swift
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks
@@ -46,12 +74,24 @@ targets:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- target: ShareExtension
- target: MarksWidget
- sdk: WidgetKit.framework
- sdk: AppIntents.framework
ShareExtension:
type: app-extension
platform: iOS
sources:
- 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:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension
@@ -59,3 +99,30 @@ targets:
CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements
SKIP_INSTALL: 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:
type: app-extension
platform: iOS
sources:
- path: MarksWidget
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksWidget
INFOPLIST_FILE: MarksWidget/Info.plist
CODE_SIGN_ENTITLEMENTS: MarksWidget/MarksWidget.entitlements
SKIP_INSTALL: YES
dependencies:
- sdk: WidgetKit.framework
- sdk: SwiftUI.framework