12 KiB
Product Workflows
This page describes the user-facing behavior implemented by the app and the source files that own each flow.
Browse bookmarks
Primary sources: /Marks/Views/BookmarksView.swift, /Marks/Views/BookmarkListRow.swift, /Marks/Views/BookmarksViewModel.swift, /Marks/Services/LinkdingAPI.swift.
The Bookmarks tab loads linkding bookmarks through BookmarksViewModel.load(). The view supports loading skeletons, pull-to-refresh, pagination as the user scrolls, an unread filter, and row actions.
A bookmark row displays title/domain/date context, unread state, available excerpt/AI summary, tags/AI tags, reading progress, and podcast-cache state. Tapping opens BrowserView. Swipe/context actions support archive, delete, edit, Safari open, and podcast conversion.
Change guidance:
- Keep row actions aligned across
BookmarksView,BookmarkListRow,BookmarkRow, andBrowserView. BookmarksViewModel.saveToCache(_:)intentionally skips saving when the unread filter is active, so filtered results do not replace the full cache.- Pagination uses linkding's
nextURL throughfetchBookmarksFromUrl(_:); do not rebuild next URLs unless the linkding API contract changes.
Add and edit bookmarks in app
Primary sources: /Marks/Views/AddBookmarkView.swift, /Marks/Views/EditBookmarkView.swift, /Marks/Views/BookmarksViewModel.swift, /Marks/Models/Bookmark.swift.
The Add view collects URL, optional title, description/notes, and comma-separated tags, normalizes/validates the URL, and calls BookmarksViewModel.addBookmark(url:title:description:tags:). It also has a paste/import text path that uses IngestPayloadParser to extract the first http/https URL from plain text and preserve surrounding notes as the bookmark description. Added bookmarks are inserted locally, indexed into Spotlight, and enriched in a background task.
The Edit view updates URL, title, description, tags, unread, and shared state through BookmarkUpdate. BookmarksViewModel.updateBookmark(_:) preserves locally stored AI summary/tags when replacing the bookmark returned from linkding.
Search and semantic search
Primary sources: /Marks/Views/SearchView.swift, /Marks/Views/BookmarksViewModel.swift, /Marks/Services/ClaudeService.swift.
The Search tab has two paths:
- normal search through linkding's bookmark API (
qparameter), - semantic search through
ClaudeService.semanticSearch(query:in:), which fetches up to 200 bookmarks and sends a compact list to the backend for ranking.
SearchMarksIntent routes spoken/system search text into IntentRouter, MainContainer switches to the Search tab, and SearchView consumes the router text into its visible search field.
Change guidance:
- Search state lives on the shared
BookmarksViewModel; search and unread filters can affect list state outside Search. - Backend semantic search sends bookmark titles/domains and optional summaries to the Marks backend. Preserve that privacy boundary in UI copy and docs.
Tags
Primary sources: /Marks/Views/TagsView.swift, /Marks/Models/Bookmark.swift.
The Tags tab derives tag counts from both linkding tags (Bookmark.tagNames) and local AI tags (Bookmark.aiTags), sorted by count/name. Tapping a tag shows matching bookmarks and reuses bookmark row/browser flows.
If changing tag semantics, check in-app Add/Edit, share extension tag parsing, linkding fetchTags(), AI suggestions, Tags tab counts, and App Intents bookmark entity fields.
AI enrichment and smart collections
Primary sources: /Marks/Services/ClaudeService.swift, /Marks/Views/BookmarksViewModel.swift, /Marks/Views/CollectionsView.swift, /Marks/Services/AISummaryStore.swift.
AI enrichment generates a short summary and tags for a bookmark. The app stores AI metadata locally in UserDefaults.standard under aiData, keyed by bookmark ID, and mirrors summaries by URL through AISummaryStore for podcast/list display.
Enrichment can happen in three ways:
- after adding a bookmark,
- as a silent background pass over up to five loaded bookmarks without summaries,
- manually through Enrich All.
Smart Collections send up to 150 bookmarks to the backend and render the returned collection names, descriptions, and bookmark IDs in CollectionsView.
Caveat: README language mentions OpenRouter/Gemini directly, but current source uses the Marks backend proxy for these calls.
Ask your bookmarks
Primary sources: /Marks/Views/AskView.swift, /Marks/Services/BookmarkAssistant.swift, /Marks/Services/BookmarkSearchTool.swift, /Marks/Services/SpotlightBookmarkSearch.swift, /Marks/Services/SpotlightIndexer.swift.
Ask is an on-device RAG workflow. BookmarkAssistant creates a FoundationModels language model session with a bookmark search tool. The model instructions say bookmarks are the only source of truth and require the tool for lookup.
The retrieval corpus comes from Spotlight. BookmarksViewModel indexes bookmarks after loading, loading more, adding, and enrichment. Deleted/archived bookmarks are removed from Spotlight.
Change guidance:
- If Ask gives stale answers, inspect Spotlight indexing and removal paths first.
- If changing indexed fields, update
BookmarkEntity,SpotlightIndexer, search tooling, and tests where relevant. - The Ask path is separate from backend semantic search and does not use
ClaudeService.
Share extension save flow
Primary sources: /Marks/Models/IngestPayload.swift, /ShareExtension/ShareViewController.swift, /ShareExtension/ShareView.swift, /ShareExtension/TagSuggester.swift, /Marks/Services/PodcastRequests.swift, /project.yml.
The share extension scans URL, HTML, RTF, plain-text, and text attachments, then IngestPayloadParser extracts the first bookmarkable web URL. For non-URL text payloads it also preserves cleaned surrounding text as notes. On load it:
- reads linkding config from app-group defaults,
- calls
LinkdingAPI.checkBookmark(url:), - pre-fills existing bookmark data or scraped metadata/linkding auto-tags,
- fetches existing tag vocabulary and backend AI tag suggestions,
- lets the user edit tags, notes, read-later state, and Create Podcast.
On save, existing bookmarks are patched; new bookmarks are created. If Create Podcast is enabled, or if configured auto-tags match, the extension enqueues a PodcastRequests item instead of generating audio itself.
Change guidance:
- The extension is short-lived. Keep long-running work in the main app.
- The extension depends on shared source files listed in
/project.yml; adding service dependencies may require target membership updates. IngestPayloadParserintentionally ignores non-HTTP(S) URLs and caps preserved notes at 1,800 characters.ShareExtension/Info.plistcurrently uses a broad activation rule. Narrowing supported payloads is a product decision.
Sources
Primary sources: /Marks/Views/SourcesView.swift, /Marks/Models/IngestedSource.swift, /Marks/Services/IngestedSourceStore.swift, /Marks/Services/SourceSpotlightIndexer.swift.
The Sources tab stores non-web material in Marks. Users can:
- import typed/pasted text with an optional title and tags,
- import PDFs, plain text files, generic text/data files through the document picker,
- search across source title, body text, tags, and original filename,
- open imported originals through QuickLook,
- delete sources and copied files,
- create or play a podcast from extractable source text.
IngestedSourceLibrary.load() reads Sources/sources.json from Application Support and indexes sources into Spotlight. Text imports write metadata/body text directly. File imports copy the selected security-scoped file into Sources/files/; PDFs extract text with PDFKit, while other files attempt UTF-8/ASCII extraction.
Change guidance:
- Empty or image-only PDFs may import with no extractable text; podcast actions are disabled when
podcastTextis empty. - Source podcasts use
marks-source://{uuid}as the cache/index URL and send extracted text plussource_kindto the backend. - If changing source storage, update deletion, Spotlight removal, QuickLook file lookup, and source podcast cache behavior together.
Podcasts
Primary sources: /Marks/Views/PodcastPlayerView.swift, /Marks/Services/PodcastGenerationManager.swift, /Marks/Services/PodcastIndex.swift, /Marks/Services/PodcastLibrary.swift, /Marks/Services/ClaudeService.swift, /Marks/Services/PodcastRequests.swift, /Marks/Info.plist.
Podcast generation converts an article URL, or a locally ingested source with extracted text, into audio through the Marks backend:
- create generation job (
/v1/podcast/generate), - poll status every few seconds,
- download audio when done,
- save MP3 under Application Support
podcasts/, - upsert
PodcastIndex, - reload
PodcastLibrary, - write recent podcast metadata for widgets.
BookmarksViewModel.playOrGeneratePodcast(...) prevents interrupting current bookmark playback. If the player is idle, it starts foreground generation/playback and callers present the player. If busy, it enqueues background generation through PodcastGenerationManager. SourcesView follows the same foreground/background pattern for local sources, using marks-source://{uuid} plus sourceText/sourceKind.
The player uses AVFoundation/MediaPlayer for playback, background audio, progress, saved playback speed, restored paused session, queue playback, now-playing metadata, remote controls, sleep timer, and played/unplayed state.
Change guidance:
- The main app plist enables background audio and the custom
marksURL scheme. - Widget podcast metadata is in the app group, but audio files and
PodcastIndexare in the main app's Application Support directory. - Share-extension queued podcast requests are drained by
BookmarksViewModel.processPendingPodcastRequests()on launch/foreground. Consider retry semantics before changing drain behavior. - Source-backed podcast generation depends on backend support for the optional
textandsource_kindfields inPOST /v1/podcast/generate.
Widgets and deep links
Primary sources: /MarksWidget/WidgetDataStore.swift, /MarksWidget/RecentBookmarksWidget.swift, /MarksWidget/RandomBookmarkWidget.swift, /MarksWidget/RecentPodcastsWidget.swift, /Marks/MarksApp.swift.
Widgets are offline readers of app-group JSON data:
- Recent Bookmarks shows up to three recent bookmarks and links rows to
marks://bookmark. - Random Bookmark chooses a random cached bookmark and links the whole widget to
marks://bookmark. - Recent Podcasts shows up to three podcasts and links rows to
marks://podcast.
The main app handles bookmark links by presenting BrowserView; podcast links start playback and show PodcastPlayerView.
App Intents, Siri, and Shortcuts
Primary sources: /Marks/Intents/MarksAppIntents.swift, /Marks/Intents/BookmarkEntity.swift, /Marks/Intents/IntentSupport.swift, /Marks/Intents/BookmarkOnscreen.swift, /MarksTests/AppIntentsTests.swift.
Implemented intents:
- Open Bookmark — routes a selected entity URL into the app browser.
- Search Marks — system search schema; opens Search tab with criteria text.
- Add Bookmark — headless linkding save and widget refresh.
- Show Unread Bookmarks — fetches up to ten unread items.
- Summarize Bookmark — backend AI summary for a selected bookmark.
BookmarkEntity maps linkding bookmarks into App Intents entities. Browser presentations annotate current bookmarks for on-screen entity references. Tests currently cover entity mapping, router behavior, direct no-network open/search intent perform calls, and shortcut registration.