mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
retry article content fetches when server returns a .processing state
This commit is contained in:
parent
3cf0888c54
commit
c39ab2d5f7
5 changed files with 133 additions and 37 deletions
|
|
@ -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<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ import WebKit
|
|||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.task {
|
||||
await viewModel.loadContent(dataService: dataService, slug: item.unwrappedSlug)
|
||||
await viewModel.loadContent(dataService: dataService, itemID: item.unwrappedID)
|
||||
}
|
||||
}
|
||||
if showFontSizePopover {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,16 @@ struct SafariWebLink: Identifiable {
|
|||
|
||||
@MainActor final class WebReaderViewModel: ObservableObject {
|
||||
@Published var articleContent: ArticleContent?
|
||||
@Published var contentFetchFailed = false // update UI to reflect this
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Models.LinkedItem> = 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<Models.LinkedItem> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue