From 1cdfe4a4392b944fcea1e73f58720f1d764a7763 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Thu, 16 Jun 2022 17:56:40 -0700 Subject: [PATCH] move function to load content by requestID into data service --- .../WebReader/WebReaderLoadingContainer.swift | 108 +-------- .../Models/DataModels/ArticleContent.swift | 6 +- .../Sources/Models/DataModels/FeedItem.swift | 2 +- .../Services/DataService/ContentLoading.swift | 227 ++++++++++++++++++ .../Services/DataService/DataService.swift | 13 + .../Public/LinkedItemContentLoading.swift | 48 +--- .../Public/LinkedItemLoading.swift | 11 + 7 files changed, 263 insertions(+), 152 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index ac508aff7..c4ce0da34 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -10,112 +10,8 @@ import Utils @Published var errorMessage: String? func loadItem(dataService: DataService, requestID: String) async { - let username: String? = await { - if let cachedUsername = dataService.currentViewer?.username { - return cachedUsername - } - - if let viewerObjectID = try? await dataService.fetchViewer() { - let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer - return viewer?.unwrappedUsername - } - - return nil - }() - - guard let username = username else { return } - - let existing = existingItemOrItemId(dataService: dataService, requestID: requestID) - if let existingItem = existing.existingItem, existingItem.isReadyToRead { - item = existingItem - return - } - - // If the page was locally created, make sure they are synced before we pull content - await dataService.syncUnsyncedArticleContent(itemID: existing.itemID) - - // Fetch the item and it's content - let item = await fetchLinkedItem(dataService: dataService, requestID: existing.itemID, username: username) - if let item = item, let itemID = item.id { - do { - let articleContent = try await dataService.loadArticleContentWithRetries(itemID: itemID, username: username, requestCount: 0) - // We've fetched the article content, now reload the item from core data - if let linkedItem = dataService.viewContext.object(with: item.objectID) as? LinkedItem { - self.item = linkedItem - } else { - self.item = nil - } - } catch { - self.item = nil - } - } else { - self.item = nil - } - } - - private func fetchLinkedItem( - dataService: DataService, - requestID: String, - username: String, - requestCount: Int = 1 - ) async -> LinkedItem? { - guard requestCount < 7 else { - errorMessage = "Unable to fetch item." - return nil - } - - if let objectID = try? await dataService.loadLinkedItem(username: username, itemID: requestID) { - if let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem { - return linkedItem - } else { - errorMessage = "Unable to fetch item." - } - return nil - } - - // Retry on error - do { - let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000) - try await Task.sleep(nanoseconds: retryDelayInNanoSeconds) - - let existing = existingItemOrItemId(dataService: dataService, requestID: requestID) - if let existingItem = existing.existingItem, existingItem.isReadyToRead { - print(" - FROM CORE DATA SERVICE", existingItem) - return existingItem - } - - let result = await fetchLinkedItem( - dataService: dataService, - requestID: existing.itemID, - username: username, - requestCount: requestCount + 1 - ) - if let result = result { - return result - } - } catch { - errorMessage = "Unable to fetch item." - } - return nil - } - - private func existingItemOrItemId(dataService: DataService, requestID: String) -> (existingItem: LinkedItem?, itemID: String) { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID) - if let existingItem = try? dataService.viewContext.fetch(fetchRequest).first { - // If the existing item is synced, we can use it - if let itemID = existingItem.id, existingItem.serverSyncStatus == ServerSyncStatus.isNSync.rawValue { - item = existingItem - return (existingItem: item, itemID: itemID) - } - - // If the existing item is not synced, we might have an updated request id - if let existingID = existingItem.id { - return (existingItem: nil, itemID: existingID) - } - } - - return (existingItem: nil, itemID: requestID) + guard let objectID = try? await dataService.loadItemContentUsingRequestID(requestID: requestID) else { return } + item = dataService.viewContext.object(with: objectID) as? LinkedItem } func trackReadEvent() { diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift b/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift index e1ff986ef..028dcbacc 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation public enum ArticleContentStatus: String { @@ -12,17 +13,20 @@ public struct ArticleContent { public let htmlContent: String public let highlightsJSONString: String public let contentStatus: ArticleContentStatus + public let objectID: NSManagedObjectID? public init( title: String, htmlContent: String, highlightsJSONString: String, - contentStatus: ArticleContentStatus + contentStatus: ArticleContentStatus, + objectID: NSManagedObjectID? ) { self.title = title self.htmlContent = htmlContent self.highlightsJSONString = highlightsJSONString self.contentStatus = contentStatus + self.objectID = objectID } } diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 765c9f329..63f790c3f 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -51,7 +51,7 @@ public extension LinkedItem { return PDFUtils.exists(filename: localPDF) || PDFUtils.tempExists(tempPDFURL: tempPDFURL) } // Check the state and whether we have HTML - return state == "SUCCEEDED" + return state == ArticleContentStatus.succeeded.rawValue } var isPDF: Bool { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift new file mode 100644 index 000000000..c9fe9302a --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift @@ -0,0 +1,227 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL +import Utils + +struct PendingLink { + let itemID: String + let retryCount: Int +} + +extension DataService { + func prefetchPage(pendingLink: PendingLink, username: String) async { + let content = try? await loadArticleContent(username: username, itemID: pendingLink.itemID, useCache: false) + + if content?.contentStatus == .processing, pendingLink.retryCount < 7 { + let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000) + + do { + try await Task.sleep(nanoseconds: retryDelayInNanoSeconds) + logger.debug("fetching content for \(pendingLink.itemID). retry count: \(pendingLink.retryCount)") + + await prefetchPage( + pendingLink: PendingLink( + itemID: pendingLink.itemID, + retryCount: pendingLink.retryCount + 1 + ), + username: username + ) + } catch { + logger.debug("prefetching task was cancelled") + } + } + } + + func loadArticleContent(username: String, itemID: String, useCache: Bool) async throws -> ArticleContent { + var objectID: NSManagedObjectID? + if useCache, let cachedContent = await cachedArticleContent(itemID: itemID) { + return cachedContent + } + + // If the page was locally created, make sure they are synced before we pull content + await syncUnsyncedArticleContent(itemID: itemID) + + let fetchResult = try await articleContentFetch(username: username, itemID: itemID) + let contentStatus = fetchResult.item.isPDF ? .succeeded : fetchResult.item.state + + if contentStatus == .succeeded { + do { + objectID = try await persistArticleContent(articleProps: fetchResult) + } catch { + var message = "unknown error" + let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error") + if case let BasicError.message(messageText) = basicError { + message = messageText + } + throw ContentFetchError.unknown(description: message) + } + } + + return ArticleContent( + title: fetchResult.item.title, + htmlContent: fetchResult.htmlContent, + highlightsJSONString: fetchResult.highlights.asJSONString, + contentStatus: fetchResult.item.isPDF ? .succeeded : fetchResult.item.state, + objectID: objectID + ) + } + + func cachedArticleContent(itemID: String) async -> ArticleContent? { + let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + linkedItemFetchRequest.predicate = NSPredicate( + format: "id == %@", itemID + ) + + let context = backgroundContext + + return await context.perform(schedule: .immediate) { + guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return nil } + guard let htmlContent = linkedItem.htmlContent else { return nil } + + let highlights = linkedItem + .highlights + .asArray(of: Highlight.self) + .filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue } + + return ArticleContent( + title: linkedItem.unwrappedTitle, + htmlContent: htmlContent, + highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString, + contentStatus: .succeeded, + objectID: linkedItem.objectID + ) + } + } + + // swiftlint:disable:next function_body_length + func persistArticleContent(articleProps: ArticleProps) async throws -> NSManagedObjectID? { + var needsPDFDownload = false + var objectID: NSManagedObjectID? + + await backgroundContext.perform { [weak self] in + guard let self = self else { return } + let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", articleProps.item.id) + + let existingItem = try? self.backgroundContext.fetch(fetchRequest).first + let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext) + objectID = linkedItem.objectID + + let highlightObjects = articleProps.highlights.map { + $0.asManagedObject(context: self.backgroundContext) + } + linkedItem.addToHighlights(NSSet(array: highlightObjects)) + linkedItem.htmlContent = articleProps.htmlContent + linkedItem.id = articleProps.item.id + linkedItem.state = articleProps.item.state.rawValue + linkedItem.title = articleProps.item.title + linkedItem.createdAt = articleProps.item.createdAt + linkedItem.savedAt = articleProps.item.savedAt + linkedItem.readingProgress = articleProps.item.readingProgress + linkedItem.readingProgressAnchor = Int64(articleProps.item.readingProgressAnchor) + linkedItem.imageURLString = articleProps.item.imageURLString + linkedItem.onDeviceImageURLString = articleProps.item.onDeviceImageURLString + linkedItem.pageURLString = articleProps.item.pageURLString + linkedItem.descriptionText = articleProps.item.descriptionText + linkedItem.publisherURLString = articleProps.item.publisherURLString + linkedItem.author = articleProps.item.author + linkedItem.publishDate = articleProps.item.publishDate + linkedItem.slug = articleProps.item.slug + linkedItem.readAt = articleProps.item.readAt + linkedItem.isArchived = articleProps.item.isArchived + linkedItem.contentReader = articleProps.item.contentReader + linkedItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue) + + if articleProps.item.isPDF { + needsPDFDownload = true + + // Check if we already have the PDF item locally. Either in temporary + // space, or in the documents directory + if let localPDF = existingItem?.localPDF { + if PDFUtils.exists(filename: localPDF) { + linkedItem.localPDF = localPDF + needsPDFDownload = false + } + } + + if let tempPDFURL = existingItem?.tempPDFURL { + linkedItem.localPDF = try? PDFUtils.moveToLocal(url: tempPDFURL) + _ = PDFUtils.exists(filename: linkedItem.localPDF) + if linkedItem.localPDF != nil { + needsPDFDownload = false + } + } + } + } + + if articleProps.item.isPDF, needsPDFDownload { + _ = try await fetchPDFData(slug: articleProps.item.slug, pageURLString: articleProps.item.pageURLString) + } + + try await backgroundContext.perform { [weak self] in + do { + try self?.backgroundContext.save() + logger.debug("ArticleContent saved succesfully") + } catch { + self?.backgroundContext.rollback() + logger.debug("Failed to save ArticleContent") + throw error + } + } + + return objectID + } + + /// Queries CoreData for a LinkedItem using a requestID. + /// - Parameter requestID: A requestID used to check on a newly created item. + /// - Returns: The id of the CoreData object if found. + func linkedItemID(from requestID: String) -> String? { + let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID) + return try? backgroundContext.fetch(fetchRequest).first?.unwrappedID + } + + func syncUnsyncedArticleContent(itemID: String) async { + let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + linkedItemFetchRequest.predicate = NSPredicate( + format: "id == %@", itemID + ) + + let context = backgroundContext + + var id: String? + var url: String? + var title: String? + var originalHtml: String? + var serverSyncStatus: Int64? + + backgroundContext.performAndWait { + guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return } + id = linkedItem.unwrappedID + url = linkedItem.unwrappedPageURLString + title = linkedItem.unwrappedTitle + originalHtml = linkedItem.originalHtml + serverSyncStatus = linkedItem.serverSyncStatus + } + + guard let id = id, let url = url, let title = title, + let serverSyncStatus = serverSyncStatus, + serverSyncStatus == ServerSyncStatus.needsCreation.rawValue + else { + return + } + + do { + if let originalHtml = originalHtml { + _ = try await savePage(id: id, url: url, title: title, originalHtml: originalHtml) + } else { + _ = try await saveURL(id: id, url: url) + } + } catch { + // We don't propogate these errors, we just let it pass through so + // the user can attempt to fetch content again. + print("Error syncUnsyncedArticleContent") + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index f14ac5b6a..a4426aaec 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -49,6 +49,19 @@ public final class DataService: ObservableObject { return try? persistentContainer.viewContext.fetch(fetchRequest).first } + public func username() async -> String? { + if let cachedUsername = currentViewer?.username { + return cachedUsername + } + + if let viewerObjectID = try? await fetchViewer() { + let viewer = backgroundContext.object(with: viewerObjectID) as? Viewer + return viewer?.unwrappedUsername + } + + return nil + } + public func switchAppEnvironment(appEnvironment: AppEnvironment) { do { try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemContentLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemContentLoading.swift index 9a5153d5a..e51fc1c21 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemContentLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemContentLoading.swift @@ -1,6 +1,7 @@ import CoreData import Foundation import Models +import Utils public extension DataService { func prefetchPages(itemIDs: [String], username: String) async { @@ -32,52 +33,11 @@ public extension DataService { let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000) try await Task.sleep(nanoseconds: retryDelayInNanoSeconds) logger.debug("fetching content for \(itemID). request count: \(requestCount)") - return try await loadArticleContentWithRetries(itemID: itemID, username: username, requestCount: requestCount + 1) + // Check for an updated requestID + let updatedItemID = linkedItemID(from: itemID) ?? itemID + return try await loadArticleContentWithRetries(itemID: updatedItemID, username: username, requestCount: requestCount + 1) case .succeeded, .unknown: return fetchedContent } } - - func syncUnsyncedArticleContent(itemID: String) async { - let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() - linkedItemFetchRequest.predicate = NSPredicate( - format: "id == %@", itemID - ) - - let context = backgroundContext - - var id: String? - var url: String? - var title: String? - var originalHtml: String? - var serverSyncStatus: Int64? - - backgroundContext.performAndWait { - guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return } - id = linkedItem.unwrappedID - url = linkedItem.unwrappedPageURLString - title = linkedItem.unwrappedTitle - originalHtml = linkedItem.originalHtml - serverSyncStatus = linkedItem.serverSyncStatus - } - - guard let id = id, let url = url, let title = title, - let serverSyncStatus = serverSyncStatus, - serverSyncStatus == ServerSyncStatus.needsCreation.rawValue - else { - return - } - - do { - if let originalHtml = originalHtml { - _ = try await savePage(id: id, url: url, title: title, originalHtml: originalHtml) - } else { - _ = try await saveURL(id: id, url: url) - } - } catch { - // We don't propogate these errors, we just let it pass through so - // the user can attempt to fetch content again. - print("Error syncUnsyncedArticleContent") - } - } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift index 62b8826c9..24c6a5738 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift @@ -40,4 +40,15 @@ public extension DataService { return persistedItemID } + + func loadItemContentUsingRequestID(requestID: String) async throws -> NSManagedObjectID? { + let username: String? = await username() + guard let username = username else { throw BasicError.message(messageText: "unauthorized user") } + + // If the page was locally created, make sure they are synced before we pull content + await syncUnsyncedArticleContent(itemID: requestID) + + let articleContent = try await loadArticleContentWithRetries(itemID: requestID, username: username, requestCount: 0) + return articleContent.objectID + } }