Files
linkding-ios/Marks/Views/BookmarksViewModel.swift
Krishna Kumar af3112530e
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s
feat(podcasts): played/unplayed queue, background generation, share-sheet audio
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

318 lines
12 KiB
Swift

import Foundation
import Observation
@Observable
@MainActor
final class BookmarksViewModel {
var bookmarks: [Bookmark] = []
var isLoading = false
var isLoadingMore = false
var error: String?
var searchQuery = ""
var nextPageUrl: String?
var smartCollections: [SmartCollection] = []
var isGeneratingCollections = false
var enrichmentProgress: Double = 0
var unreadFilter = false
private let api: LinkdingAPI
let claude = ClaudeService()
let podcastPlayer = PodcastPlayerViewModel()
let podcastGenerator = PodcastGenerationManager()
private var enrichTask: Task<Void, Never>?
private let cacheFileUrl: URL
init(api: LinkdingAPI, cacheKey: String = "") {
self.api = api
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)"
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
error = nil
do {
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
bookmarks = response.results
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
}
isLoading = false
startEnrichment()
}
func loadMore() async {
guard let next = nextPageUrl, !isLoadingMore else { return }
isLoadingMore = true
do {
let response = try await api.fetchBookmarksFromUrl(next)
bookmarks.append(contentsOf: response.results)
nextPageUrl = response.next
restoreAIData()
let added = response.results
Task { await SpotlightIndexer.index(added) }
} catch {
self.error = error.localizedDescription
}
isLoadingMore = false
}
func search() async {
isLoading = true
do {
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
bookmarks = response.results
nextPageUrl = response.next
restoreAIData()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func toggleUnreadFilter() async {
unreadFilter.toggle()
await load(useCache: false)
}
func semanticSearch() async {
guard !searchQuery.isEmpty else { return }
isLoading = true
do {
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter)
var all = allResponse.results
// Restore AI data so summaries are available for ranking
restoreAIData(into: &all)
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
nextPageUrl = nil
Analytics.track("search.semantic", ["query": searchQuery, "resultCount": bookmarks.count])
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func addBookmark(url: String, title: String, tags: [String]) async throws {
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
let bookmark = try await api.createBookmark(create)
bookmarks.insert(bookmark, at: 0)
Task { await SpotlightIndexer.index([bookmark]) }
Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)")
Task {
Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)")
do {
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
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 {
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 {
Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
self.error = "AI enrichment failed: \(error.localizedDescription)"
}
}
}
func delete(_ bookmark: Bookmark) async {
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
}
}
func archive(_ bookmark: Bookmark) async {
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
}
}
func updateBookmark(_ bookmark: Bookmark) async throws {
let update = BookmarkUpdate(
url: bookmark.url,
title: bookmark.title,
description: bookmark.description,
tagNames: bookmark.tagNames,
unread: bookmark.unread,
shared: bookmark.shared
)
var updated = try await api.updateBookmark(id: bookmark.id, update: update)
if let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) {
updated.aiSummary = bookmarks[i].aiSummary
updated.aiTags = bookmarks[i].aiTags
bookmarks[i] = updated
}
}
func generateSmartCollections() async {
guard !bookmarks.isEmpty else { return }
isGeneratingCollections = true
do {
let allResponse = try await api.fetchBookmarks(limit: 200)
smartCollections = try await claude.generateCollections(from: allResponse.results)
Analytics.track("collections.generated", ["collectionCount": smartCollections.count])
} catch {
self.error = error.localizedDescription
}
isGeneratingCollections = false
}
func enrichAll() async {
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
guard !toEnrich.isEmpty else { return }
enrichmentProgress = 0
for (done, i) in toEnrich.enumerated() {
do {
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i])
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
}
}
let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count
if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) }
enrichmentProgress = 0
}
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
private func startEnrichment() {
enrichTask?.cancel()
enrichTask = Task { [weak self] in
guard let self else { return }
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
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] }
Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
let (summary, tags) = try await claude.enrich(bookmark: bm)
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 {
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
break
}
}
}
}
// MARK: Disk cache
private static let cacheEncoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
private static let cacheDecoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
private func saveToCache(_ list: [Bookmark]) {
guard !unreadFilter else { return } // don't overwrite full cache with filtered results
do {
let data = try Self.cacheEncoder.encode(list)
try data.write(to: cacheFileUrl, options: .atomic)
} catch {
Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
}
}
private func loadFromCache() {
guard bookmarks.isEmpty,
let data = try? Data(contentsOf: cacheFileUrl),
var cached = try? Self.cacheDecoder.decode([Bookmark].self, from: data) else { return }
restoreAIData(into: &cached)
bookmarks = cached
}
// MARK: AI persistence
private func saveAIData(for bookmark: Bookmark) {
var store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
store["\(bookmark.id)"] = [
"summary": bookmark.aiSummary ?? "",
"tags": bookmark.aiTags ?? []
]
UserDefaults.standard.set(store, forKey: "aiData")
}
private func restoreAIData() {
restoreAIData(into: &bookmarks)
}
private func restoreAIData(into list: inout [Bookmark]) {
let store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
for i in list.indices {
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]
}
}
}
}