Merge pull request #816 from omnivore-app/refactor/article-content-fetches

Refactor/article content fetches
This commit is contained in:
Satindar Dhillon 2022-06-20 08:36:35 -07:00 committed by GitHub
commit cc484fd49a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 774 additions and 776 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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
}
}

View file

@ -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"]

View file

@ -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
}

View file

@ -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
}()

View file

@ -168,7 +168,7 @@ struct CreateProfileView: View {
.foregroundColor(.red)
}
}
.animation(.default)
.animation(.default, value: 0.35)
VStack(alignment: .leading, spacing: 6) {
Text("Bio (optional)")

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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")
}
}
}

View file

@ -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)

View file

@ -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)")
}

View file

@ -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
}
}

View file

@ -26,6 +26,7 @@ extension DataService {
}
}
// swiftlint:disable:next function_body_length
func syncLabelCreation(label: InternalLinkedItemLabel) {
enum MutationResult {
case saved(label: InternalLinkedItemLabel)

View file

@ -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() }))
}
)
}

View file

@ -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)

View file

@ -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)

View file

@ -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 {

View file

@ -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:

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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 {

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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