Files
linkding-ios/Marks/Views/BookmarksViewModel.swift
T
Krishna KumarandClaude Opus 5 2e1b454c09
CI / build-and-deploy (pull_request) Successful in 30s
Fix crash: enrichment held array indices across awaits
`startEnrichment` snapshotted `bookmarks.indices`, then awaited a network
call per bookmark. `bookmarks` is replaced wholesale by loads, searches
and the unread filter, so by the time the loop read `bookmarks[i]` the
index could be out of range — EXC_BREAKPOINT in
Array._checkSubscript, straight from the read. The write path already
guarded with `i < bookmarks.count`; the read did not.

`enrichAll()` had the same shape and the same exposure.

Both now track bookmark ids and re-resolve the position after each await,
via a shared `apply(summary:tags:toId:)` that skips a bookmark that is no
longer loaded rather than writing to whatever now sits at that index —
which is the other half of the bug: a stale-but-in-range index would have
silently attached one bookmark's summary to another.

Found while investigating leftover Spotlight "translation error ...
Code=1 (null)" entries after the indexing fix. Those turned out to be a
symptom, not a separate bug: the crash tore the process down mid-donation
and the in-flight items failed to translate. With the crash fixed they
are gone, and the suite went from crashing (0 tests executed) to green
across three consecutive runs.

Adds a source round-trip to SpotlightRetrievalTests, so both donation
paths — app entities and raw searchable items — are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-29 23:46:07 -05:00

360 lines
14 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] = []
/// Every tag name on the server. The loaded bookmark page only ever
/// exposes the tags of the most recent 50, which is not the vocabulary.
var allTags: [String] = []
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, description: String = "", tags: [String]) async throws {
let create = BookmarkCreate(
url: url,
title: title,
description: description,
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
}
}
/// Best-effort: an empty tag list just means the picker falls back to the
/// tags it can see on loaded bookmarks, so a failure here isn't worth an
/// error alert.
func loadAllTags() async {
guard let tags = try? await api.fetchTags() else { return }
allTags = tags
}
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 {
// Track ids, not indices. Each iteration awaits the network, and
// `bookmarks` is replaced wholesale by loads, searches and filter
// toggles — an index captured before the await can be out of range by
// the time it is used.
let toEnrich = bookmarks.filter { $0.aiSummary == nil }.map(\.id)
guard !toEnrich.isEmpty else { return }
enrichmentProgress = 0
var completed = 0
for id in toEnrich {
guard let bookmark = bookmarks.first(where: { $0.id == id }) else { continue }
do {
let (summary, tags) = try await claude.enrich(bookmark: bookmark)
apply(summary: summary, tags: tags, toId: id)
completed += 1
enrichmentProgress = Double(completed) / Double(toEnrich.count)
} catch {
break
}
}
if completed > 0 { Analytics.track("enrich.completed", ["count": completed]) }
enrichmentProgress = 0
}
/// Write an enrichment result back by id, skipping it if the bookmark is no
/// longer loaded (deleted, archived, or filtered away mid-flight).
private func apply(summary: String, tags: [String], toId id: Int) {
guard let i = bookmarks.firstIndex(where: { $0.id == id }) else {
Log.ai.notice("enrich result dropped: id=\(id, privacy: .public) no longer loaded")
return
}
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
}
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
//
// Everything here is keyed by bookmark id rather than array index. This loop
// awaits a network call per bookmark, and `bookmarks` gets replaced under it
// by loads, searches and the unread filter — an index captured up front used
// to crash on the read (`bookmarks[i]`), which the write path already
// guarded against but the read did not.
private func startEnrichment() {
enrichTask?.cancel()
enrichTask = Task { [weak self] in
guard let self else { return }
let ids = await MainActor.run {
bookmarks.filter { $0.aiSummary == nil }.prefix(5).map(\.id)
}
Log.ai.info("startEnrichment: \(ids.count, privacy: .public) to enrich")
for id in ids {
guard !Task.isCancelled else { return }
let claude = await MainActor.run(body: { self.claude })
guard let bm = await MainActor.run(body: {
bookmarks.first { $0.id == id }
}) else { continue }
do {
Log.ai.debug("startEnrichment enriching id=\(id, privacy: .public)")
let (summary, tags) = try await claude.enrich(bookmark: bm)
Log.ai.debug("startEnrichment done id=\(id, privacy: .public)")
await MainActor.run { apply(summary: summary, tags: tags, toId: id) }
} catch {
Log.ai.error("startEnrichment failed id=\(id, 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")
// Mirror by URL so the decoupled Podcasts tab can show summaries.
AISummaryStore.set(bookmark.aiSummary, for: bookmark.url)
}
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]
// Keep the URL-keyed summary cache populated for existing bookmarks.
if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) }
}
}
}
}