diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index c0407f6a6..69959bfc0 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1261,7 +1261,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1292,7 +1292,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1374,7 +1374,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1408,7 +1408,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1463,7 +1463,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1495,7 +1495,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1534,7 +1534,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1573,7 +1573,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1611,7 +1611,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1696,7 +1696,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; SDKROOT = iphoneos; @@ -1750,7 +1750,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1778,7 +1778,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; SDKROOT = iphoneos; diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index fb400565a..4c08a1dcd 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -80,7 +80,7 @@ import Views isLoading = false receivedIdx = thisSearchIdx cursor = queryResult.cursor - await dataService.prefetchPages(itemSlugs: newItems.map(\.unwrappedSlug)) + await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID)) } else if searchTermIsEmpty { await dataService.viewContext.perform { let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index ff5434e1f..c8e26c311 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -187,10 +187,10 @@ import WebKit ) } } else { - Color.clear - .contentShape(Rectangle()) + Text(viewModel.contentFetchFailed ? "Unable to fetch content." : "Processing...") + .padding() .task { - await viewModel.loadContent(dataService: dataService, slug: item.unwrappedSlug) + await viewModel.loadContent(dataService: dataService, itemID: item.unwrappedID) } } if showFontSizePopover { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 0eaaa1acc..97017cfd8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -10,13 +10,16 @@ struct SafariWebLink: Identifiable { @MainActor final class WebReaderViewModel: ObservableObject { @Published var articleContent: ArticleContent? + @Published var contentFetchFailed = false - var slug: String? + func loadContent(dataService: DataService, itemID: String) async { + contentFetchFailed = false - func loadContent(dataService: DataService, slug: String) async { - self.slug = slug - guard let username = dataService.currentViewer?.username else { return } - articleContent = try? await dataService.articleContent(username: username, slug: slug, useCache: true) + do { + articleContent = try await dataService.fetchArticleContent(itemID: itemID) + } catch { + contentFetchFailed = true + } } func createHighlight( diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift b/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift index 7f21779c2..4e969e987 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift @@ -1,14 +1,24 @@ import Foundation +public enum ArticleContentStatus { + case failed + case processing + case succeeded + case unknown +} + public struct ArticleContent { public let htmlContent: String public let highlightsJSONString: String + public let contentStatus: ArticleContentStatus public init( htmlContent: String, - highlightsJSONString: String + highlightsJSONString: String, + contentStatus: ArticleContentStatus ) { self.htmlContent = htmlContent self.highlightsJSONString = highlightsJSONString + self.contentStatus = contentStatus } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 9f2c5a068..217010def 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -4,22 +4,80 @@ import Models import SwiftGraphQL extension DataService { - public func prefetchPages(itemSlugs: [String]) async { + struct PendingLink { + let itemID: String + let retryCount: Int + } + + public func prefetchPages(itemIDs: [String]) async { guard let username = currentViewer?.username else { return } - for slug in itemSlugs { - // TODO: maybe check for cached content before downloading again? check timestamp? - _ = try? await articleContent(username: username, slug: slug, useCache: false) + for itemID in itemIDs { + await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username) } } - public func articleContent(username: String, slug: String, useCache: Bool) async throws -> ArticleContent { + func prefetchPage(pendingLink: PendingLink, username: String) async { + let content = try? await articleContent(username: username, itemID: pendingLink.itemID, useCache: false) + + if content?.contentStatus == .processing, pendingLink.retryCount < 6 { + 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") + } + } + } + + public func fetchArticleContent( + itemID: String, + username: String? = nil, + requestCount: Int = 1 + ) async throws -> ArticleContent { + guard let username = username ?? currentViewer?.username else { + throw BasicError.message(messageText: "username could not be fetched from core data") + } + + guard let fetchedContent = try? await articleContent(username: username, itemID: itemID, useCache: true) else { + throw BasicError.message(messageText: "networking error") + } + + switch fetchedContent.contentStatus { + case .failed, .unknown: + throw BasicError.message(messageText: "content fetch failed") + case .processing: + do { + 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 fetchArticleContent(itemID: itemID, username: username, requestCount: requestCount + 1) + } catch { + throw BasicError.message(messageText: "content fetch failed") + } + case .succeeded: + return fetchedContent + } + } + + public func articleContent(username: String, itemID: String, useCache: Bool) async throws -> ArticleContent { struct ArticleProps { let htmlContent: String let highlights: [InternalHighlight] + let contentStatus: Enums.ArticleSavingRequestStatus? } - if useCache, let cachedContent = cachedArticleContent(slug: slug) { + if useCache, let cachedContent = await cachedArticleContent(itemID: itemID) { return cachedContent } @@ -31,7 +89,8 @@ extension DataService { let articleSelection = Selection.Article { ArticleProps( htmlContent: try $0.content(), - highlights: try $0.highlights(selection: highlightSelection.list) + highlights: try $0.highlights(selection: highlightSelection.list), + contentStatus: try $0.state() ) } @@ -47,7 +106,8 @@ extension DataService { } let query = Selection.Query { - try $0.article(slug: slug, username: username, selection: selection) + // backend has a hack that allows us to pass in itemID in place of slug + try $0.article(slug: itemID, username: username, selection: selection) } let path = appEnvironment.graphqlPath @@ -62,15 +122,18 @@ extension DataService { switch payload.data { case let .success(result: result): - self?.persistArticleContent( - htmlContent: result.htmlContent, - slug: slug, - highlights: result.highlights - ) + if let status = result.contentStatus, status == .succeeded { + self?.persistArticleContent( + htmlContent: result.htmlContent, + itemID: itemID, + highlights: result.highlights + ) + } let articleContent = ArticleContent( htmlContent: result.htmlContent, - highlightsJSONString: result.highlights.asJSONString + highlightsJSONString: result.highlights.asJSONString, + contentStatus: .make(from: result.contentStatus) ) continuation.resume(returning: articleContent) @@ -81,11 +144,11 @@ extension DataService { } } - func persistArticleContent(htmlContent: String, slug: String, highlights: [InternalHighlight]) { + func persistArticleContent(htmlContent: String, itemID: String, highlights: [InternalHighlight]) { backgroundContext.perform { [weak self] in guard let self = self else { return } let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug) + fetchRequest.predicate = NSPredicate(format: "id == %@", itemID) let linkedItem = try? self.backgroundContext.fetch(fetchRequest).first @@ -98,7 +161,7 @@ extension DataService { linkedItem.htmlContent = htmlContent if linkedItem.isPDF { - self.fetchPDFData(slug: slug, pageURLString: linkedItem.unwrappedPageURLString) + self.fetchPDFData(slug: linkedItem.unwrappedSlug, pageURLString: linkedItem.unwrappedPageURLString) } do { @@ -137,23 +200,43 @@ extension DataService { } } - func cachedArticleContent(slug: String) -> ArticleContent? { + func cachedArticleContent(itemID: String) async -> ArticleContent? { let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() linkedItemFetchRequest.predicate = NSPredicate( - format: "slug == %@", slug + format: "id == %@", itemID ) - guard let linkedItem = try? persistentContainer.viewContext.fetch(linkedItemFetchRequest).first else { return nil } - guard let htmlContent = linkedItem.htmlContent else { return nil } + let context = backgroundContext - let highlights = linkedItem - .highlights - .asArray(of: Highlight.self) - .filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue } + 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 } - return ArticleContent( - htmlContent: htmlContent, - highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString - ) + let highlights = linkedItem + .highlights + .asArray(of: Highlight.self) + .filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue } + + return ArticleContent( + htmlContent: htmlContent, + highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString, + contentStatus: .succeeded + ) + } + } +} + +private extension ArticleContentStatus { + static func make(from savingRequestStatus: Enums.ArticleSavingRequestStatus?) -> ArticleContentStatus { + guard let savingRequestStatus = savingRequestStatus else { return .unknown } + + switch savingRequestStatus { + case .failed: + return .failed + case .processing: + return .processing + case .succeeded: + return .succeeded + } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift index e8e04d43a..ac6152ac3 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift @@ -44,6 +44,7 @@ public extension DataService { try $0.articles( after: OptionalArgument(cursor), first: OptionalArgument(limit), + includePending: OptionalArgument(true), query: OptionalArgument(searchQuery), sharedOnly: .present(false), sort: OptionalArgument(