mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
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.
226 lines
7.6 KiB
Swift
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
|
|
}
|
|
}
|