diff --git a/MealMood/Services/DeduplicationService.swift b/MealMood/Services/DeduplicationService.swift index 2920e07..7600719 100644 --- a/MealMood/Services/DeduplicationService.swift +++ b/MealMood/Services/DeduplicationService.swift @@ -11,10 +11,49 @@ enum DeduplicationService { static func run(context: ModelContext) { dedupeSettings(context: context) dedupeTags(context: context) + dedupeDishes(context: context) dedupeWeekPlans(context: context) try? context.save() } + /// Collapse Dishes that CloudKit mirrored into several objects sharing the + /// same `id` UUID (the cause of the "Duplicate values for key" launch + /// crash). Only ever touches copies that share one id — distinct dishes are + /// never merged. The keeper is chosen **deterministically** (richest copy, + /// then a stable store-identity tiebreak) so every device on the account + /// deletes the exact same losers and the graph converges to a single copy — + /// never to zero. Slot references point at the id, which is unchanged, so + /// no remapping is needed. + private static func dedupeDishes(context: ModelContext) { + let all = (try? context.fetch(FetchDescriptor())) ?? [] + guard all.count > 1 else { return } + + let grouped = Dictionary(grouping: all, by: \.id) + for (_, copies) in grouped where copies.count > 1 { + let keeper = copies.sorted { lhs, rhs in + let lhsRichness = richness(of: lhs) + let rhsRichness = richness(of: rhs) + if lhsRichness != rhsRichness { return lhsRichness > rhsRichness } + return String(describing: lhs.persistentModelID) < String(describing: rhs.persistentModelID) + }.first! + for copy in copies where copy !== keeper { + context.delete(copy) + } + } + } + + /// Rough "how much data does this copy carry" score, used to pick which + /// duplicate to keep so a richer copy never loses to an emptier one. + private static func richness(of dish: Dish) -> Int { + var score = 0 + if dish.photoData != nil { score += 4 } + if !(dish.descriptionText ?? "").isEmpty { score += 2 } + if dish.isPriority { score += 1 } + score += dish.ingredients.count + score += dish.tagIds.count + return score + } + /// Keep a single AppSettings: prefer one with onboarding completed, then /// premium (never drop an entitlement marker), then lowest id. private static func dedupeSettings(context: ModelContext) {