Files
yattee/Yattee/Data/DataManager+Bookmarks.swift
Arkadiusz Fal c7ad4fad2e Make CloudKit apply path scope-aware and re-upload local-newer merges
CloudKit record names carry a source scope suffix so the same channel
or video ID can exist under different sources, but the apply path
matched local entities by bare ID: incoming records could merge into an
entity from a different scope, and a remote deletion of one scope
deleted every local entity with that ID and cascaded scoped deletions
for all other scopes back to CloudKit.

- Match local entities on the full mapper-derived record name when
  merging incoming records, materializing records for upload, and
  applying remote deletions.
- Remote deletions now remove only the matching entity and no longer
  queue CloudKit deletions (no echo, no cross-scope cascade).
- Insert dedupe for CloudKit-applied records is scope-aware, so a
  same-ID record from a different source inserts instead of being
  silently dropped. Remotely added bookmarks also update the
  fast-lookup cache immediately.
- When conflict resolution during a fetch keeps newer local data, the
  merge result is queued for upload; previously the server (and other
  devices) kept the stale version unless the entity was edited again.
  Strict timestamp comparisons prevent re-upload ping-pong between
  devices.
2026-07-15 20:15:03 +02:00

226 lines
7.6 KiB
Swift

//
// 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<Bookmark>(
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<Bookmark>())
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<Bookmark>(
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<Bookmark>(
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<Bookmark>()
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<Bookmark>(
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<Bookmark>(
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<Bookmark>(
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<Bookmark>(
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
}
}