0

The app I'm working on aims to show its users one unique Item per day. The Item has an associated ItemText and ItemImage. All Items are created when the user installs the app (see the create() function below).

The app uses SwiftData for persistence and should sync between user devices using iCloud.

Currently, when viewing past Items, there are multiple ones per day - with different content (image and text).

Is it possible to enfore one-item-per-day in iCloud and if so, how? Alternatively, how do I deduplicate the returned CKRecords before showing them in the UI? I have created such a function in a separate CloudKitService class, but am not sure where to execute it automatically.

Ideally, I'd like to retain the automagic link between SwiftData and CloudKit, but at the same time make sure there are no duplicates.

@Model class Item { var id: String = "" // will be set to the YYYYMMDD representation of the date var date: Date = Date.now var favourite: Bool = false @Relationship(deleteRule: .nullify) var text: ItemText? @Relationship(deleteRule: .cascade) var image: ItemImage? init(id: String, date: Date) { self.id = id self.date = date self.favourite = false } } @Model class ItemText: Codable { var id: Int = 0 @Attribute(.spotlight) var title: String = "" var summary: String = "" var link: String = "" @Relationship(deleteRule: .cascade, inverse: \Item.text) var items: [Item]? = [] init(id: Int, title: String, summary: String, link: String) { self.id = id self.title = title self.summary = summary self.link = link } } @Model class ItemImage: Codable { var id: String = "" var url: String = "" var thumbURL: String = "" var widgetImageURL: String = "" var user: String = "" var username: String = "" var colour: String = "" var blurhash: String = "" @Attribute(.externalStorage) var imageData: Data? @Attribute(.externalStorage) var thumbData: Data? @Attribute(.externalStorage) var widgetImageData: Data? @Relationship(deleteRule: .nullify, inverse: \Item.image) var items: [Item]? = [] public init(id: String, url: String, thumbURL: String, widgetImageURL: String, user: String, username: String, colour: String, blurhash: String) { self.id = id self.url = url self.thumbURL = thumbURL self.widgetImageURL = widgetImageURL self.user = user self.username = username self.colour = colour self.blurhash = blurhash } } @ModelActor actor ItemService { private var context: ModelContext { modelExecutor.modelContext } private let cal = Calendar.current private let imageParams = "&fm=avif&h=\(Int(SGConvenience.deviceHeight) * 3)" private let thumbParams = "&fm=avif&w=200" private let widgetParams = "&fm=jpg&q=80&w=800" private func create(texts: [ItemText], images: [ItemImage], startDate: Date) { guard images.count >= texts.count else { print("Not enough images: [\(images.count)], texts: [\(texts.count)]") return } /// Create objects and insert for (index, text) in texts.enumerated() { let date = cal.date(byAdding: .day, value: index, to: startDate) ?? .now let id = date.yearMonthDay let image = images[index] let newText = ItemText(id: text.id, title: text.title, summary: text.summary, link: text.link) context.insert(newText) let newItem = Item(id: id, date: date) newText.items?.append(newItem) let newImage = ItemImage( id: image.id, url: image.url + imageParams, thumbURL: image.url + thumbParams, widgetImageURL: image.url + widgetParams, user: image.user, username: image.username, colour: image.colour, blurhash: image.blurhash ) context.insert(newImage) newImage.items?.append(newItem) } try? context.save() } } 
5
  • This should be resolved on the client (SwiftData) side and not the server side (iCloud) in my opinion. Commented May 7 at 12:54
  • Would you care to give me a hint as to how, please? Commented May 7 at 16:46
  • It depends on how your app currently works but when the user adds a new Item (or ItemText) object you should first check if one already exists and if so load that one instead of creating a new object Commented May 7 at 17:24
  • As I mentioned, all items are created in advance when the app is installed. There is no user interaction, nor should there be any reason why "new" items are created daily. This is why I'm scrathing my head to understand where the duplicates are comng from – they started appearing only after adding CloudKit. Commented May 8 at 6:05
  • Ok then I misunderstood you. Maybe it has something to do with your id properties, any strange values or anything in the logs? Commented May 8 at 20:25

1 Answer 1

1

If I understand your question, then I ran across this recently by trying to have a singleton UserPreferences across all devices. But when a new device comes online, it isn't synced, so it creates the UserPreferences and then syncs which uploads the new one and downloads the old one and I end up with two preferences.

AFAIK, this is just how CloudKit works and must be worked around with deduplication.

I'm not sure how you're fetching your data for display in the UI, but if you're just grabbing all Items and displaying them, you could put a wrapper around that which does the deduplication.

First is to add a deduplicated: Bool to the Item schema.

Then, assuming you have items: [Item] somewhere (in CoreData this would be the FetchResults) you can add the wrapper like so...

var deduplicatedItems: [Item] { // Select only non-deduplicated items let currentItems = items.filter { !$0.deduplicated } // Group all of the items by day return Dictionary(grouping: currentItems) { $0.date.startOfDay } // Reduce into a single list of items .reduce(into: []) { result, dailyItems in // let date = dailyItems.key // <- if you need it for deduplication let allItemsForDay: [Item] = dailyItems.value result.append(deduplicate(allItemsForDay)) } } func deduplicate(items: [Item]) -> Item { // You have a lot of options here and it's up to what your intent is. // Most simple is to compare `item.date` and take the earlier one. // More complicated, you can combine certain properties depending on conditions // but it's really up to your use case. let finalItem: Item = ... // Mark all other items as deduplicated // This is an unoptimized way to do this. // Probably better to do the deduplication setting while you're // calculating/generating/fetching `finalItem` above. items.forEach { if $0 != finalItem { $0.deduplicated = true }} // This is going to save the context every time which could be costly. // Better to put it outside of this function. However, for clarity, I didn't // want it to get missed. context.save() return finalItem } 

Doing full deletion on the deduplicated items can be tricky because you don't want to be too agressive or you'll run into issues where one client is adding something that another client is deleting. I can't remember where I read about this but it was somewhere in the official apple docs (probably a WWDC presentation). If I run across it again, I'll try to remember to link it here.

The suggestion was to "at some point in the future" go through and clean up deduplicated data.

Then, in the UI, instead of iterating over items, you'd iterate over deduplicatedItems.

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.