import CocoaLumberjackSwift internal enum ReadingListsOperationError: Error { case cancelled } internal class ReadingListsSyncOperation: ReadingListsOperation { var syncedReadingListsCount = 0 var syncedReadingListEntriesCount = 0 override func execute() { syncedReadingListsCount = 0 syncedReadingListEntriesCount = 0 DispatchQueue.main.async { self.dataStore.performBackgroundCoreDataOperation { (moc) in do { try self.executeSync(on: moc) } catch let error { DDLogError("error during sync operation: \(error)") do { if moc.hasChanges { try moc.save() } } catch let error { DDLogError("error during sync save: \(error)") } if let readingListError = error as? APIReadingListError, readingListError == .notSetup { DispatchQueue.main.async { self.readingListsController.setSyncEnabled(false, shouldDeleteLocalLists: false, shouldDeleteRemoteLists: false) self.finish() } } else if let readingListError = error as? APIReadingListError, readingListError == .needsFullSync { let readingListsController = self.readingListsController DispatchQueue.main.async { guard let oldState = readingListsController?.syncState else { return } var newState = oldState newState.insert(.needsSync) if newState != oldState { readingListsController?.syncState = newState } self.finish() DispatchQueue.main.async { readingListsController?.sync() } } } else if let readingListError = error as? ReadingListsOperationError, readingListError == .cancelled { self.apiController.cancelAllTasks() self.finish() } else { self.finish(with: error) } } } } } func executeSync(on moc: NSManagedObjectContext) throws { let syncEndpointsAreAvailable = moc.wmf_isSyncRemotelyEnabled let rawSyncState = moc.wmf_numberValue(forKey: WMFReadingListSyncStateKey)?.int64Value ?? 0 var syncState = ReadingListSyncState(rawValue: rawSyncState) let taskGroup = WMFTaskGroup() let hasValidLocalCredentials = apiController.session.hasValidCentralAuthCookies(for: Configuration.current.wikipediaCookieDomain) if syncEndpointsAreAvailable && syncState.contains(.needsRemoteDisable) && hasValidLocalCredentials { var disableReadingListsError: Error? = nil taskGroup.enter() apiController.teardownReadingLists(completion: { (error) in disableReadingListsError = error taskGroup.leave() }) taskGroup.wait() if let error = disableReadingListsError { try moc.save() self.finish(with: error) return } else { syncState.remove(.needsRemoteDisable) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() } } if syncState.contains(.needsRandomLists) { try executeRandomListPopulation(in: moc) syncState.remove(.needsRandomLists) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() } if syncState.contains(.needsRandomEntries) { try executeRandomArticlePopulation(in: moc, englishOnly: false) syncState.remove(.needsRandomEntries) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() } else if syncState.contains(.needsRandomEnEntries) { try executeRandomArticlePopulation(in: moc, englishOnly: true) syncState.remove(.needsRandomEnEntries) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() } if syncState.contains(.needsLocalReset) { try moc.wmf_batchProcessObjects(handler: { (readingListEntry: ReadingListEntry) in readingListEntry.readingListEntryID = nil readingListEntry.isUpdatedLocally = true readingListEntry.errorCode = nil guard !self.isCancelled else { throw ReadingListsOperationError.cancelled } }) try moc.wmf_batchProcessObjects(handler: { (readingList: ReadingList) in readingList.readingListID = nil readingList.isUpdatedLocally = true readingList.errorCode = nil guard !self.isCancelled else { throw ReadingListsOperationError.cancelled } }) syncState.remove(.needsLocalReset) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() moc.reset() } if syncState.contains(.needsLocalListClear) { try moc.wmf_batchProcess(matchingPredicate: NSPredicate(format: "isDefault != YES"), handler: { (lists: [ReadingList]) in try self.readingListsController.markLocalDeletion(for: lists) guard !self.isCancelled else { throw ReadingListsOperationError.cancelled } }) syncState.remove(.needsLocalListClear) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() } if syncState.contains(.needsLocalArticleClear) { try moc.wmf_batchProcess(matchingPredicate: NSPredicate(format: "savedDate != NULL"), handler: { (articles: [WMFArticle]) in self.readingListsController.unsave(articles, in: moc) guard !self.isCancelled else { throw ReadingListsOperationError.cancelled } }) syncState.remove(.needsLocalArticleClear) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) try moc.save() } let localSyncOnly = { try self.executeLocalOnlySync(on: moc) try moc.save() self.finish() } guard hasValidLocalCredentials else { try localSyncOnly() return } // local only sync guard syncState != [] else { if syncEndpointsAreAvailable { // make an update call to see if the user has enabled sync on another device var updateError: Error? = nil taskGroup.enter() let iso8601String = DateFormatter.wmf_iso8601().string(from: Date()) apiController.updatedListsAndEntries(since: iso8601String, completion: { (lists, entries, since, error) in updateError = error taskGroup.leave() }) taskGroup.wait() if updateError == nil { DispatchQueue.main.async { self.readingListsController.setSyncEnabled(true, shouldDeleteLocalLists: false, shouldDeleteRemoteLists: false) self.readingListsController.postReadingListsServerDidConfirmSyncWasEnabledForAccountNotification(true) self.finish() } } else { if let apiError = updateError as? APIReadingListError, apiError == .notSetup { readingListsController.postReadingListsServerDidConfirmSyncWasEnabledForAccountNotification(false) } try localSyncOnly() } } else { readingListsController.postReadingListsServerDidConfirmSyncWasEnabledForAccountNotification(false) try localSyncOnly() } return } guard syncEndpointsAreAvailable else { self.finish() return } if syncState.contains(.needsRemoteEnable) { var enableReadingListsError: Error? = nil taskGroup.enter() apiController.setupReadingLists(completion: { (error) in enableReadingListsError = error taskGroup.leave() }) taskGroup.wait() if let error = enableReadingListsError { try moc.save() finish(with: error) return } else { syncState.remove(.needsRemoteEnable) moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) self.readingListsController.postReadingListsServerDidConfirmSyncWasEnabledForAccountNotification(true) try moc.save() } } if syncState.contains(.needsSync) { try executeFullSync(on: moc) syncState.remove(.needsSync) syncState.insert(.needsUpdate) } else if syncState.contains(.needsUpdate) { try executeUpdate(on: moc) } moc.wmf_setValue(NSNumber(value: syncState.rawValue), forKey: WMFReadingListSyncStateKey) if moc.hasChanges { try moc.save() } finish() } func executeLocalOnlySync(on moc: NSManagedObjectContext) throws { try moc.wmf_batchProcessObjects(matchingPredicate: NSPredicate(format: "isDeletedLocally == YES"), handler: { (list: ReadingList) in moc.delete(list) }) try moc.wmf_batchProcessObjects(matchingPredicate: NSPredicate(format: "isDeletedLocally == YES"), handler: { (entry: ReadingListEntry) in moc.delete(entry) }) } func executeFullSync(on moc: NSManagedObjectContext) throws { let taskGroup = WMFTaskGroup() var allAPIReadingLists: [APIReadingList] = [] var getAllAPIReadingListsError: Error? var nextSince: String? = nil taskGroup.enter() readingListsController.apiController.getAllReadingLists { (readingLists, since, error) in getAllAPIReadingListsError = error allAPIReadingLists = readingLists nextSince = since taskGroup.leave() } taskGroup.wait() guard !isCancelled else { throw ReadingListsOperationError.cancelled } if let getAllAPIReadingListsError = getAllAPIReadingListsError { throw getAllAPIReadingListsError } let group = WMFTaskGroup() syncedReadingListsCount += try createOrUpdate(remoteReadingLists: allAPIReadingLists, deleteMissingLocalLists: true, inManagedObjectContext: moc) // Get all entries var remoteEntriesByReadingListID: [Int64: [APIReadingListEntry]] = [:] for remoteReadingList in allAPIReadingLists { group.enter() apiController.getAllEntriesForReadingListWithID(readingListID: remoteReadingList.id, completion: { (entries, error) in if let error = error { DDLogError("Error fetching entries for reading list with ID \(remoteReadingList.id): \(error)") } else { remoteEntriesByReadingListID[remoteReadingList.id] = entries } group.leave() }) } group.wait() guard !isCancelled else { throw ReadingListsOperationError.cancelled } for (readingListID, remoteReadingListEntries) in remoteEntriesByReadingListID { syncedReadingListEntriesCount += try createOrUpdate(remoteReadingListEntries: remoteReadingListEntries, for: readingListID, deleteMissingLocalEntries: true, inManagedObjectContext: moc) } if let since = nextSince { moc.wmf_setValue(since as NSString, forKey: WMFReadingListUpdateKey) } if moc.hasChanges { try moc.save() } try processLocalUpdates(in: moc) guard moc.hasChanges else { return } try moc.save() } func executeUpdate(on moc: NSManagedObjectContext) throws { try processLocalUpdates(in: moc) if moc.hasChanges { try moc.save() } guard let since = moc.wmf_stringValue(forKey: WMFReadingListUpdateKey) else { return } var updatedLists: [APIReadingList] = [] var updatedEntries: [APIReadingListEntry] = [] var updateError: Error? var nextSince: String? let taskGroup = WMFTaskGroup() taskGroup.enter() apiController.updatedListsAndEntries(since: since, completion: { (lists, entries, since, error) in updateError = error updatedLists = lists updatedEntries = entries nextSince = since taskGroup.leave() }) taskGroup.wait() guard !isCancelled else { throw ReadingListsOperationError.cancelled } if let error = updateError { throw error } syncedReadingListsCount += try createOrUpdate(remoteReadingLists: updatedLists, inManagedObjectContext: moc) syncedReadingListEntriesCount += try createOrUpdate(remoteReadingListEntries: updatedEntries, inManagedObjectContext: moc) if let since = nextSince { moc.wmf_setValue(since as NSString, forKey: WMFReadingListUpdateKey) } guard moc.hasChanges else { return } try moc.save() } func executeRandomListPopulation(in moc: NSManagedObjectContext) throws { let countOfListsToCreate = moc.wmf_numberValue(forKey: "WMFCountOfListsToCreate")?.int64Value ?? 10 for i in 1...countOfListsToCreate { do { _ = try readingListsController.createReadingList(named: "\(i)", in: moc) } catch { _ = try readingListsController.createReadingList(named: "\(arc4random())", in: moc) } } try moc.save() } func executeRandomArticlePopulation(in moc: NSManagedObjectContext, englishOnly: Bool) throws { let countOfEntriesToCreate = moc.wmf_numberValue(forKey: "WMFCountOfEntriesToCreate")?.int64Value ?? 10 var maybeSiteURL = URL(string: "https://en.wikipedia.org") let randomArticleFetcher = RandomArticleFetcher() let taskGroup = WMFTaskGroup() try moc.wmf_batchProcessObjects { (list: ReadingList) in guard !list.isDefault else { return } do { var summaryResponses: [WMFInMemoryURLKey: ArticleSummary] = [:] for i in 1...countOfEntriesToCreate { if !englishOnly { if let randomLanguage = dataStore.languageLinkController.allLanguages.randomElement() { maybeSiteURL = randomLanguage.siteURL } } guard let siteURL = maybeSiteURL else { continue } taskGroup.enter() randomArticleFetcher.fetchRandomArticle(withSiteURL: siteURL, completion: { (error, result, summary) in if let key = result?.wmf_inMemoryKey, let summary = summary { summaryResponses[key] = summary } taskGroup.leave() }) if i % 16 == 0 || i == countOfEntriesToCreate { taskGroup.wait() guard !isCancelled else { throw ReadingListsOperationError.cancelled } let articlesByKey = try moc.wmf_createOrUpdateArticleSummmaries(withSummaryResponses: summaryResponses) let articles = Array(articlesByKey.values) try readingListsController.add(articles: articles, to: list, in: moc) if let defaultReadingList = moc.defaultReadingList { try readingListsController.add(articles: articles, to: defaultReadingList, in: moc) } try moc.save() summaryResponses.removeAll(keepingCapacity: true) } } } catch let error { DDLogError("error populating lists: \(error)") } } } private func split(readingList: ReadingList, intoReadingListsOfSize size: Int, in moc: NSManagedObjectContext) throws -> [ReadingList] { var newReadingLists: [ReadingList] = [] guard let readingListName = readingList.name else { assertionFailure("Missing reading list name") return newReadingLists } let entries = Array(readingList.entries ?? []) let entriesCount = Int(readingList.countOfEntries) let splitEntriesArrays = stride(from: size, to: entriesCount, by: size).map { Array(entries[$0.. = ReadingList.fetchRequest() listsToCreateOrUpdateFetch.sortDescriptors = [NSSortDescriptor(keyPath: \ReadingList.createdDate, ascending: false)] listsToCreateOrUpdateFetch.predicate = NSPredicate(format: "isUpdatedLocally == YES") let listsToUpdate = try moc.fetch(listsToCreateOrUpdateFetch) var createdReadingLists: [Int64: ReadingList] = [:] var failedReadingLists: [(ReadingList, APIReadingListError)] = [] var updatedReadingLists: [Int64: ReadingList] = [:] var deletedReadingLists: [Int64: ReadingList] = [:] var listsToCreate: [ReadingList] = [] var requestCount = 0 for localReadingList in listsToUpdate { autoreleasepool { guard let readingListName = localReadingList.name else { moc.delete(localReadingList) return } guard let readingListID = localReadingList.readingListID?.int64Value else { if localReadingList.isDeletedLocally || localReadingList.canonicalName == nil { // if it has no id and is deleted locally, it can just be deleted moc.delete(localReadingList) } else if !localReadingList.isDefault { let entryLimit = moc.wmf_readingListsConfigMaxListsPerUser if localReadingList.countOfEntries > entryLimit && !UserDefaults.standard.wmf_didSplitExistingReadingLists() { if let splitReadingLists = try? split(readingList: localReadingList, intoReadingListsOfSize: entryLimit, in: moc) { listsToCreate.append(contentsOf: splitReadingLists) } } else { listsToCreate.append(localReadingList) } } return } if localReadingList.isDeletedLocally { requestCount += 1 taskGroup.enter() self.apiController.deleteList(withListID: readingListID, completion: { (deleteError) in defer { taskGroup.leave() } guard deleteError == nil else { DDLogError("Error deleting reading list: \(String(describing: deleteError))") return } deletedReadingLists[readingListID] = localReadingList }) } else if localReadingList.isUpdatedLocally { requestCount += 1 taskGroup.enter() self.apiController.updateList(withListID: readingListID, name: readingListName, description: localReadingList.readingListDescription, completion: { (updateError) in defer { taskGroup.leave() } guard updateError == nil else { DDLogError("Error updating reading list: \(String(describing: updateError))") return } updatedReadingLists[readingListID] = localReadingList }) } } } let listNamesAndDescriptionsToCreate: [(name: String, description: String?)] = listsToCreate.map { assert($0.canonicalName != nil) return (name: $0.canonicalName ?? "", description: $0.readingListDescription) } var start = 0 var end = 0 while end < listNamesAndDescriptionsToCreate.count { end = min(listNamesAndDescriptionsToCreate.count, start + WMFReadingListBatchSizePerRequestLimit) autoreleasepool { taskGroup.enter() let listsToCreateSubarray = Array(listsToCreate[start.. = ReadingListEntry.fetchRequest() entriesToCreateOrUpdateFetch.predicate = NSPredicate(format: "isUpdatedLocally == YES") entriesToCreateOrUpdateFetch.sortDescriptors = [NSSortDescriptor(keyPath: \ReadingListEntry.createdDate, ascending: false)] let localReadingListEntriesToUpdate = try moc.fetch(entriesToCreateOrUpdateFetch) var deletedReadingListEntries: [Int64: ReadingListEntry] = [:] var entriesToAddByListID: [Int64: [(project: String, title: String, entry: ReadingListEntry)]] = [:] for localReadingListEntry in localReadingListEntriesToUpdate { try autoreleasepool { guard let articleKey = localReadingListEntry.articleKey, let articleURL = URL(string: articleKey), let project = articleURL.wmf_site?.absoluteString, let title = articleURL.wmf_title else { moc.delete(localReadingListEntry) return } guard let readingListID = localReadingListEntry.list?.readingListID?.int64Value else { return } guard let readingListEntryID = localReadingListEntry.readingListEntryID?.int64Value else { if localReadingListEntry.isDeletedLocally { // if it has no id and is deleted locally, it can just be deleted moc.delete(localReadingListEntry) } else { entriesToAddByListID[readingListID, default: []].append((project: project, title: title, entry: localReadingListEntry)) } return } if localReadingListEntry.isDeletedLocally { requestCount += 1 taskGroup.enter() self.apiController.removeEntry(withEntryID: readingListEntryID, fromListWithListID: readingListID, completion: { (deleteError) in defer { taskGroup.leave() } guard deleteError == nil else { DDLogError("Error deleting reading list entry: \(String(describing: deleteError))") return } deletedReadingListEntries[readingListEntryID] = localReadingListEntry }) if requestCount % WMFReadingListBatchSizePerRequestLimit == 0 { taskGroup.wait() for (_, localReadingListEntry) in deletedReadingListEntries { moc.delete(localReadingListEntry) } deletedReadingListEntries.removeAll(keepingCapacity: true) try moc.save() guard !isCancelled else { throw ReadingListsOperationError.cancelled } } } else { // there's no "updating" of an entry currently localReadingListEntry.isUpdatedLocally = false } } } taskGroup.wait() for (_, localReadingListEntry) in deletedReadingListEntries { moc.delete(localReadingListEntry) } try moc.save() guard !isCancelled else { throw ReadingListsOperationError.cancelled } for (readingListID, entries) in entriesToAddByListID { var start = 0 var end = 0 while end < entries.count { end = min(entries.count, start + WMFReadingListBatchSizePerRequestLimit) try autoreleasepool { var createdReadingListEntries: [Int64: ReadingListEntry] = [:] var failedReadingListEntries: [(ReadingListEntry, APIReadingListError)] = [] requestCount += 1 taskGroup.enter() let entrySubarray = Array(entries[start.. URL? { guard let articleURL = remoteEntry.articleURL else { return nil } let preferredLanguageVariantCode = dataStore.languageLinkController.preferredLanguageVariantCode(forLanguageCode: articleURL.wmf_languageCode) var variantAwareArticleURL = articleURL variantAwareArticleURL.wmf_languageVariantCode = preferredLanguageVariantCode return variantAwareArticleURL } // Code using RemoteReadingListArticleKey as a key coordinate between remote and local readling list entries. // This key is the articleKey value which *does not* take language variants into account. // Collections using the WMFInMemoryURLKey *do* take language variants into account. internal func locallyCreate(_ readingListEntries: [APIReadingListEntry], with readingListsByEntryID: [Int64: ReadingList]? = nil, in moc: NSManagedObjectContext) throws { guard !readingListEntries.isEmpty else { return } let summaryFetcher = ArticleFetcher(session: apiController.session, configuration: Configuration.current) let group = WMFTaskGroup() let semaphore = DispatchSemaphore(value: 1) var remoteEntriesToCreateLocallyByArticleKey: [WMFInMemoryURLKey: APIReadingListEntry] = [:] var requestedArticleKeys: Set = [] var articleSummariesByArticleKey: [WMFInMemoryURLKey: ArticleSummary] = [:] var entryCount = 0 var articlesByKey: [WMFInMemoryURLKey: WMFArticle] = [:] for remoteEntry in readingListEntries { autoreleasepool { let isDeleted = remoteEntry.deleted ?? false guard !isDeleted else { return } guard let variantAwareArticleURL = variantAwareURLForRemoteEntry(remoteEntry), let variantAwareArticleKey = variantAwareArticleURL.wmf_inMemoryKey, let remoteArticleKey = remoteEntry.articleKey else { return } remoteEntriesToCreateLocallyByArticleKey[variantAwareArticleKey] = remoteEntry guard !requestedArticleKeys.contains(remoteArticleKey) else { return } requestedArticleKeys.insert(remoteArticleKey) if let article = dataStore.fetchArticle(with: variantAwareArticleURL, in: moc) { articlesByKey[variantAwareArticleKey] = article } else { group.enter() summaryFetcher.fetchSummaryForArticle(with: variantAwareArticleKey, completion: { (result, response, error) in guard let result = result else { group.leave() return } semaphore.wait() articleSummariesByArticleKey[variantAwareArticleKey] = result semaphore.signal() group.leave() }) entryCount += 1 } } } group.wait() guard !isCancelled else { throw ReadingListsOperationError.cancelled } let updatedArticlesByKey = try moc.wmf_createOrUpdateArticleSummmaries(withSummaryResponses: articleSummariesByArticleKey) articlesByKey.merge(updatedArticlesByKey, uniquingKeysWith: { (a, b) in return a }) var finalReadingListsByEntryID: [Int64: ReadingList] if let readingListsByEntryID = readingListsByEntryID { finalReadingListsByEntryID = readingListsByEntryID } else { finalReadingListsByEntryID = [:] var readingListsByReadingListID: [Int64: ReadingList] = [:] let localReadingListsFetch: NSFetchRequest = ReadingList.fetchRequest() localReadingListsFetch.predicate = NSPredicate(format: "readingListID IN %@", readingListEntries.compactMap { $0.listId }) let localReadingLists = try moc.fetch(localReadingListsFetch) for localReadingList in localReadingLists { guard let localReadingListID = localReadingList.readingListID?.int64Value else { continue } readingListsByReadingListID[localReadingListID] = localReadingList } for readingListEntry in readingListEntries { guard let listId = readingListEntry.listId, let readingList = readingListsByReadingListID[listId] else { DDLogError("Missing list for reading list entry: \(readingListEntry)") throw APIReadingListError.needsFullSync } finalReadingListsByEntryID[readingListEntry.id] = readingList } } var updatedLists: Set = [] for (variantAwareArticleKey, remoteEntry) in remoteEntriesToCreateLocallyByArticleKey { autoreleasepool { guard let readingList = finalReadingListsByEntryID[remoteEntry.id] else { return } var fetchedArticle = articlesByKey[variantAwareArticleKey] if fetchedArticle == nil { if let newArticle = dataStore.fetchArticle(withKey: variantAwareArticleKey.databaseKey, variant: variantAwareArticleKey.languageVariantCode, in: moc) { if newArticle.displayTitleHTML == "" { newArticle.displayTitleHTML = remoteEntry.title } fetchedArticle = newArticle } } guard let article = fetchedArticle else { return } var entry = ReadingListEntry(context: moc) entry.update(with: remoteEntry) // if there's a key mismatch, locally delete the bad entry and create a new one with the correct key // Note the remote entry has no variant info, so its *articleKey* should match the article *key* property. // Comparing inMemoryKey values leads to a mismatch since the article will potentially have variant info if remoteEntry.articleKey != article.key { entry.list = readingList entry.articleKey = remoteEntry.articleKey try? readingListsController.markLocalDeletion(for: [entry]) entry = ReadingListEntry(context: moc) entry.update(with: remoteEntry) entry.readingListEntryID = nil entry.isUpdatedLocally = true } if entry.createdDate == nil { entry.createdDate = NSDate() } if entry.updatedDate == nil { entry.updatedDate = entry.createdDate } entry.list = readingList entry.articleKey = article.key entry.variant = article.variant entry.displayTitle = article.displayTitle if article.savedDate == nil { article.savedDate = entry.createdDate as Date? } updatedLists.insert(readingList) } } for readingList in updatedLists { try autoreleasepool { try readingList.updateArticlesAndEntries() } } } internal func createOrUpdate(remoteReadingLists: [APIReadingList], deleteMissingLocalLists: Bool = false, inManagedObjectContext moc: NSManagedObjectContext) throws -> Int { guard !remoteReadingLists.isEmpty || deleteMissingLocalLists else { return 0 } var createdOrUpdatedReadingListsCount = 0 // Arrange remote lists by ID and name for merging with local lists var remoteReadingListsByID: [Int64: APIReadingList] = [:] var remoteReadingListsByName: [String: [Int64: APIReadingList]] = [:] // server still allows multiple lists with the same name var remoteDefaultReadingList: APIReadingList? = nil for remoteReadingList in remoteReadingLists { if remoteReadingList.isDefault { remoteDefaultReadingList = remoteReadingList } remoteReadingListsByID[remoteReadingList.id] = remoteReadingList remoteReadingListsByName[remoteReadingList.name.precomposedStringWithCanonicalMapping, default: [:]][remoteReadingList.id] = remoteReadingList } if let remoteDefaultReadingList = remoteDefaultReadingList { let defaultReadingList = moc.defaultReadingList defaultReadingList?.readingListID = NSNumber(value: remoteDefaultReadingList.id) } let localReadingListsFetch: NSFetchRequest = ReadingList.fetchRequest() let localReadingLists = try moc.fetch(localReadingListsFetch) var localListsMissingRemotely: [ReadingList] = [] for localReadingList in localReadingLists { var remoteReadingList: APIReadingList? if let localReadingListID = localReadingList.readingListID?.int64Value { // remove from the dictionary because we will create any lists left in the dictionary if let remoteReadingListByID = remoteReadingListsByID.removeValue(forKey: localReadingListID) { remoteReadingListsByName[remoteReadingListByID.name]?.removeValue(forKey: remoteReadingListByID.id) remoteReadingList = remoteReadingListByID } } if remoteReadingList == nil { if localReadingList.readingListID == nil, let localReadingListName = localReadingList.name?.precomposedStringWithCanonicalMapping { if let remoteReadingListByName = remoteReadingListsByName[localReadingListName]?.values.first { // remove from the dictionary because we will create any lists left in this dictionary remoteReadingListsByID.removeValue(forKey: remoteReadingListByName.id) remoteReadingList = remoteReadingListByName } } } guard let remoteReadingListForUpdate = remoteReadingList else { if localReadingList.readingListID != nil { localListsMissingRemotely.append(localReadingList) } continue } let isDeleted = remoteReadingListForUpdate.deleted ?? false if isDeleted { try readingListsController.markLocalDeletion(for: [localReadingList]) moc.delete(localReadingList) // object can be removed since we have the server-side update createdOrUpdatedReadingListsCount += 1 } else { localReadingList.update(with: remoteReadingListForUpdate) createdOrUpdatedReadingListsCount += 1 } } if deleteMissingLocalLists { // Delete missing reading lists try readingListsController.markLocalDeletion(for: localListsMissingRemotely) for readingList in localListsMissingRemotely { moc.delete(readingList) createdOrUpdatedReadingListsCount += 1 } } // create any list that wasn't matched by ID or name for (_, remoteReadingList) in remoteReadingListsByID { let isDeleted = remoteReadingList.deleted ?? false guard !isDeleted else { continue } guard let localList = NSEntityDescription.insertNewObject(forEntityName: "ReadingList", into: moc) as? ReadingList else { continue } localList.update(with: remoteReadingList) if localList.createdDate == nil { localList.createdDate = NSDate() } if localList.updatedDate == nil { localList.updatedDate = localList.createdDate } createdOrUpdatedReadingListsCount += 1 } return createdOrUpdatedReadingListsCount } // This method uses RemoteReadingListArticleKey as a key to coordinate between remote and local readling list entries. // This key is the articleKey value which *does not* take language variants into account. // Since APIReadingListEntry does not support language variants, this is the desired behavior. internal func createOrUpdate(remoteReadingListEntries: [APIReadingListEntry], for readingListID: Int64? = nil, deleteMissingLocalEntries: Bool = false, inManagedObjectContext moc: NSManagedObjectContext) throws -> Int { guard !remoteReadingListEntries.isEmpty || deleteMissingLocalEntries else { return 0 } var createdOrUpdatedReadingListEntriesCount = 0 // Arrange remote list entries by ID and key for merging with local lists var remoteReadingListEntriesByReadingListID: [Int64: [RemoteReadingListArticleKey: APIReadingListEntry]] = [:] if let readingListID = readingListID { remoteReadingListEntriesByReadingListID[readingListID] = [:] } for remoteReadingListEntry in remoteReadingListEntries { guard let listID = remoteReadingListEntry.listId ?? readingListID, let articleKey = remoteReadingListEntry.articleKey else { DDLogError("missing id or article key for remote entry: \(remoteReadingListEntry)") assert(false) continue } remoteReadingListEntriesByReadingListID[listID, default: [:]][articleKey] = remoteReadingListEntry } var entriesToDelete: [ReadingListEntry] = [] for (readingListID, readingListEntriesByKey) in remoteReadingListEntriesByReadingListID { try autoreleasepool { let localReadingListsFetch: NSFetchRequest = ReadingList.fetchRequest() localReadingListsFetch.predicate = NSPredicate(format: "readingListID == %@", NSNumber(value: readingListID)) let localReadingLists = try moc.fetch(localReadingListsFetch) let localReadingListEntries = localReadingLists.first?.entries?.filter { !$0.isDeletedLocally } ?? [] var localEntriesMissingRemotely: [ReadingListEntry] = [] var remoteEntriesMissingLocally: [RemoteReadingListArticleKey: APIReadingListEntry] = readingListEntriesByKey for localReadingListEntry in localReadingListEntries { guard let articleKey = localReadingListEntry.articleKey else { moc.delete(localReadingListEntry) createdOrUpdatedReadingListEntriesCount += 1 continue } guard let remoteReadingListEntryForUpdate: APIReadingListEntry = remoteEntriesMissingLocally.removeValue(forKey: articleKey) else { if localReadingListEntry.readingListEntryID != nil { localEntriesMissingRemotely.append(localReadingListEntry) } continue } let isDeleted = remoteReadingListEntryForUpdate.deleted ?? false if isDeleted { entriesToDelete.append(localReadingListEntry) createdOrUpdatedReadingListEntriesCount += 1 } else { localReadingListEntry.update(with: remoteReadingListEntryForUpdate) createdOrUpdatedReadingListEntriesCount += 1 } } if deleteMissingLocalEntries { entriesToDelete.append(contentsOf: localEntriesMissingRemotely) } try readingListsController.markLocalDeletion(for: entriesToDelete) for entry in entriesToDelete { moc.delete(entry) createdOrUpdatedReadingListEntriesCount += 1 } try moc.save() moc.reset() // create any entry that wasn't matched var start = 0 var end = 0 let entries = Array(remoteEntriesMissingLocally.values) while end < entries.count { end = min(entries.count, start + WMFReadingListCoreDataBatchSize) try locallyCreate(Array(entries[start..