Files
linkding-ios/openwiki/workflows/product-workflows.md
Krishna Kumar 8e1d94a4ea
All checks were successful
CI / build-and-deploy (push) Successful in 25s
Add source editing to ingest
2026-07-02 16:35:02 -05:00

175 lines
12 KiB
Markdown

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