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>
318 lines
12 KiB
Swift
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]
|
|
}
|
|
}
|
|
}
|
|
}
|