// // DataManager+Bookmarks.swift // Yattee // // Bookmark operations for DataManager. // import Foundation import SwiftData extension DataManager { // MARK: - Bookmarks /// Adds a video to bookmarks. func addBookmark(for video: Video) { // Check if already bookmarked let videoID = video.id.videoID let descriptor = FetchDescriptor( predicate: #Predicate { $0.videoID == videoID } ) do { let existing = try modelContext.fetch(descriptor) guard existing.isEmpty else { return } // Get max sort order let allBookmarks = try modelContext.fetch(FetchDescriptor()) let maxOrder = allBookmarks.map(\.sortOrder).max() ?? -1 let bookmark = Bookmark.from(video: video, sortOrder: maxOrder + 1) modelContext.insert(bookmark) save() // Update cache immediately for fast lookup cachedBookmarkedVideoIDs.insert(videoID) // Queue for CloudKit sync cloudKitSync?.queueBookmarkSave(bookmark) NotificationCenter.default.post(name: .bookmarksDidChange, object: nil) } catch { LoggingService.shared.logCloudKitError("Failed to add bookmark", error: error) } } /// Removes a video from bookmarks. func removeBookmark(for videoID: String) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.videoID == videoID } ) do { let bookmarks = try modelContext.fetch(descriptor) guard !bookmarks.isEmpty else { return } // Capture scopes before deleting let scopes = bookmarks.map { SourceScope.from( sourceRawValue: $0.sourceRawValue, globalProvider: $0.globalProvider, instanceURLString: $0.instanceURLString, externalExtractor: $0.externalExtractor ) } bookmarks.forEach { modelContext.delete($0) } save() // Update cache immediately for fast lookup cachedBookmarkedVideoIDs.remove(videoID) // Queue scoped CloudKit deletions for scope in scopes { cloudKitSync?.queueBookmarkDelete(videoID: videoID, scope: scope) } NotificationCenter.default.post(name: .bookmarksDidChange, object: nil) } catch { LoggingService.shared.logCloudKitError("Failed to remove bookmark", error: error) } } /// Checks if a video is bookmarked using cached Set for O(1) lookup. func isBookmarked(videoID: String) -> Bool { cachedBookmarkedVideoIDs.contains(videoID) } /// Gets all bookmarks, most recent first. func bookmarks(limit: Int = 100) -> [Bookmark] { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.createdAt, order: .reverse)] ) descriptor.fetchLimit = limit do { return try modelContext.fetch(descriptor) } catch { LoggingService.shared.logCloudKitError("Failed to fetch bookmarks", error: error) return [] } } /// Gets the total count of bookmarks. func bookmarksCount() -> Int { let descriptor = FetchDescriptor() do { return try modelContext.fetchCount(descriptor) } catch { return 0 } } /// Gets a bookmark for a specific video ID. func bookmark(for videoID: String) -> Bookmark? { let descriptor = FetchDescriptor( predicate: #Predicate { $0.videoID == videoID } ) return try? modelContext.fetch(descriptor).first } /// Gets all bookmarks matching a video ID. The same ID can exist /// under multiple source scopes; callers that care about a specific /// source must pick the matching entity themselves. func bookmarks(forVideoID videoID: String) -> [Bookmark] { let descriptor = FetchDescriptor( predicate: #Predicate { $0.videoID == videoID } ) return (try? modelContext.fetch(descriptor)) ?? [] } /// Deletes a single bookmark without queueing a CloudKit deletion. /// Used by CloudKitSyncEngine when applying remote deletions. func deleteBookmark(_ bookmark: Bookmark) { let videoID = bookmark.videoID modelContext.delete(bookmark) save() // Keep the fast-lookup cache accurate; another scope may still // have a bookmark with this video ID if self.bookmark(for: videoID) == nil { cachedBookmarkedVideoIDs.remove(videoID) } TopShelfSnapshotWriter.writeBookmarks(dataManager: self) } /// Inserts a bookmark into the database. /// Used by CloudKitSyncEngine for applying remote bookmarks. func insertBookmark(_ bookmark: Bookmark) { // Check for duplicates within the same source scope - the same // video ID can legitimately exist under different sources let videoID = bookmark.videoID let descriptor = FetchDescriptor( predicate: #Predicate { $0.videoID == videoID } ) let scopeSuffix = bookmark.sourceScopeSuffix do { let existing = try modelContext.fetch(descriptor) if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) { modelContext.insert(bookmark) save() cachedBookmarkedVideoIDs.insert(videoID) } } catch { // Insert anyway if we can't check modelContext.insert(bookmark) save() cachedBookmarkedVideoIDs.insert(videoID) } TopShelfSnapshotWriter.writeBookmarks(dataManager: self) } /// Updates bookmark tags and note for a video. func updateBookmark(videoID: String, tags: [String], note: String?) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.videoID == videoID } ) do { guard let bookmark = try modelContext.fetch(descriptor).first else { LoggingService.shared.logCloudKitError("Failed to update bookmark: not found", error: nil) return } let now = Date() // Update tags and timestamp if changed if bookmark.tags != tags { bookmark.tags = tags bookmark.tagsModifiedAt = now } // Update note and timestamp if changed if bookmark.note != note { bookmark.note = note bookmark.noteModifiedAt = now } save() // Queue for CloudKit sync cloudKitSync?.queueBookmarkSave(bookmark) NotificationCenter.default.post(name: .bookmarksDidChange, object: nil) } catch { LoggingService.shared.logCloudKitError("Failed to update bookmark", error: error) } } } // MARK: - Source Scope private extension Bookmark { /// Record-name scope suffix used to distinguish same-ID entities across sources. var sourceScopeSuffix: String { SourceScope.from( sourceRawValue: sourceRawValue, globalProvider: globalProvider, instanceURLString: instanceURLString, externalExtractor: externalExtractor ).recordNameSuffix } }