mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #816 from omnivore-app/refactor/article-content-fetches
Refactor/article content fetches
This commit is contained in:
commit
cc484fd49a
39 changed files with 774 additions and 776 deletions
|
|
@ -1229,7 +1229,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
|
||||
|
|
@ -1239,7 +1239,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
|
||||
|
|
@ -1260,7 +1260,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
|
||||
|
|
@ -1270,7 +1270,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1341,7 +1341,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
|
@ -1352,7 +1352,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
|
|
@ -1375,7 +1375,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
|
@ -1386,7 +1386,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1441,7 +1441,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1473,7 +1473,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
|
@ -1512,7 +1512,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
|
|
@ -1538,7 +1538,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
|
@ -1551,7 +1551,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
|
@ -1576,7 +1576,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
|
@ -1589,7 +1589,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
|
|
@ -1674,7 +1674,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
|
||||
PRODUCT_NAME = ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
@ -1728,7 +1728,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1756,7 +1756,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
|
||||
PRODUCT_NAME = ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ class ExtensionSaveService {
|
|||
self.queue = OperationQueue()
|
||||
}
|
||||
|
||||
private func queueSaveOperation(_ pageScrape: PageScrapePayload, shareExtensionViewModel: ShareExtensionChildViewModel) {
|
||||
private func queueSaveOperation(
|
||||
_ pageScrape: PageScrapePayload,
|
||||
shareExtensionViewModel: ShareExtensionChildViewModel
|
||||
) {
|
||||
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
|
||||
guard !expiring else {
|
||||
self.queue.cancelAllOperations()
|
||||
|
|
@ -85,6 +88,7 @@ class ExtensionSaveService {
|
|||
var queue: OperationQueue?
|
||||
var uploadTask: URLSessionTask?
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
enum State: Int {
|
||||
case created
|
||||
case started
|
||||
|
|
@ -170,7 +174,11 @@ class ExtensionSaveService {
|
|||
case .none:
|
||||
requestId = try await services.dataService.createPageFromUrl(id: requestId, url: pageScrapePayload.url)
|
||||
case let .pdf(localUrl):
|
||||
try await services.dataService.createPageFromPdf(id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url)
|
||||
try await services.dataService.createPageFromPdf(
|
||||
id: requestId,
|
||||
localPdfURL: localUrl,
|
||||
url: pageScrapePayload.url
|
||||
)
|
||||
case let .html(html, title, _):
|
||||
requestId = try await services.dataService.createPage(
|
||||
id: requestId,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ struct ShareExtensionView: View {
|
|||
var body: some View {
|
||||
ShareExtensionChildView(
|
||||
viewModel: childViewModel,
|
||||
onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
|
||||
onAppearAction: {
|
||||
viewModel.savePage(
|
||||
extensionContext: extensionContext,
|
||||
shareExtensionViewModel: childViewModel
|
||||
)
|
||||
},
|
||||
readNowButtonAction: { viewModel.handleReadNowAction(requestId: $0, extensionContext: extensionContext) },
|
||||
dismissButtonTappedAction: { _, _ in
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Combine
|
|||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
// swiftlint:disable file_length
|
||||
#if os(iOS)
|
||||
import PSPDFKit
|
||||
import PSPDFKitUI
|
||||
|
|
@ -147,7 +148,7 @@ import Utils
|
|||
pdfStateObject.document = document
|
||||
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
|
||||
} else {
|
||||
errorMessage = "Unable to download PDF: \(pdfURL)"
|
||||
errorMessage = "Unable to download PDF: \(pdfURL?.description ?? "")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +221,7 @@ import Utils
|
|||
return result
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func highlightSelection(pageView: PDFPageView, selectedText: String, dataService: DataService) -> String {
|
||||
let highlightID = UUID().uuidString.lowercased()
|
||||
let quote = quoteFromSelectedText(selectedText)
|
||||
|
|
|
|||
|
|
@ -1,27 +1,23 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import Utils
|
||||
|
||||
public final class PDFViewerViewModel: ObservableObject {
|
||||
@Published public var errorMessage: String?
|
||||
@Published public var readerView: Bool = false
|
||||
final class PDFViewerViewModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var readerView: Bool = false
|
||||
|
||||
public let pdfItem: PDFItem
|
||||
let pdfItem: PDFItem
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
public init(pdfItem: PDFItem) {
|
||||
init(pdfItem: PDFItem) {
|
||||
self.pdfItem = pdfItem
|
||||
}
|
||||
|
||||
public func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
|
||||
func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
|
||||
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
|
||||
}
|
||||
|
||||
public func createHighlight(
|
||||
func createHighlight(
|
||||
dataService: DataService,
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
|
|
@ -37,7 +33,8 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
public func mergeHighlight(
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func mergeHighlight(
|
||||
dataService: DataService,
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
|
|
@ -55,13 +52,13 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
public func removeHighlights(dataService: DataService, highlightIds: [String]) {
|
||||
func removeHighlights(dataService: DataService, highlightIds: [String]) {
|
||||
highlightIds.forEach { highlightID in
|
||||
dataService.deleteHighlight(highlightID: highlightID)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateItemReadProgress(dataService: DataService, percent: Double, anchorIndex: Int) {
|
||||
func updateItemReadProgress(dataService: DataService, percent: Double, anchorIndex: Int) {
|
||||
dataService.updateLinkReadingProgress(
|
||||
itemID: pdfItem.itemID,
|
||||
readingProgress: percent,
|
||||
|
|
@ -69,7 +66,7 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
public func highlightShareURL(dataService: DataService, shortId: String) -> URL? {
|
||||
func highlightShareURL(dataService: DataService, shortId: String) -> URL? {
|
||||
let baseURL = dataService.appEnvironment.serverBaseURL
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||
|
||||
|
|
@ -82,29 +79,22 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
return components?.url
|
||||
}
|
||||
|
||||
public var itemDownloaded: Bool {
|
||||
if let localPdfURL = pdfItem.localPdfURL, FileManager.default.fileExists(atPath: localPdfURL.path) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func downloadPDF(dataService: DataService) async -> URL? {
|
||||
func downloadPDF(dataService: DataService) async -> URL? {
|
||||
do {
|
||||
if itemDownloaded {
|
||||
return pdfItem.localPdfURL
|
||||
if let localPdfURL = pdfItem.localPdfURL, FileManager.default.fileExists(atPath: localPdfURL.path) {
|
||||
return localPdfURL
|
||||
}
|
||||
|
||||
if let tempURL = pdfItem.tempPDFURL {
|
||||
if let localURL = try? PDFUtils.copyToLocal(url: tempURL) {
|
||||
if (try? PDFUtils.copyToLocal(url: tempURL)) != nil {
|
||||
return tempURL
|
||||
}
|
||||
}
|
||||
if let localURL = try await dataService.fetchPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL) {
|
||||
return localURL
|
||||
}
|
||||
|
||||
return try await dataService.loadPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL)
|
||||
} catch {
|
||||
print("error downloading PDF", error)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,4 +102,5 @@ public final class Services {
|
|||
#endif
|
||||
|
||||
// Command to simulate BG Task
|
||||
// swiftlint:disable:next line_length
|
||||
// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.omnivore.fetchLinkedItems"]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import UserNotifications
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
// swiftlint:disable file_length
|
||||
#if os(iOS)
|
||||
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
|
||||
|
||||
|
|
@ -196,7 +197,10 @@ import Views
|
|||
}
|
||||
.padding(.horizontal)
|
||||
.sheet(isPresented: $showLabelsSheet) {
|
||||
FilterByLabelsView(initiallySelected: viewModel.selectedLabels, initiallyNegated: viewModel.negatedLabels) {
|
||||
FilterByLabelsView(
|
||||
initiallySelected: viewModel.selectedLabels,
|
||||
initiallyNegated: viewModel.negatedLabels
|
||||
) {
|
||||
self.viewModel.selectedLabels = $0
|
||||
self.viewModel.negatedLabels = $1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import Views
|
|||
Task { _ = try? await dataService.fetchViewer() }
|
||||
}
|
||||
|
||||
let queryResult = try? await dataService.fetchLinkedItems(
|
||||
let queryResult = try? await dataService.loadLinkedItems(
|
||||
limit: 10,
|
||||
searchQuery: searchQuery,
|
||||
cursor: isRefresh ? nil : cursor
|
||||
|
|
@ -81,7 +81,7 @@ import Views
|
|||
let newItems: [LinkedItem] = {
|
||||
var itemObjects = [LinkedItem]()
|
||||
dataService.viewContext.performAndWait {
|
||||
itemObjects = queryResult.items.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
|
||||
itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
|
||||
}
|
||||
return itemObjects
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ struct CreateProfileView: View {
|
|||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.animation(.default)
|
||||
.animation(.default, value: 0.35)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Bio (optional)")
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import WebKit
|
|||
return webView
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
|
||||
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
|
||||
|
|
|
|||
|
|
@ -10,115 +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.fetchArticleContent(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
|
||||
}
|
||||
|
||||
print("FETCHING", requestID, requestCount)
|
||||
|
||||
if let objectID = try? await dataService.fetchLinkedItem(username: username, itemID: requestID) {
|
||||
if let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem {
|
||||
print(" - FROM DATA SERVICE", 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<Models.LinkedItem> = 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() {
|
||||
|
|
@ -141,8 +34,8 @@ import Utils
|
|||
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
|
||||
|
||||
public var body: some View {
|
||||
if let item = viewModel.item, item.isReadyToRead {
|
||||
if let pdfItem = PDFItem.make(item: item), let urlStr = item.pageURLString, let remoteUrl = URL(string: urlStr) {
|
||||
if let item = viewModel.item {
|
||||
if let pdfItem = PDFItem.make(item: item) {
|
||||
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
|
||||
.navigationBarHidden(true)
|
||||
.navigationViewStyle(.stack)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ struct SafariWebLink: Identifiable {
|
|||
errorMessage = nil
|
||||
|
||||
do {
|
||||
articleContent = try await dataService.fetchArticleContent(itemID: itemID)
|
||||
articleContent = try await dataService.loadArticleContentWithRetries(itemID: itemID)
|
||||
} catch {
|
||||
if retryCount == 0 {
|
||||
return await loadContent(dataService: dataService, itemID: itemID, retryCount: 1)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public enum ArticleContentStatus {
|
||||
case failed
|
||||
case processing
|
||||
case succeeded
|
||||
case unknown
|
||||
public enum ArticleContentStatus: String {
|
||||
case failed = "FAILED"
|
||||
case processing = "PROCESSING"
|
||||
case succeeded = "SUCCEEDED"
|
||||
case unknown = "UNKNOWN"
|
||||
}
|
||||
|
||||
public struct ArticleContent {
|
||||
|
|
@ -12,16 +13,25 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
public extension String {
|
||||
var asArticleContentStatus: ArticleContentStatus? {
|
||||
ArticleContentStatus(rawValue: self)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import CoreData
|
|||
import Foundation
|
||||
import Utils
|
||||
|
||||
public struct HomeFeedData { // TODO: rename this
|
||||
public let items: [NSManagedObjectID]
|
||||
public struct LinkedItemQueryResult {
|
||||
public let itemIDs: [NSManagedObjectID]
|
||||
public let cursor: String?
|
||||
|
||||
public init(items: [NSManagedObjectID], cursor: String?) {
|
||||
self.items = items
|
||||
public init(itemIDs: [NSManagedObjectID], cursor: String?) {
|
||||
self.itemIDs = itemIDs
|
||||
self.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
|
@ -45,15 +45,6 @@ public extension LinkedItem {
|
|||
readingProgress >= 0.98
|
||||
}
|
||||
|
||||
var isReadyToRead: Bool {
|
||||
if isPDF {
|
||||
// If its a PDF we verify the local file is available
|
||||
return PDFUtils.exists(filename: localPDF) || PDFUtils.tempExists(tempPDFURL: tempPDFURL)
|
||||
}
|
||||
// Check the state and whether we have HTML
|
||||
return state == "SUCCEEDED"
|
||||
}
|
||||
|
||||
var isPDF: Bool {
|
||||
if let contentReader = contentReader {
|
||||
return contentReader == "PDF"
|
||||
|
|
|
|||
|
|
@ -58,11 +58,17 @@ public extension LinkedItemFilter {
|
|||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
|
||||
case .readlater:
|
||||
// non-archived or deleted items without the Newsletter label
|
||||
let nonNewsletterLabelPredicate = NSPredicate(format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0")
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate])
|
||||
let nonNewsletterLabelPredicate = NSPredicate(
|
||||
format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate
|
||||
])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0")
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
|
||||
case .all:
|
||||
// include everything undeleted
|
||||
|
|
|
|||
|
|
@ -256,28 +256,14 @@ private extension PageScrapePayload {
|
|||
let localFile = UUID().uuidString.lowercased() + ".pdf"
|
||||
dest.appendPathComponent(localFile)
|
||||
do {
|
||||
print("EXISTING PDF URL", url)
|
||||
let attr = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
if let attr = attr {
|
||||
print("EXISTING FILE SIZE", attr[.size])
|
||||
}
|
||||
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
print("COPIED TO URL", dest)
|
||||
|
||||
let attr2 = try? FileManager.default.attributesOfItem(atPath: dest.path)
|
||||
if let attr2 = attr2 {
|
||||
print("COPIED FILE SIZE", attr2[.size])
|
||||
}
|
||||
|
||||
return PageScrapePayload(url: url.absoluteString, localUrl: dest)
|
||||
} catch {
|
||||
print("error copying file locally", error)
|
||||
}
|
||||
}
|
||||
// TODO:
|
||||
// Don't try to handle file URLs that are not PDFs.
|
||||
// In the future we can add image and other file type support here
|
||||
|
||||
// If file is not a pdf then return nil
|
||||
return nil
|
||||
}
|
||||
return PageScrapePayload(url: url.absoluteString)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
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<Models.LinkedItem> = 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<Models.LinkedItem> = 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 loadPDFData(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) async -> String? {
|
||||
await backgroundContext.perform(schedule: .immediate) {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID)
|
||||
return try? self.backgroundContext.fetch(fetchRequest).first?.unwrappedID
|
||||
}
|
||||
}
|
||||
|
||||
func syncUnsyncedArticleContent(itemID: String) async {
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -109,6 +122,7 @@ public final class DataService: ObservableObject {
|
|||
return isFirstRunOfVersion || isFirstRunWithBuildNumber
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
public func persistPageScrapePayload(_ pageScrape: PageScrapePayload, requestId: String) async throws {
|
||||
let normalizedURL = normalizeURL(pageScrape.url)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ extension DataService {
|
|||
// Fetch the items
|
||||
for itemID in missingItemIds { // TOOD: run these in parallel
|
||||
logger.debug("fetching item with ID: \(itemID)")
|
||||
_ = try await articleContent(username: username, itemID: itemID, useCache: false)
|
||||
_ = try await loadArticleContent(username: username, itemID: itemID, useCache: false)
|
||||
fetchedItemCount += 1
|
||||
logger.debug("done fetching item with ID: \(itemID)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11333,6 +11333,7 @@ extension Objects {
|
|||
let readAt: [String: DateTime]
|
||||
let readingProgressAnchorIndex: [String: Int]
|
||||
let readingProgressPercent: [String: Double]
|
||||
let savedAt: [String: DateTime]
|
||||
let shortId: [String: String]
|
||||
let siteName: [String: String]
|
||||
let slug: [String: String]
|
||||
|
|
@ -11439,6 +11440,10 @@ extension Objects.SearchItem: Decodable {
|
|||
if let value = try container.decode(Double?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "savedAt":
|
||||
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "shortId":
|
||||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
|
|
@ -11512,6 +11517,7 @@ extension Objects.SearchItem: Decodable {
|
|||
readAt = map["readAt"]
|
||||
readingProgressAnchorIndex = map["readingProgressAnchorIndex"]
|
||||
readingProgressPercent = map["readingProgressPercent"]
|
||||
savedAt = map["savedAt"]
|
||||
shortId = map["shortId"]
|
||||
siteName = map["siteName"]
|
||||
slug = map["slug"]
|
||||
|
|
@ -11798,7 +11804,7 @@ extension Fields where TypeLock == Objects.SearchItem {
|
|||
}
|
||||
}
|
||||
|
||||
func readingProgressAnchorIndex() throws -> Int? {
|
||||
func readingProgressAnchorIndex() throws -> Int {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "readingProgressAnchorIndex",
|
||||
arguments: []
|
||||
|
|
@ -11807,13 +11813,16 @@ extension Fields where TypeLock == Objects.SearchItem {
|
|||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
return data.readingProgressAnchorIndex[field.alias!]
|
||||
if let data = data.readingProgressAnchorIndex[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
case .mocking:
|
||||
return nil
|
||||
return Int.mockValue
|
||||
}
|
||||
}
|
||||
|
||||
func readingProgressPercent() throws -> Double? {
|
||||
func readingProgressPercent() throws -> Double {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "readingProgressPercent",
|
||||
arguments: []
|
||||
|
|
@ -11822,9 +11831,30 @@ extension Fields where TypeLock == Objects.SearchItem {
|
|||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
return data.readingProgressPercent[field.alias!]
|
||||
if let data = data.readingProgressPercent[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
case .mocking:
|
||||
return nil
|
||||
return Double.mockValue
|
||||
}
|
||||
}
|
||||
|
||||
func savedAt() throws -> DateTime {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "savedAt",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
if let data = data.savedAt[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
case .mocking:
|
||||
return DateTime.mockValue
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -11954,7 +11984,7 @@ extension Fields where TypeLock == Objects.SearchItem {
|
|||
}
|
||||
}
|
||||
|
||||
func updatedAt() throws -> DateTime {
|
||||
func updatedAt() throws -> DateTime? {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "updatedAt",
|
||||
arguments: []
|
||||
|
|
@ -11963,12 +11993,9 @@ extension Fields where TypeLock == Objects.SearchItem {
|
|||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
if let data = data.updatedAt[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
return data.updatedAt[field.alias!]
|
||||
case .mocking:
|
||||
return DateTime.mockValue
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func syncLabelCreation(label: InternalLinkedItemLabel) {
|
||||
enum MutationResult {
|
||||
case saved(label: InternalLinkedItemLabel)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ public extension DataService {
|
|||
let selection = Selection<MutationResult, Unions.UnsubscribeResult> {
|
||||
try $0.on(
|
||||
unsubscribeError: .init { .error(errorMessage: (try $0.errorCodes().first ?? .unauthorized).rawValue) },
|
||||
unsubscribeSuccess: .init { .success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() })) }
|
||||
unsubscribeSuccess: .init {
|
||||
.success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() }))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ extension DataService {
|
|||
quote: String,
|
||||
patch: String,
|
||||
articleId: String,
|
||||
overlapHighlightIdList: [String] // TODO: pass in annotation?
|
||||
overlapHighlightIdList: [String]
|
||||
) -> [String: Any]? {
|
||||
let internalHighlight = InternalHighlight(
|
||||
id: highlightID,
|
||||
|
|
@ -38,6 +38,7 @@ extension DataService {
|
|||
return internalHighlight.encoded()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func syncHighlightMerge(highlight: InternalHighlight, articleId: String, overlapHighlightIdList: [String]) {
|
||||
enum MutationResult {
|
||||
case saved(highlight: InternalHighlight)
|
||||
|
|
|
|||
|
|
@ -84,8 +84,11 @@ public extension Networker {
|
|||
}
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func saveArticlePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String?) -> AnyPublisher<Void, SaveArticleError> {
|
||||
// swiftlint:disable:next function_body_length
|
||||
func saveArticlePublisher(
|
||||
pageScrapePayload: PageScrapePayload,
|
||||
uploadFileId: String?
|
||||
) -> AnyPublisher<Void, SaveArticleError> {
|
||||
enum MutationResult {
|
||||
case saved(created: Bool)
|
||||
case error(errorCode: Enums.CreateArticleErrorCode)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public struct UploadFileRequestPayload {
|
|||
}
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next function_body_length
|
||||
func uploadFileRequest(id: String, url: String) async throws -> UploadFileRequestPayload {
|
||||
enum MutationResult {
|
||||
case success(payload: UploadFileRequestPayload)
|
||||
|
|
@ -60,11 +61,7 @@ public extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .success(payload):
|
||||
if let urlString = payload.urlString, let url = URL(string: urlString) {
|
||||
continuation.resume(returning: payload)
|
||||
} else {
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: "No upload URL"))
|
||||
}
|
||||
continuation.resume(returning: payload)
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
|
|
@ -85,12 +82,6 @@ public extension DataService {
|
|||
request.httpMethod = "PUT"
|
||||
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
|
||||
|
||||
print("UPLOADING PDF", localPdfURL)
|
||||
let attr = try? FileManager.default.attributesOfItem(atPath: localPdfURL.path)
|
||||
if let attr = attr {
|
||||
print("UPLOADING ATTR", attr[.size])
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let task = networker.urlSession.uploadTask(with: request, fromFile: localPdfURL) { _, response, _ in
|
||||
if let httpResponse = response as? HTTPURLResponse, 200 ... 299 ~= httpResponse.statusCode {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import Utils
|
|||
|
||||
public extension DataService {
|
||||
internal func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
// TODO: send a simple request to see if we're online?
|
||||
var unsyncedLinkedItems = [LinkedItem]()
|
||||
var unsyncedHighlights = [Highlight]()
|
||||
|
||||
|
|
@ -118,9 +117,6 @@ public extension DataService {
|
|||
Task {
|
||||
try await createPageFromPdf(id: id, localPdfURL: localPdfURL, url: url)
|
||||
}
|
||||
} else {
|
||||
// TODO: This is an invalid object, we should have a way of reflecting that with an error state
|
||||
// updateLinkedItemStatus(id: id, status: .)
|
||||
}
|
||||
case "WEB":
|
||||
let id = item.unwrappedID
|
||||
|
|
@ -130,9 +126,9 @@ public extension DataService {
|
|||
|
||||
Task {
|
||||
if let originalHtml = originalHtml {
|
||||
try await createPage(id: id, originalHtml: originalHtml, title: title, url: url)
|
||||
_ = try await createPage(id: id, originalHtml: originalHtml, title: title, url: url)
|
||||
} else {
|
||||
try await createPageFromUrl(id: id, url: url)
|
||||
_ = try await createPageFromUrl(id: id, url: url)
|
||||
}
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func prefetchPages(itemIDs: [String], username: String) async {
|
||||
// TODO: make this concurrent
|
||||
for itemID in itemIDs {
|
||||
await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
|
||||
}
|
||||
}
|
||||
|
||||
func loadArticleContentWithRetries(
|
||||
itemID: String,
|
||||
username: String? = nil,
|
||||
requestCount: Int = 1
|
||||
) async throws -> ArticleContent {
|
||||
guard requestCount < 7 else {
|
||||
throw ContentFetchError.badData
|
||||
}
|
||||
|
||||
guard let username = username ?? currentViewer?.username else {
|
||||
throw ContentFetchError.unauthorized
|
||||
}
|
||||
|
||||
let fetchedContent = try await loadArticleContent(username: username, itemID: itemID, useCache: true)
|
||||
|
||||
switch fetchedContent.contentStatus {
|
||||
case .failed:
|
||||
throw ContentFetchError.badData
|
||||
case .processing:
|
||||
let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000)
|
||||
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
|
||||
logger.debug("fetching content for \(itemID). request count: \(requestCount)")
|
||||
|
||||
// Check for an updated itemID
|
||||
// May have changed in the loadArticleContent call
|
||||
let updatedItemID = await linkedItemID(from: itemID)
|
||||
|
||||
return try await loadArticleContentWithRetries(
|
||||
itemID: updatedItemID ?? itemID,
|
||||
username: username,
|
||||
requestCount: requestCount + 1
|
||||
)
|
||||
case .succeeded, .unknown:
|
||||
return fetchedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
public extension DataService {
|
||||
/// Requests `LinkedItem`s from the server and stores it in CoreData.
|
||||
/// - Parameters:
|
||||
/// - limit: max count of items
|
||||
/// - searchQuery: search terms and filters
|
||||
/// - cursor: cursor when loading batch for infinite list
|
||||
/// - Returns: `LinkedItemQueryResult` (managed object IDs and an optional cursor)
|
||||
func loadLinkedItems(
|
||||
limit: Int,
|
||||
searchQuery: String?,
|
||||
cursor: String?
|
||||
) async throws -> LinkedItemQueryResult {
|
||||
// Send offline changes to server before fetching items
|
||||
try? await syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let fetchResult = try await fetchLinkedItems(limit: limit, searchQuery: searchQuery, cursor: cursor)
|
||||
|
||||
guard let itemIDs = fetchResult.items.persist(context: backgroundContext) else {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
}
|
||||
|
||||
return LinkedItemQueryResult(itemIDs: itemIDs, cursor: fetchResult.cursor)
|
||||
}
|
||||
|
||||
/// Requests a single `LinkedItem` from the server and stores it in CoreData
|
||||
/// - Parameters:
|
||||
/// - username: the Viewer's username
|
||||
/// - itemID: id of item being requested
|
||||
/// - Returns: The `NSManagedObjectID` of the `LinkedItem`
|
||||
func loadLinkedItem(username: String, itemID: String) async throws -> NSManagedObjectID {
|
||||
let item = try await fetchLinkedItem(username: username, itemID: itemID)
|
||||
|
||||
guard let persistedItemID = [item].persist(context: backgroundContext)?.first else {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func loadPDFData(slug: String, pageURLString: String) async throws -> URL? {
|
||||
guard let url = URL(string: pageURLString) else {
|
||||
throw BasicError.message(messageText: "No PDF URL found")
|
||||
}
|
||||
|
||||
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
|
||||
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "pdfFetch failed. no data received.")
|
||||
}
|
||||
|
||||
var localPdfURL: URL?
|
||||
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(UUID().uuidString + ".pdf")
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
|
||||
|
||||
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
|
||||
guard let linkedItem = linkedItem else {
|
||||
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
|
||||
do {
|
||||
try data.write(to: tempPath)
|
||||
let localPDF = try PDFUtils.moveToLocal(url: tempPath)
|
||||
localPdfURL = PDFUtils.localPdfURL(filename: localPDF)
|
||||
linkedItem.tempPDFURL = nil
|
||||
linkedItem.localPDF = localPDF
|
||||
try self?.backgroundContext.save()
|
||||
} catch {
|
||||
self?.backgroundContext.rollback()
|
||||
let errorMessage = "pdfFetch failed. core data save failed."
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return localPdfURL
|
||||
}
|
||||
}
|
||||
|
|
@ -4,91 +4,15 @@ import Models
|
|||
import SwiftGraphQL
|
||||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
internal struct PendingLink {
|
||||
let itemID: String
|
||||
let retryCount: Int
|
||||
}
|
||||
|
||||
func prefetchPages(itemIDs: [String], username: String) async {
|
||||
// TODO: make this concurrent
|
||||
// TODO: make a non-pending page option for BG tasks
|
||||
for itemID in itemIDs {
|
||||
await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
|
||||
}
|
||||
}
|
||||
|
||||
internal 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 < 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 fetchArticleContent(
|
||||
itemID: String,
|
||||
username: String? = nil,
|
||||
requestCount: Int = 1
|
||||
) async throws -> ArticleContent {
|
||||
guard requestCount < 7 else {
|
||||
throw ContentFetchError.badData
|
||||
}
|
||||
|
||||
guard let username = username ?? currentViewer?.username else {
|
||||
throw ContentFetchError.unauthorized
|
||||
}
|
||||
|
||||
let fetchedContent = try await articleContent(username: username, itemID: itemID, useCache: true)
|
||||
|
||||
switch fetchedContent.contentStatus {
|
||||
case .failed:
|
||||
throw ContentFetchError.badData
|
||||
case .processing:
|
||||
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)
|
||||
case .succeeded, .unknown:
|
||||
return fetchedContent
|
||||
}
|
||||
}
|
||||
struct ArticleProps {
|
||||
let item: InternalLinkedItem
|
||||
let htmlContent: String
|
||||
let highlights: [InternalHighlight]
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
// swiftlint:disable:next function_body_length
|
||||
func articleContent(
|
||||
username: String,
|
||||
itemID: String,
|
||||
useCache: Bool
|
||||
) async throws -> ArticleContent {
|
||||
struct ArticleProps {
|
||||
let item: InternalLinkedItem
|
||||
let htmlContent: String
|
||||
let highlights: [InternalHighlight]
|
||||
let contentStatus: Enums.ArticleSavingRequestStatus?
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func articleContentFetch(username: String, itemID: String) async throws -> ArticleProps {
|
||||
enum QueryResult {
|
||||
case success(result: ArticleProps)
|
||||
case error(error: String)
|
||||
|
|
@ -103,7 +27,7 @@ public extension DataService {
|
|||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
state: try $0.state()?.rawValue ?? "SUCCEEDED",
|
||||
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
|
|
@ -122,8 +46,7 @@ public extension DataService {
|
|||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
),
|
||||
htmlContent: try $0.content(),
|
||||
highlights: try $0.highlights(selection: highlightSelection.list),
|
||||
contentStatus: try $0.state()
|
||||
highlights: try $0.highlights(selection: highlightSelection.list)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +68,7 @@ public extension DataService {
|
|||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
let result: ArticleProps = try await withCheckedThrowingContinuation { continuation in
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: ContentFetchError.network)
|
||||
|
|
@ -154,244 +77,11 @@ public extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
// Default to suceeded since older links will return a nil status
|
||||
// (but the content is almost always there)
|
||||
let status = result.contentStatus ?? .succeeded
|
||||
if status == .failed {
|
||||
continuation.resume(throwing: ContentFetchError.badData)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: result)
|
||||
case .error:
|
||||
continuation.resume(throwing: ContentFetchError.badData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let articleContent = ArticleContent(
|
||||
title: result.item.title,
|
||||
htmlContent: result.htmlContent,
|
||||
highlightsJSONString: result.highlights.asJSONString,
|
||||
contentStatus: result.item.isPDF ? .succeeded : .make(from: result.contentStatus)
|
||||
)
|
||||
|
||||
if result.contentStatus == .succeeded || result.item.isPDF {
|
||||
do {
|
||||
try await persistArticleContent(
|
||||
item: result.item,
|
||||
htmlContent: result.htmlContent,
|
||||
highlights: result.highlights
|
||||
)
|
||||
} 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
|
||||
}
|
||||
|
||||
internal func persistArticleContent(item: InternalLinkedItem, htmlContent: String, highlights: [InternalHighlight]) async throws {
|
||||
var needsPDFDownload = false
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", item.id)
|
||||
|
||||
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
|
||||
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
|
||||
|
||||
let highlightObjects = highlights.map {
|
||||
$0.asManagedObject(context: self.backgroundContext)
|
||||
}
|
||||
linkedItem.addToHighlights(NSSet(array: highlightObjects))
|
||||
linkedItem.htmlContent = htmlContent
|
||||
linkedItem.id = item.id
|
||||
linkedItem.state = item.state
|
||||
linkedItem.title = item.title
|
||||
linkedItem.createdAt = item.createdAt
|
||||
linkedItem.savedAt = item.savedAt
|
||||
linkedItem.readingProgress = item.readingProgress
|
||||
linkedItem.readingProgressAnchor = Int64(item.readingProgressAnchor)
|
||||
linkedItem.imageURLString = item.imageURLString
|
||||
linkedItem.onDeviceImageURLString = item.onDeviceImageURLString
|
||||
linkedItem.pageURLString = item.pageURLString
|
||||
linkedItem.descriptionText = item.descriptionText
|
||||
linkedItem.publisherURLString = item.publisherURLString
|
||||
linkedItem.author = item.author
|
||||
linkedItem.publishDate = item.publishDate
|
||||
linkedItem.slug = item.slug
|
||||
linkedItem.readAt = item.readAt
|
||||
linkedItem.isArchived = item.isArchived
|
||||
linkedItem.contentReader = item.contentReader
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
|
||||
if 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 item.isPDF, needsPDFDownload {
|
||||
try await fetchPDFData(slug: item.slug, pageURLString: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchPDFData(slug: String, pageURLString: String) async throws -> URL? {
|
||||
guard let url = URL(string: pageURLString) else {
|
||||
throw BasicError.message(messageText: "No PDF URL found")
|
||||
}
|
||||
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
|
||||
}
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "pdfFetch failed. no data received.")
|
||||
}
|
||||
|
||||
var localPdfURL: URL?
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(UUID().uuidString + ".pdf")
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
|
||||
|
||||
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
|
||||
guard let linkedItem = linkedItem else {
|
||||
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
|
||||
do {
|
||||
try data.write(to: tempPath)
|
||||
let localPDF = try PDFUtils.moveToLocal(url: tempPath)
|
||||
localPdfURL = PDFUtils.localPdfURL(filename: localPDF)
|
||||
linkedItem.tempPDFURL = nil
|
||||
linkedItem.localPDF = localPDF
|
||||
try self?.backgroundContext.save()
|
||||
} catch {
|
||||
self?.backgroundContext.rollback()
|
||||
let errorMessage = "pdfFetch failed. core data save failed."
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return localPdfURL
|
||||
}
|
||||
|
||||
internal func cachedArticleContent(itemID: String) async -> ArticleContent? {
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func syncUnsyncedArticleContent(itemID: String) async {
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = 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
|
||||
}
|
||||
|
||||
if let id = id, let url = url, let title = title,
|
||||
let serverSyncStatus = serverSyncStatus,
|
||||
serverSyncStatus == ServerSyncStatus.needsCreation.rawValue
|
||||
{
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func fetchLinkedItems(
|
||||
limit: Int,
|
||||
searchQuery: String?,
|
||||
cursor: String?
|
||||
) async throws -> HomeFeedData {
|
||||
// Send offline changes to server before fetching items
|
||||
try? await syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
struct InternalHomeFeedData {
|
||||
let items: [InternalLinkedItem]
|
||||
let cursor: String?
|
||||
}
|
||||
|
||||
enum QueryResult {
|
||||
case success(result: InternalHomeFeedData)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticlesResult> {
|
||||
try $0.on(
|
||||
articlesError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
articlesSuccess: .init {
|
||||
QueryResult.success(
|
||||
result: InternalHomeFeedData(
|
||||
items: try $0.edges(selection: articleEdgeSelection.list),
|
||||
cursor: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.endCursor()
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.articles(
|
||||
after: OptionalArgument(cursor),
|
||||
first: OptionalArgument(limit),
|
||||
includePending: OptionalArgument(true),
|
||||
query: OptionalArgument(searchQuery),
|
||||
sharedOnly: .present(false),
|
||||
sort: OptionalArgument(
|
||||
InputObjects.SortParams(
|
||||
by: .updatedTime,
|
||||
order: .present(.descending)
|
||||
)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { [weak self] queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
if let context = self?.backgroundContext, let items = result.items.persist(context: context) {
|
||||
continuation.resume(returning: HomeFeedData(items: items.map(\.objectID), cursor: result.cursor))
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
|
||||
}
|
||||
case .error:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLinkedItem(username: String, itemID: String) async throws -> NSManagedObjectID {
|
||||
struct ArticleProps {
|
||||
let item: InternalLinkedItem
|
||||
}
|
||||
|
||||
enum QueryResult {
|
||||
case success(result: InternalLinkedItem)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let articleSelection = Selection.Article {
|
||||
InternalLinkedItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
state: try $0.state()?.rawValue ?? "SUCCEEDED",
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
onDeviceImageURLString: nil,
|
||||
documentDirectoryPath: nil,
|
||||
pageURLString: try $0.url(),
|
||||
descriptionText: try $0.description(),
|
||||
publisherURLString: try $0.originalArticleUrl(),
|
||||
siteName: try $0.siteName(),
|
||||
author: try $0.author(),
|
||||
publishDate: try $0.publishedAt()?.value,
|
||||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleSelection))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
// 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
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { [weak self] queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: ContentFetchError.network)
|
||||
return
|
||||
}
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
if let context = self?.backgroundContext, let item = [result].persist(context: context)?.first {
|
||||
continuation.resume(returning: item.objectID)
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
|
||||
}
|
||||
case .error:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let libraryArticleSelection = Selection.Article {
|
||||
InternalLinkedItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
state: try $0.state()?.rawValue ?? "SUCCEEDED",
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
onDeviceImageURLString: nil,
|
||||
documentDirectoryPath: nil,
|
||||
pageURLString: try $0.url(),
|
||||
descriptionText: try $0.description(),
|
||||
publisherURLString: try $0.originalArticleUrl(),
|
||||
siteName: try $0.siteName(),
|
||||
author: try $0.author(),
|
||||
publishDate: try $0.publishedAt()?.value,
|
||||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private let articleEdgeSelection = Selection.ArticleEdge {
|
||||
try $0.node(selection: libraryArticleSelection)
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
struct InternalLinkedItemQueryResult {
|
||||
let items: [InternalLinkedItem]
|
||||
let cursor: String?
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
/// Performs GraphQL request to fetch `InternalLinkedItem`s and a cursor value
|
||||
/// - Parameters:
|
||||
/// - limit: max number of items to return
|
||||
/// - searchQuery: search query used by server to narrow search
|
||||
/// - cursor: cursor to indicate batch cutoff
|
||||
/// - Returns: `InternalLinkedItemQueryResult` or a `ContentFetchError` if request fails.
|
||||
func fetchLinkedItems(
|
||||
limit: Int,
|
||||
searchQuery: String?,
|
||||
cursor: String?
|
||||
) async throws -> InternalLinkedItemQueryResult {
|
||||
enum QueryResult {
|
||||
case success(result: InternalLinkedItemQueryResult)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.SearchResult> {
|
||||
try $0.on(
|
||||
searchError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
searchSuccess: .init {
|
||||
QueryResult.success(
|
||||
result: InternalLinkedItemQueryResult(
|
||||
items: try $0.edges(selection: searchItemEdgeSelection.list),
|
||||
cursor: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.endCursor()
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.search(
|
||||
after: OptionalArgument(cursor),
|
||||
first: OptionalArgument(limit),
|
||||
query: OptionalArgument(searchQuery),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: ContentFetchError.network)
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
continuation.resume(returning: result)
|
||||
case let .error(error):
|
||||
continuation.resume(throwing: ContentFetchError.unknown(description: error.description))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs GraphQL request to fetch a single `InternalLinkedItem`
|
||||
/// - Parameters:
|
||||
/// - username: the Viewer's username
|
||||
/// - itemID: id of the item being requested
|
||||
/// - Returns: Returns an `InternalLinkedItem` or throws a `ContentFetchError` if
|
||||
/// request could not be completed
|
||||
func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLinkedItem {
|
||||
struct ArticleProps {
|
||||
let item: InternalLinkedItem
|
||||
}
|
||||
|
||||
enum QueryResult {
|
||||
case success(result: InternalLinkedItem)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: libraryArticleSelection))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
// 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
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: ContentFetchError.network)
|
||||
return
|
||||
}
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
continuation.resume(returning: result)
|
||||
case let .error(error):
|
||||
continuation.resume(throwing: ContentFetchError.unknown(description: error.description))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let libraryArticleSelection = Selection.Article {
|
||||
InternalLinkedItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
onDeviceImageURLString: nil,
|
||||
documentDirectoryPath: nil,
|
||||
pageURLString: try $0.url(),
|
||||
descriptionText: try $0.description(),
|
||||
publisherURLString: try $0.originalArticleUrl(),
|
||||
siteName: try $0.siteName(),
|
||||
author: try $0.author(),
|
||||
publishDate: try $0.publishedAt()?.value,
|
||||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private let searchItemSelection = Selection.SearchItem {
|
||||
InternalLinkedItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt()?.value ?? Date(),
|
||||
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
onDeviceImageURLString: nil,
|
||||
documentDirectoryPath: nil,
|
||||
pageURLString: try $0.url(),
|
||||
descriptionText: try $0.description(),
|
||||
publisherURLString: try $0.originalArticleUrl(),
|
||||
siteName: try $0.siteName(),
|
||||
author: try $0.author(),
|
||||
publishDate: try $0.publishedAt()?.value,
|
||||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private let searchItemEdgeSelection = Selection.SearchItemEdge {
|
||||
try $0.node(selection: searchItemSelection)
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ struct InternalLinkedItem {
|
|||
let savedAt: Date
|
||||
let readAt: Date?
|
||||
let updatedAt: Date
|
||||
let state: String
|
||||
let state: ArticleContentStatus
|
||||
var readingProgress: Double
|
||||
var readingProgressAnchor: Int
|
||||
let imageURLString: String?
|
||||
|
|
@ -31,7 +31,7 @@ struct InternalLinkedItem {
|
|||
if let contentReader = contentReader {
|
||||
return contentReader == "PDF"
|
||||
}
|
||||
return (pageURLString ?? "").hasSuffix("pdf")
|
||||
return pageURLString.hasSuffix("pdf")
|
||||
}
|
||||
|
||||
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItem {
|
||||
|
|
@ -44,7 +44,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.savedAt = savedAt
|
||||
linkedItem.updatedAt = updatedAt
|
||||
linkedItem.readAt = readAt
|
||||
linkedItem.state = state
|
||||
linkedItem.state = state.rawValue
|
||||
linkedItem.readingProgress = readingProgress
|
||||
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
|
||||
linkedItem.imageURLString = imageURLString
|
||||
|
|
@ -70,7 +70,7 @@ struct InternalLinkedItem {
|
|||
}
|
||||
|
||||
extension Sequence where Element == InternalLinkedItem {
|
||||
func persist(context: NSManagedObjectContext) -> [LinkedItem]? {
|
||||
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
|
||||
var linkedItems: [LinkedItem]?
|
||||
context.performAndWait {
|
||||
linkedItems = map { $0.asManagedObject(inContext: context) }
|
||||
|
|
@ -83,7 +83,12 @@ extension Sequence where Element == InternalLinkedItem {
|
|||
print("Failed to save LinkedItems: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return linkedItems
|
||||
|
||||
if let linkedItems = linkedItems {
|
||||
return linkedItems.map(\.objectID)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +109,7 @@ extension JSONArticle {
|
|||
savedAt: savedAt,
|
||||
readAt: readAt,
|
||||
updatedAt: updatedAt,
|
||||
state: "SUCCEEDED",
|
||||
state: .succeeded,
|
||||
readingProgress: readingProgressPercent,
|
||||
readingProgressAnchor: readingProgressAnchorIndex,
|
||||
imageURLString: image,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//
|
||||
// PDFUtils.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/3/22.
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
import Foundation
|
||||
import QuickLookThumbnailing
|
||||
|
|
@ -47,13 +40,6 @@ public enum PDFUtils {
|
|||
return false
|
||||
}
|
||||
|
||||
public static func tempExists(tempPDFURL: URL?) -> Bool {
|
||||
if let tempPDFURL = tempPDFURL {
|
||||
return FileManager.default.fileExists(atPath: tempPDFURL.path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func titleFromPdfFile(_ urlStr: String) -> String {
|
||||
let url = URL(string: urlStr)
|
||||
if let url = url {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ public final class WebView: WKWebView {
|
|||
}
|
||||
|
||||
public func updateMaxWidthPercentage() {
|
||||
if let maxWidthPercentage = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) as? Int {
|
||||
if let maxWidthPercentage = UserDefaults.standard.value(
|
||||
forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue
|
||||
) as? Int {
|
||||
dispatchEvent(.updateMaxWidthPercentage(maxWidthPercentage: maxWidthPercentage))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ struct CornerRadiusStyle: ViewModifier {
|
|||
var corners = UIRectCorner.allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
let path = UIBezierPath(
|
||||
roundedRect: rect,
|
||||
byRoundingCorners: corners,
|
||||
cornerRadii: CGSize(width: radius, height: radius)
|
||||
)
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ public struct Snackbar: View {
|
|||
.cornerRadius(5)
|
||||
.offset(x: 0, y: -8)
|
||||
.shadow(color: .gray, radius: 2)
|
||||
.animation(Animation.spring())
|
||||
.animation(.spring(), value: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ final class UtilsTests: XCTestCase {
|
|||
XCTAssertEqual(normalizeURL("https://omnivore.app/"), "https://omnivore.app")
|
||||
|
||||
// utm_ removed
|
||||
// swiftlint:disable:next line_length
|
||||
XCTAssertEqual(normalizeURL("https://omnivore.app/?aa=a&bb=b&utm_track=track&cc=c"), "https://omnivore.app?aa=a&bb=b&cc=c")
|
||||
|
||||
// query params sorted
|
||||
|
|
|
|||
Loading…
Reference in a new issue