deep-linking-sample/Apps/Wikipedia/WMF Framework/ReadingListsSyncOperation.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

1077 lines
50 KiB
Swift

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..<min($0 + size, entriesCount)])
}
for (index, splitEntries) in splitEntriesArrays.enumerated() {
guard let newReadingList = NSEntityDescription.insertNewObject(forEntityName: "ReadingList", into: moc) as? ReadingList else {
continue
}
let splitIndex = index + 1
var newReadingListName = "\(readingListName)_\(splitIndex)"
while try readingListsController.listExists(with: newReadingListName, in: moc) {
newReadingListName = "\(newReadingListName)_\(splitIndex)"
}
newReadingList.name = newReadingListName
newReadingList.createdDate = NSDate()
newReadingList.updatedDate = newReadingList.createdDate
newReadingList.isUpdatedLocally = true
for splitEntry in splitEntries {
splitEntry.list = newReadingList
splitEntry.isUpdatedLocally = true
}
readingList.isUpdatedLocally = true
try readingList.updateArticlesAndEntries()
try newReadingList.updateArticlesAndEntries()
newReadingLists.append(newReadingList)
}
newReadingLists.append(readingList)
if !newReadingLists.isEmpty {
let userInfo = [ReadingListsController.readingListsWereSplitNotificationEntryLimitKey: size]
NotificationCenter.default.post(name: ReadingListsController.readingListsWereSplitNotification, object: nil, userInfo: userInfo)
UserDefaults.standard.wmf_setDidSplitExistingReadingLists(true)
}
return newReadingLists
}
func processLocalUpdates(in moc: NSManagedObjectContext) throws {
let taskGroup = WMFTaskGroup()
let listsToCreateOrUpdateFetch: NSFetchRequest<ReadingList> = 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..<end])
let namesAndDescriptionsSubarray = Array(listNamesAndDescriptionsToCreate[start..<end])
self.apiController.createLists(namesAndDescriptionsSubarray, completion: { (readingListIDs, createError) in
defer {
taskGroup.leave()
}
guard let readingListIDs = readingListIDs else {
DDLogError("Error creating reading list: \(String(describing: createError))")
if let apiError = createError as? APIReadingListError {
for list in listsToCreateSubarray {
failedReadingLists.append((list, apiError))
}
}
return
}
for (index, readingList) in readingListIDs.enumerated() {
guard index < listsToCreateSubarray.count else {
break
}
let localReadingList = listsToCreateSubarray[index]
guard let readingListID = readingList.0 else {
if let apiError = readingList.1 as? APIReadingListError {
failedReadingLists.append((localReadingList, apiError))
}
continue
}
createdReadingLists[readingListID] = localReadingList
}
})
}
start = end
}
taskGroup.wait()
guard !isCancelled else {
throw ReadingListsOperationError.cancelled
}
for (readingListID, localReadingList) in createdReadingLists {
localReadingList.readingListID = NSNumber(value: readingListID)
localReadingList.isUpdatedLocally = false
}
for (_, localReadingList) in updatedReadingLists {
localReadingList.isUpdatedLocally = false
}
for (_, localReadingList) in deletedReadingLists {
moc.delete(localReadingList)
}
for failed in failedReadingLists {
failed.0.errorCode = failed.1.rawValue
}
let entriesToCreateOrUpdateFetch: NSFetchRequest<ReadingListEntry> = 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..<end])
let entryProjectAndTitles = entrySubarray.map { (project: $0.project, title: $0.title) }
self.apiController.addEntriesToList(withListID: readingListID, entries: entryProjectAndTitles, completion: { (readingListEntryIDs, createError) in
defer {
taskGroup.leave()
}
guard let readingListEntryIDs = readingListEntryIDs else {
DDLogError("Error creating reading list entry: \(String(describing: createError))")
if let apiError = createError as? APIReadingListError {
for entry in entrySubarray {
failedReadingListEntries.append((entry.entry, apiError))
}
}
return
}
for (index, readingListEntryIDOrError) in readingListEntryIDs.enumerated() {
guard index < entrySubarray.count else {
break
}
let localReadingListEntry = entrySubarray[index].entry
guard let readingListEntryID = readingListEntryIDOrError.0 else {
if let apiError = readingListEntryIDOrError.1 as? APIReadingListError {
failedReadingListEntries.append((localReadingListEntry, apiError))
}
continue
}
createdReadingListEntries[readingListEntryID] = localReadingListEntry
}
})
taskGroup.wait()
for (readingListEntryID, localReadingListEntry) in createdReadingListEntries {
localReadingListEntry.readingListEntryID = NSNumber(value: readingListEntryID)
localReadingListEntry.isUpdatedLocally = false
}
for failed in failedReadingListEntries {
failed.0.errorCode = failed.1.rawValue
}
if moc.hasChanges {
try moc.save()
}
guard !isCancelled else {
throw ReadingListsOperationError.cancelled
}
}
start = end
}
}
}
// Given a remote reading list entry, returns a URL with its language variant code set.
// For languages without variants, language variant code is nil.
// For languages with variants, uses a best guess heuristic searching app preferred languages,
// then OS preferred languages/locales, then an app-defined fallback.
private func variantAwareURLForRemoteEntry(_ remoteEntry: APIReadingListEntry) -> 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<RemoteReadingListArticleKey> = []
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> = 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<ReadingList> = []
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> = 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> = 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..<end]), in: moc)
start = end
try moc.save()
moc.reset()
createdOrUpdatedReadingListEntriesCount += 1
}
}
}
return createdOrUpdatedReadingListEntriesCount
}
}