mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #532 from omnivore-app/feature/ios-pending-item-support
Pending item support - iOS/macOS
This commit is contained in:
commit
90f204830f
7 changed files with 148 additions and 51 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue