Merge pull request #760 from omnivore-app/fix/ios-scrape-failures

Fix issue with ShareExtension.js not executing on iOS
This commit is contained in:
Jackson Harper 2022-06-09 14:14:33 -07:00 committed by GitHub
commit 0529c23c00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 568 additions and 313 deletions

View file

@ -18,7 +18,7 @@ class ExtensionSaveService {
self.queue = OperationQueue()
}
private func queueSaveOperation(_ pageScrape: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
private func queueSaveOperation(_ pageScrape: PageScrapePayload, shareExtensionViewModel: ShareExtensionChildViewModel) {
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
guard !expiring else {
self.queue.cancelAllOperations()
@ -26,14 +26,14 @@ class ExtensionSaveService {
return
}
let operation = SaveOperation(pageScrapePayload: pageScrape, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
let operation = SaveOperation(pageScrapePayload: pageScrape, shareExtensionViewModel: shareExtensionViewModel)
self.queue.addOperation(operation)
self.queue.waitUntilAllOperationsAreFinished()
}
}
public func save(_ extensionContext: NSExtensionContext, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
public func save(_ extensionContext: NSExtensionContext, shareExtensionViewModel: ShareExtensionChildViewModel) {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
guard let self = self else { return }
@ -68,8 +68,8 @@ class ExtensionSaveService {
}
}
}
self.queueSaveOperation(payload, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
case let .failure:
self.queueSaveOperation(payload, shareExtensionViewModel: shareExtensionViewModel)
case .failure:
DispatchQueue.main.async {
shareExtensionViewModel.status = .failed(error: .unknown(description: "Could not retrieve content"))
}
@ -78,7 +78,6 @@ class ExtensionSaveService {
}
class SaveOperation: Operation, URLSessionDelegate {
let requestId: String
let services: Services
let pageScrapePayload: PageScrapePayload
let shareExtensionViewModel: ShareExtensionChildViewModel
@ -92,9 +91,8 @@ class ExtensionSaveService {
case finished
}
init(pageScrapePayload: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
init(pageScrapePayload: PageScrapePayload, shareExtensionViewModel: ShareExtensionChildViewModel) {
self.pageScrapePayload = pageScrapePayload
self.requestId = requestId
self.shareExtensionViewModel = shareExtensionViewModel
self.state = .created
@ -138,7 +136,7 @@ class ExtensionSaveService {
queue = OperationQueue()
Task {
await persist(services: self.services, pageScrapePayload: self.pageScrapePayload, requestId: self.requestId)
await persist(services: self.services, pageScrapePayload: self.pageScrapePayload)
}
}
@ -146,38 +144,48 @@ class ExtensionSaveService {
super.cancel()
}
private func updateStatus(newStatus: ShareExtensionStatus) {
private func updateStatus(_ requestId: String?, newStatus: ShareExtensionStatus) {
DispatchQueue.main.async {
self.shareExtensionViewModel.status = newStatus
if let requestId = requestId {
self.shareExtensionViewModel.requestId = requestId
}
}
}
private func persist(services: Services, pageScrapePayload: PageScrapePayload, requestId: String) async {
private func persist(services: Services, pageScrapePayload: PageScrapePayload) async {
var requestId = shareExtensionViewModel.requestId
do {
try await services.dataService.persistPageScrapePayload(pageScrapePayload, requestId: requestId)
} catch {
updateStatus(newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")))
updateStatus(nil, newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")))
return
}
do {
updateStatus(newStatus: .saved)
updateStatus(requestId, newStatus: .saved)
switch pageScrapePayload.contentType {
case .none:
try await services.dataService.syncUrl(id: requestId, url: pageScrapePayload.url)
requestId = try await services.dataService.createPageFromUrl(id: requestId, url: pageScrapePayload.url)
case let .pdf(localUrl):
try await services.dataService.syncPdf(id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url)
try await services.dataService.createPageFromPdf(id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url)
case let .html(html, title, _):
try await services.dataService.syncPage(id: requestId, originalHtml: html, title: title, url: pageScrapePayload.url)
requestId = try await services.dataService.createPage(
id: requestId,
originalHtml: html,
title: title,
url: pageScrapePayload.url
)
}
} catch {
updateStatus(newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")))
updateStatus(nil, newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")))
return
}
updateStatus(newStatus: .synced)
updateStatus(requestId, newStatus: .synced)
state = .finished
}
}

View file

@ -22,13 +22,11 @@ public extension PlatformViewController {
public class ShareExtensionViewModel: ObservableObject {
@Published var title: String?
@Published var status: ShareExtensionStatus = .processing
@Published var debugText: String?
let saveService = ExtensionSaveService()
let requestId = UUID().uuidString.lowercased()
func handleReadNowAction(extensionContext: NSExtensionContext?) {
func handleReadNowAction(requestId: String, extensionContext: NSExtensionContext?) {
#if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)")
@ -40,15 +38,11 @@ public class ShareExtensionViewModel: ObservableObject {
func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) {
if let extensionContext = extensionContext {
saveService.save(extensionContext, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
saveService.save(extensionContext, shareExtensionViewModel: shareExtensionViewModel)
} else {
updateStatus(.failed(error: .unknown(description: "Internal Error")))
}
}
private func updateStatus(_ newStatus: ShareExtensionStatus) {
DispatchQueue.main.async {
self.status = newStatus
DispatchQueue.main.async {
shareExtensionViewModel.status = .failed(error: .unknown(description: "Internal Error"))
}
}
}
}
@ -62,7 +56,7 @@ struct ShareExtensionView: View {
ShareExtensionChildView(
viewModel: childViewModel,
onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
readNowButtonAction: { viewModel.handleReadNowAction(requestId: $0, extensionContext: extensionContext) },
dismissButtonTappedAction: { _, _ in
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}

View file

@ -21,15 +21,14 @@ import Utils
let url: URL
}
let pdfURL: URL
let viewModel: PDFViewerViewModel
@StateObject var pdfStateObject = PDFStateObject()
@State var readerView: Bool = false
@State private var shareLink: ShareLink?
@State private var errorMessage: String?
init(remoteURL: URL, viewModel: PDFViewerViewModel) {
self.pdfURL = viewModel.pdfItem.localPdfURL ?? remoteURL
init(viewModel: PDFViewerViewModel) {
self.viewModel = viewModel
}
@ -135,12 +134,21 @@ import Utils
.sheet(item: $shareLink) {
ShareSheet(activityItems: [$0.url])
}
} else if let errorMessage = errorMessage {
Text(errorMessage)
} else {
ProgressView()
.task {
let document = HighlightedDocument(url: pdfURL, viewModel: viewModel)
pdfStateObject.document = document
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
// NOTE: the issue here is the PDF is downloaded, but saved to a URL we don't know about
// because it is changed.
let pdfURL = await viewModel.downloadPDF(dataService: dataService)
if let pdfURL = pdfURL {
let document = HighlightedDocument(url: pdfURL, viewModel: viewModel)
pdfStateObject.document = document
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
} else {
errorMessage = "Unable to download PDF: \(pdfURL)"
}
}
}
}

View file

@ -9,7 +9,6 @@ public final class PDFViewerViewModel: ObservableObject {
@Published public var readerView: Bool = false
public let pdfItem: PDFItem
private var storedURL: URL?
var subscriptions = Set<AnyCancellable>()
@ -81,4 +80,25 @@ 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? {
do {
if itemDownloaded {
return pdfItem.localPdfURL
}
if let localURL = try await dataService.fetchPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL) {
return localURL
}
} catch {
print("error downloading PDF", error)
}
return nil
}
}

View file

@ -300,7 +300,7 @@ struct LinkItemDetailView: View {
@ViewBuilder private var fixedNavBarReader: some View {
if let pdfItem = viewModel.pdfItem, let pdfURL = pdfItem.pdfURL {
#if os(iOS)
PDFViewer(remoteURL: pdfURL, viewModel: PDFViewerViewModel(pdfItem: pdfItem))
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
PDFWrapperView(pdfURL: pdfURL)

View file

@ -6,9 +6,8 @@ import WebKit
#if os(iOS)
struct WebReader: UIViewRepresentable {
let htmlContent: String
let highlightsJSONString: String
let item: LinkedItem
let articleContent: ArticleContent
let openLinkAction: (URL) -> Void
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
let navBarVisibilityRatioUpdater: (Double) -> Void
@ -143,9 +142,8 @@ import WebKit
webView.loadHTMLString(
WebReaderContent(
htmlContent: htmlContent,
highlightsJSONString: highlightsJSONString,
item: item,
articleContent: articleContent,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
fontSize: fontSize(),
lineHeight: lineHeight(),

View file

@ -89,6 +89,7 @@ import WebKit
Button(
action: {
dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived)
presentationMode.wrappedValue.dismiss()
Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox")
},
label: {
@ -122,6 +123,7 @@ import WebKit
Button("Remove Link", role: .destructive) {
Snackbar.show(message: "Link removed")
dataService.removeLink(objectID: item.objectID)
presentationMode.wrappedValue.dismiss()
}
Button("Cancel", role: .cancel, action: {})
}
@ -134,9 +136,8 @@ import WebKit
ZStack {
if let articleContent = viewModel.articleContent {
WebReader(
htmlContent: articleContent.htmlContent,
highlightsJSONString: articleContent.highlightsJSONString,
item: item,
articleContent: articleContent,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)

View file

@ -7,16 +7,14 @@ struct WebReaderContent {
let textFontSize: Int
let lineHeight: Int
let margin: Int
let htmlContent: String
let highlightsJSONString: String
let item: LinkedItem
let themeKey: String
let fontFamily: WebFont
let articleContent: ArticleContent
init(
htmlContent: String,
highlightsJSONString: String,
item: LinkedItem,
articleContent: ArticleContent,
isDark: Bool,
fontSize: Int,
lineHeight: Int,
@ -26,11 +24,10 @@ struct WebReaderContent {
self.textFontSize = fontSize
self.lineHeight = lineHeight
self.margin = margin
self.htmlContent = htmlContent
self.highlightsJSONString = highlightsJSONString
self.item = item
self.themeKey = isDark ? "Gray" : "LightGray"
self.fontFamily = fontFamily
self.articleContent = articleContent
}
// swiftlint:disable line_length
@ -52,7 +49,7 @@ struct WebReaderContent {
<body>
<div id="root" />
<div id='_omnivore-htmlContent' style="display: none;">
\(htmlContent)
\(articleContent.htmlContent)
</div>
<script type="text/javascript">
window.omnivoreEnv = {
@ -70,14 +67,14 @@ struct WebReaderContent {
savedAt: \(savedAt),
publishedAt: \(publishedAt),
url: `\(item.unwrappedPageURLString)`,
title: `\(item.unwrappedTitle.replacingOccurrences(of: "`", with: "\\`"))`,
title: `\(articleContent.title.replacingOccurrences(of: "`", with: "\\`"))`,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "\(item.unwrappedPageURLString)",
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
labels: \(item.labelsJSONString),
highlights: \(highlightsJSONString),
highlights: \(articleContent.highlightsJSONString),
}
window.fontSize = \(textFontSize)

View file

@ -1,3 +1,4 @@
import CoreData
import Models
import Services
import SwiftUI
@ -24,9 +25,32 @@ import Utils
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: requestID)
await fetchLinkedItem(dataService: dataService, requestID: requestID, username: username)
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(
@ -34,34 +58,67 @@ import Utils
requestID: String,
username: String,
requestCount: Int = 1
) async {
) async -> LinkedItem? {
guard requestCount < 7 else {
errorMessage = "Unable to fetch item."
return
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 {
item = linkedItem
print(" - FROM DATA SERVICE", linkedItem)
return linkedItem
} else {
errorMessage = "Unable to fetch item."
}
return
return nil
}
// Retry on error
do {
let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000)
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
await fetchLinkedItem(
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: requestID,
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)
}
func trackReadEvent() {
@ -84,12 +141,20 @@ import Utils
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
public var body: some View {
if let item = viewModel.item {
WebReaderContainerView(item: item)
.navigationBarHidden(true)
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
.task { viewModel.trackReadEvent() }
if let item = viewModel.item, item.isReadyToRead {
if let pdfItem = PDFItem.make(item: item), let urlStr = item.pageURLString, let remoteUrl = URL(string: urlStr) {
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
.navigationBarHidden(true)
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
.task { viewModel.trackReadEvent() }
} else {
WebReaderContainerView(item: item)
.navigationBarHidden(true)
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
.task { viewModel.trackReadEvent() }
}
} else if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
} else {

View file

@ -24,12 +24,13 @@
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="contentReader" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdId" optional="YES" attributeType="String"/>
<attribute name="descriptionText" optional="YES" attributeType="String"/>
<attribute name="htmlContent" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="imageURLString" optional="YES" attributeType="String"/>
<attribute name="isArchived" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="localPdfURL" optional="YES" attributeType="String"/>
<attribute name="localPDF" optional="YES" attributeType="String"/>
<attribute name="onDeviceImageURLString" optional="YES" attributeType="String"/>
<attribute name="originalHtml" optional="YES" attributeType="String"/>
<attribute name="pageURLString" attributeType="String"/>
@ -42,6 +43,7 @@
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="siteName" optional="YES" attributeType="String"/>
<attribute name="slug" attributeType="String"/>
<attribute name="state" optional="YES" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
@ -89,7 +91,7 @@
</entity>
<elements>
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="404"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="434"/>
<element name="LinkedItemLabel" positionX="-36" positionY="18" width="128" height="134"/>
<element name="NewsletterEmail" positionX="0" positionY="180" width="128" height="74"/>
<element name="Viewer" positionX="45" positionY="234" width="128" height="89"/>

View file

@ -8,15 +8,18 @@ public enum ArticleContentStatus {
}
public struct ArticleContent {
public let title: String
public let htmlContent: String
public let highlightsJSONString: String
public let contentStatus: ArticleContentStatus
public init(
title: String,
htmlContent: String,
highlightsJSONString: String,
contentStatus: ArticleContentStatus
) {
self.title = title
self.htmlContent = htmlContent
self.highlightsJSONString = highlightsJSONString
self.contentStatus = contentStatus

View file

@ -1,5 +1,6 @@
import CoreData
import Foundation
import Utils
public struct HomeFeedData { // TODO: rename this
public let items: [NSManagedObjectID]
@ -43,6 +44,15 @@ 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)
}
// Check the state and whether we have HTML
return state == "SUCCEEDED"
}
var isPDF: Bool {
if let contentReader = contentReader {
return contentReader == "PDF"

View file

@ -1,11 +1,12 @@
import CoreData
import Foundation
import Utils
public struct PDFItem {
public let objectID: NSManagedObjectID
public let itemID: String
public let pdfURL: URL?
public let localPdfURL: URL?
public let localPDF: String?
public let title: String
public let slug: String
public let readingProgress: Double
@ -22,7 +23,7 @@ public struct PDFItem {
objectID: item.objectID,
itemID: item.unwrappedID,
pdfURL: URL(string: item.unwrappedPageURLString),
localPdfURL: item.localPdfURL.flatMap { URL(string: $0) },
localPDF: item.localPDF,
title: item.unwrappedID,
slug: item.unwrappedSlug,
readingProgress: item.readingProgress,
@ -33,4 +34,11 @@ public struct PDFItem {
highlights: item.highlights.asArray(of: Highlight.self)
)
}
public var localPdfURL: URL? {
if let localPDF = localPDF {
return PDFUtils.localPdfURL(filename: localPDF)
}
return nil
}
}

View file

@ -42,9 +42,9 @@ public extension LinkedItemSort {
var sortDescriptors: [NSSortDescriptor] {
switch self {
case .newest /* , .relevance */:
return [NSSortDescriptor(keyPath: \LinkedItem.createdAt, ascending: false)]
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)]
case .oldest:
return [NSSortDescriptor(keyPath: \LinkedItem.createdAt, ascending: true)]
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: true)]
// case .recentlyRead:
// return [NSSortDescriptor(keyPath: \LinkedItem.updatedAt, ascending: false)]
case .recentlyPublished:

View file

@ -53,7 +53,6 @@ public enum PageScraper {
var pageScrapePayload: PageScrapePayload?
let PDFKey = UTType.pdf.identifier
let publicFileKey = UTType.fileURL.identifier
let propertyListKey = UTType.propertyList.identifier
let group = DispatchGroup()
@ -257,7 +256,20 @@ 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)

View file

@ -121,9 +121,11 @@ public final class DataService: ObservableObject {
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
linkedItem.createdId = requestId
linkedItem.id = existingItem?.unwrappedID ?? requestId
linkedItem.title = normalizedURL
linkedItem.pageURLString = normalizedURL
linkedItem.state = existingItem != nil ? existingItem?.state : "PROCESSING"
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsCreation.rawValue)
linkedItem.savedAt = currentTime
linkedItem.createdAt = currentTime
@ -136,8 +138,8 @@ public final class DataService: ObservableObject {
linkedItem.author = nil
linkedItem.publishDate = nil
if let currentViewer = self.currentViewer {
linkedItem.slug = "\(currentViewer)/\(requestId)"
if let currentViewer = self.currentViewer, let username = currentViewer.username {
linkedItem.slug = "\(username)/\(requestId)"
} else {
// Technically this is invalid, but I don't think slug is used at all locally anymore
linkedItem.slug = requestId
@ -146,18 +148,15 @@ public final class DataService: ObservableObject {
switch pageScrape.contentType {
case let .pdf(localUrl):
linkedItem.contentReader = "PDF"
linkedItem.localPdfURL = localUrl.absoluteString
linkedItem.title = PDFUtils.titleFromPdfFile(pageScrape.url)
// let thumbnailUrl = PDFUtils.thumbnailUrl(localUrl: localUrl)
// linkedItem.imageURLString = await PDFUtils.createThumbnailFor(inputUrl: localUrl, at: thumbnailUrl)
print("PERSISTING PDF", localUrl)
linkedItem.localPDF = try PDFUtils.copyToLocal(url: localUrl)
case let .html(html: html, title: title, iconURL: iconURL):
linkedItem.contentReader = "WEB"
linkedItem.originalHtml = html
linkedItem.imageURLString = iconURL
linkedItem.title = title ?? PDFUtils.titleFromPdfFile(pageScrape.url)
case .none:
print("SAVING URL", linkedItem.unwrappedPageURLString)
linkedItem.contentReader = "WEB"
}

View file

@ -19,7 +19,7 @@ public extension DataService {
let input = InputObjects.UploadFileRequestInput(
clientRequestId: OptionalArgument(id),
contentType: "application/pdf",
createPageEntry: OptionalArgument(false),
createPageEntry: OptionalArgument(true),
url: url
)
@ -83,6 +83,12 @@ 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 {
@ -95,23 +101,7 @@ public extension DataService {
}
}
func uploadFileInBackground(id: String, localPdfURL: String?, url: URL, usingSession session: URLSession) -> URLSessionTask? {
if let localPdfURL = localPdfURL, let localUrl = URL(string: localPdfURL) {
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("application/pdf", forHTTPHeaderField: "content-type")
request.setValue(id, forHTTPHeaderField: "clientRequestId")
let task = session.uploadTask(with: request, fromFile: localUrl)
return task
} else {
// TODO: How should we handle this scenario?
return nil
}
}
// swiftlint:disable:next line_length
func saveFilePublisher(requestId: String, uploadFileId: String, url: String) async throws {
func saveFilePublisher(requestId: String, uploadFileId: String, url: String) async throws -> String? {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
@ -127,7 +117,13 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") }
saveSuccess: .init {
if let requestId = try? $0.clientRequestId(), let url = try? $0.url() {
return .saved(requestId: requestId, url: url)
} else {
return .error(errorCode: .unknown)
}
}
)
}
@ -148,8 +144,8 @@ public extension DataService {
}
switch payload.data {
case .saved:
continuation.resume()
case let .saved(requestId: requestId, url: _):
continuation.resume(returning: requestId)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:

View file

@ -3,7 +3,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func savePage(id: String, url: String, title: String, originalHtml: String) async throws {
func savePage(id: String, url: String, title: String, originalHtml: String) async throws -> String? {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
@ -20,7 +20,13 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") }
saveSuccess: .init {
if let requestId = try? $0.clientRequestId(), let url = try? $0.url() {
return .saved(requestId: requestId, url: url)
} else {
return .error(errorCode: .unknown)
}
}
)
}
@ -42,8 +48,8 @@ public extension DataService {
return
}
switch payload.data {
case .saved:
continuation.resume()
case let .saved(requestId: requestId, url: _):
continuation.resume(returning: requestId)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:

View file

@ -3,7 +3,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func saveURL(id: String, url: String) async throws {
func saveURL(id: String, url: String) async throws -> String? {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
@ -18,7 +18,13 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") }
saveSuccess: .init {
if let requestId = try? $0.clientRequestId(), let url = try? $0.url() {
return .saved(requestId: requestId, url: url)
} else {
return .error(errorCode: .unknown)
}
}
)
}
@ -41,8 +47,8 @@ public extension DataService {
}
switch payload.data {
case .saved:
continuation.resume()
case let .saved(requestId: requestId, url: _):
continuation.resume(returning: requestId)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:

View file

@ -1,6 +1,7 @@
import CoreData
import Foundation
import Models
import Utils
public extension DataService {
internal func syncOfflineItemsWithServerIfNeeded() async throws {
@ -35,27 +36,36 @@ public extension DataService {
}
}
private func updateLinkedItemStatus(id: String, status: ServerSyncStatus) async throws {
try backgroundContext.performAndWait {
private func updateLinkedItemStatus(id: String, newId: String?, status: ServerSyncStatus) async throws {
backgroundContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
guard let linkedItem = (try? backgroundContext.fetch(fetchRequest))?.first else { return }
if let newId = newId {
linkedItem.id = newId
}
linkedItem.serverSyncStatus = Int64(status.rawValue)
}
}
func syncPdf(id: String, localPdfURL: URL, url: String) async throws {
func createPageFromPdf(id: String, localPdfURL: URL, url: String) async throws {
do {
try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing)
let uploadRequest = try await uploadFileRequest(id: id, url: url)
if let urlString = uploadRequest.urlString, let uploadUrl = URL(string: urlString) {
let attr = try? FileManager.default.attributesOfItem(atPath: localPdfURL.path)
if let attr = attr {
print("ATTR", attr[.size])
}
try await uploadFile(id: id, localPdfURL: localPdfURL, url: uploadUrl)
// try await services.dataService.saveFilePublisher(requestId: requestId, uploadFileId: uploadFileID, url: url)
} else {
throw SaveArticleError.badData
}
try await updateLinkedItemStatus(id: id, status: .isNSync)
try await updateLinkedItemStatus(id: id, newId: nil, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
@ -67,13 +77,16 @@ public extension DataService {
}
}
func syncPage(id: String, originalHtml: String, title: String?, url: String) async throws {
func createPage(id: String, originalHtml: String, title: String?, url: String) async throws -> String {
do {
try await savePage(id: id, url: url, title: title ?? url, originalHtml: originalHtml)
try await updateLinkedItemStatus(id: id, status: .isNSync)
try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing)
let newId = try await savePage(id: id, url: url, title: title ?? url, originalHtml: originalHtml)
try await updateLinkedItemStatus(id: id, newId: newId, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
return newId ?? id
} catch {
backgroundContext.performAndWait {
backgroundContext.rollback()
@ -82,13 +95,16 @@ public extension DataService {
}
}
func syncUrl(id: String, url: String) async throws {
func createPageFromUrl(id: String, url: String) async throws -> String {
do {
try await updateLinkedItemStatus(id: id, status: .isSyncing)
try await saveURL(id: id, url: url)
try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing)
let newId = try await saveURL(id: id, url: url)
try await updateLinkedItemStatus(id: id, newId: newId, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
return newId ?? id
} catch {
backgroundContext.performAndWait {
backgroundContext.rollback()
@ -101,12 +117,10 @@ public extension DataService {
switch item.contentReader {
case "PDF":
let id = item.unwrappedID
let localPdfURL = item.localPdfURL
let url = item.unwrappedPageURLString
if let pdfUrlStr = localPdfURL, let localPdfURL = URL(string: pdfUrlStr) {
if let localPDF = item.localPDF, let localPdfURL = PDFUtils.localPdfURL(filename: localPDF) {
Task {
try await syncPdf(id: id, localPdfURL: localPdfURL, url: url)
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
@ -120,9 +134,9 @@ public extension DataService {
Task {
if let originalHtml = originalHtml {
try await syncPage(id: id, originalHtml: originalHtml, title: title, url: url)
try await createPage(id: id, originalHtml: originalHtml, title: title, url: url)
} else {
try await syncUrl(id: id, url: url)
try await createPageFromUrl(id: id, url: url)
}
}
default:
@ -186,23 +200,4 @@ public extension DataService {
}
}
}
@objc
func locallyCreatedItemSynced(notification: NSNotification) {
print("SYNCED LOCALLY CREATED ITEM", notification)
if let objectId = notification.userInfo?["objectID"] as? String {
do {
try backgroundContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", objectId)
if let existingItem = try? self.backgroundContext.fetch(fetchRequest).first {
existingItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
try self.backgroundContext.save()
}
}
} catch {
print("ERROR", error)
}
}
}
}

View file

@ -2,14 +2,15 @@ import CoreData
import Foundation
import Models
import SwiftGraphQL
import Utils
extension DataService {
struct PendingLink {
public extension DataService {
internal struct PendingLink {
let itemID: String
let retryCount: Int
}
public func prefetchPages(itemIDs: [String], username: String) async {
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 {
@ -17,7 +18,7 @@ extension DataService {
}
}
func prefetchPage(pendingLink: PendingLink, username: String) async {
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 {
@ -40,7 +41,7 @@ extension DataService {
}
}
public func fetchArticleContent(
func fetchArticleContent(
itemID: String,
username: String? = nil,
requestCount: Int = 1
@ -69,7 +70,7 @@ extension DataService {
}
// swiftlint:disable:next function_body_length
public func articleContent(
func articleContent(
username: String,
itemID: String,
useCache: Bool
@ -101,6 +102,7 @@ extension DataService {
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
@ -142,8 +144,8 @@ extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { [weak self] queryResult in
let result: ArticleProps = 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
@ -158,133 +160,134 @@ extension DataService {
continuation.resume(throwing: ContentFetchError.badData)
return
}
if status == .succeeded || result.item.isPDF {
do {
try self?.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
}
continuation.resume(throwing: ContentFetchError.unknown(description: message))
}
}
let articleContent = ArticleContent(
htmlContent: result.htmlContent,
highlightsJSONString: result.highlights.asJSONString,
contentStatus: result.item.isPDF ? .succeeded : .make(from: result.contentStatus)
)
continuation.resume(returning: articleContent)
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
}
func persistArticleContent(item: InternalLinkedItem, htmlContent: String, highlights: [InternalHighlight]) throws {
Task {
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)
internal func persistArticleContent(item: InternalLinkedItem, htmlContent: String, highlights: [InternalHighlight]) async throws {
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 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.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.isArchived = item.isArchived
linkedItem.contentReader = item.contentReader
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.isArchived = item.isArchived
linkedItem.contentReader = item.contentReader
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
}
if linkedItem.isPDF, linkedItem.localPdfURL == nil {
do {
try self.fetchPDFData(slug: linkedItem.unwrappedSlug, pageURLString: linkedItem.unwrappedPageURLString)
} catch {
throw error
}
}
if item.isPDF {
try await fetchPDFData(slug: item.slug, pageURLString: item.pageURLString)
}
do {
try self.backgroundContext.save()
logger.debug("ArticleContent saved succesfully")
} catch {
self.backgroundContext.rollback()
logger.debug("Failed to save ArticleContent")
throw error
}
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) throws {
Task {
guard let url = URL(string: pageURLString) else { return }
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.")
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)
}
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)
}
let subPath = UUID().uuidString + ".pdf" // linkedItem.title.isEmpty ? UUID().uuidString : linkedItem.title
let path = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(subPath)
do {
try data.write(to: path)
linkedItem.localPdfURL = path.absoluteString
try self?.backgroundContext.save()
logger.debug("PDF data saved succesfully")
} catch {
self?.backgroundContext.rollback()
logger.debug("PDF data saved succesfully")
let errorMessage = "pdfFetch failed. core data save failed."
throw BasicError.message(messageText: errorMessage)
}
do {
try data.write(to: tempPath)
let localPDF = try PDFUtils.moveToLocal(url: tempPath)
localPdfURL = PDFUtils.localPdfURL(filename: localPDF)
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
}
func cachedArticleContent(itemID: String) async -> ArticleContent? {
internal func cachedArticleContent(itemID: String) async -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
@ -302,6 +305,7 @@ extension DataService {
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
return ArticleContent(
title: linkedItem.unwrappedTitle,
htmlContent: htmlContent,
highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString,
contentStatus: .succeeded
@ -309,7 +313,7 @@ extension DataService {
}
}
public func syncUnsyncedArticleContent(itemID: String) async {
func syncUnsyncedArticleContent(itemID: String) async {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
@ -334,7 +338,7 @@ extension DataService {
if let id = id, let url = url, let title = title,
let serverSyncStatus = serverSyncStatus,
serverSyncStatus != ServerSyncStatus.isNSync.rawValue
serverSyncStatus == ServerSyncStatus.needsCreation.rawValue
{
do {
if let originalHtml = originalHtml {
@ -342,12 +346,10 @@ extension DataService {
} else {
try await saveURL(id: id, url: url)
}
try backgroundContext.performAndWait {
try backgroundContext.save()
}
} 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

@ -98,6 +98,7 @@ public extension DataService {
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
@ -164,6 +165,7 @@ private let libraryArticleSelection = Selection.Article {
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),

View file

@ -8,6 +8,7 @@ struct InternalLinkedItem {
let createdAt: Date
let savedAt: Date
let updatedAt: Date
let state: String
var readingProgress: Double
var readingProgressAnchor: Int
let imageURLString: String?
@ -41,6 +42,7 @@ struct InternalLinkedItem {
linkedItem.createdAt = createdAt
linkedItem.savedAt = savedAt
linkedItem.updatedAt = updatedAt
linkedItem.state = state
linkedItem.readingProgress = readingProgress
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
linkedItem.imageURLString = imageURLString
@ -98,6 +100,7 @@ extension JSONArticle {
createdAt: createdAt,
savedAt: savedAt,
updatedAt: updatedAt,
state: "SUCCEEDED",
readingProgress: readingProgressPercent,
readingProgressAnchor: readingProgressAnchorIndex,
imageURLString: image,

View file

@ -26,9 +26,13 @@ public func normalizeURL(_ dirtyURL: String) -> String {
}
urlObject.queryItems = urlObject.queryItems?.filter { item in
item.name.starts(with: "utm_")
!item.name.starts(with: "utm_")
}
urlObject.queryItems = urlObject.queryItems?.sorted(by: { first, second in
first.name <= second.name
})
if /* options.removeTrailingSlash */ true {
urlObject.path = urlObject.path.replacingRegex(pattern: "/$", replaceWith: "")
}

View file

@ -11,6 +11,40 @@ import QuickLookThumbnailing
import UIKit
public enum PDFUtils {
public static func copyToLocal(url: URL) throws -> String {
let subPath = UUID().uuidString + ".pdf"
let dest = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(subPath)
try FileManager.default.copyItem(at: url, to: dest)
return subPath
}
public static func moveToLocal(url: URL) throws -> String {
let subPath = UUID().uuidString + ".pdf"
let dest = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(subPath)
try FileManager.default.moveItem(at: url, to: dest)
return subPath
}
public static func localPdfURL(filename: String) -> URL? {
let url = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(filename)
return url
}
public static func exists(filename: String?) -> Bool {
if let filename = filename, let localPdfURL = localPdfURL(filename: filename) {
return FileManager.default.fileExists(atPath: localPdfURL.absoluteString)
}
return false
}
public static func titleFromPdfFile(_ urlStr: String) -> String {
let url = URL(string: urlStr)
if let url = url {

View file

@ -161,6 +161,10 @@ public struct GridCard: View {
}
.onTapGesture { tapHandler() }
}
if let status = item.serverSyncStatus, status != ServerSyncStatus.isNSync.rawValue {
SyncStatusIcon(status: ServerSyncStatus(rawValue: Int(status)) ?? ServerSyncStatus.isNSync)
}
}
.padding(.horizontal, 0)
.padding(.top, 0)

View file

@ -66,7 +66,7 @@ public struct FeedCard: View {
}
}
if item.sortedLabels.count > 0 {
if item.hasLabels {
// Category Labels
ScrollView(.horizontal, showsIndicators: false) {
HStack {

View file

@ -7,8 +7,11 @@ public class ShareExtensionChildViewModel: ObservableObject {
@Published public var title: String?
@Published public var url: String?
@Published public var iconURL: String?
@Published public var requestId: String
public init() {}
public init() {
self.requestId = UUID().uuidString.lowercased()
}
}
public enum ShareExtensionStatus {
@ -126,7 +129,7 @@ struct CheckmarkButtonView: View {
public struct ShareExtensionChildView: View {
let viewModel: ShareExtensionChildViewModel
let onAppearAction: () -> Void
let readNowButtonAction: () -> Void
let readNowButtonAction: (String) -> Void
let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void
@State var reminderTime: ReminderTime?
@ -135,7 +138,7 @@ public struct ShareExtensionChildView: View {
public init(
viewModel: ShareExtensionChildViewModel,
onAppearAction: @escaping () -> Void,
readNowButtonAction: @escaping () -> Void,
readNowButtonAction: @escaping (String) -> Void,
dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void
) {
self.viewModel = viewModel
@ -178,8 +181,10 @@ public struct ShareExtensionChildView: View {
private var cloudIconColor: Color {
switch viewModel.status {
case .saved, .processing:
case .saved:
return .appGrayText
case .processing:
return .clear
case .failed(error: _), .syncFailed(error: _):
return .red
case .synced:
@ -187,13 +192,9 @@ public struct ShareExtensionChildView: View {
}
}
private func localImage(from: URL) -> Image? {
do {
if let data = try? Data(contentsOf: from), let img = UIImage(data: data) {
return Image(uiImage: img)
}
} catch {
return nil
private func localImage(from url: URL) -> Image? {
if let data = try? Data(contentsOf: url), let img = UIImage(data: data) {
return Image(uiImage: img)
}
return nil
}
@ -283,13 +284,12 @@ public struct ShareExtensionChildView: View {
Spacer()
HStack {
if FeatureFlag.enableReadNow {
Button(
action: { readNowButtonAction() },
label: { Text("Read Now").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
}
Button(
action: { readNowButtonAction(self.viewModel.requestId) },
label: { Text("Read Now").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
Button(
action: {
dismissButtonTappedAction(reminderTime, hideUntilReminded)

View file

@ -0,0 +1,50 @@
//
// File.swift
//
//
// Created by Jackson Harper on 6/5/22.
//
import Foundation
import Models
import SwiftUI
import Utils
public struct SyncStatusIcon: View {
let status: ServerSyncStatus
init(status: ServerSyncStatus) {
self.status = status
}
private var cloudIconName: String {
switch status {
// case .isNSync:
// return "checkmark.icloud"
case .isNSync:
return "exclamationmark.icloud"
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate:
return "icloud"
}
}
private var cloudIconColor: Color {
switch status {
// case .isNSync:
// return .blue
case .isNSync:
return .red
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate:
return .appGrayText
}
}
public var body: some View {
Image(systemName: cloudIconName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 12, height: 12, alignment: .trailing)
.foregroundColor(cloudIconColor)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8))
}
}

View file

@ -9,7 +9,20 @@ final class UtilsTests: XCTestCase {
XCTAssertEqual("Hello", "Hello")
}
func testNormalizeUrl() {
// trailing slash removed
XCTAssertEqual(normalizeURL("https://omnivore.app/"), "https://omnivore.app")
// utm_ removed
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
XCTAssertEqual(normalizeURL("https://omnivore.app/?aa=a&cc=c&bb=b"), "https://omnivore.app?aa=a&bb=b&cc=c")
XCTAssertEqual(normalizeURL("https://omnivore.app/?cc=c&bb=b&aa=a"), "https://omnivore.app?aa=a&bb=b&cc=c")
}
static var allTests = [
("testExample", testExample)
("testExample", testExample),
("testNormalizeUrl", testNormalizeUrl)
]
}

View file

@ -188,7 +188,7 @@ export const createPage = async (
return body._id as string
} catch (e) {
console.error('failed to create a page in elastic', e)
console.error('failed to create a page in elastic', JSON.stringify(e))
return undefined
}
}

View file

@ -1,6 +1,11 @@
import { PubsubClient } from '../datalayer/pubsub'
import { homePageURL } from '../env'
import { Maybe, SavePageInput, SaveResult } from '../generated/graphql'
import {
Maybe,
SaveErrorCode,
SavePageInput,
SaveResult,
} from '../generated/graphql'
import { DataModels } from '../resolvers/types'
import { generateSlug, stringToHash, validatedDate } from '../utils/helpers'
import { parsePreparedContent } from '../utils/parser'
@ -102,25 +107,44 @@ export const savePage = async (
state: ArticleSavingRequestStatus.Succeeded,
})
if (existingPage) {
await updatePage(
existingPage.id,
{
savedAt: new Date(),
archivedAt: undefined,
},
ctx
)
if (
!(await updatePage(
existingPage.id,
{
savedAt: new Date(),
archivedAt: undefined,
},
ctx
))
) {
return {
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to update existing page',
}
}
input.clientRequestId = existingPage.id
} else if (shouldParseInBackend(input)) {
await createPageSaveRequest(
saver.userId,
input.url,
ctx.models,
ctx.pubsub,
input.clientRequestId
)
try {
await createPageSaveRequest(
saver.userId,
input.url,
ctx.models,
ctx.pubsub,
input.clientRequestId
)
} catch (e) {
return {
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to create page save request',
}
}
} else {
await createPage(articleToSave, ctx)
if (!(await createPage(articleToSave, ctx))) {
return {
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to create new page',
}
}
}
return {

View file

@ -267,15 +267,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
))}
</SpanBox>
) : null}
{props.isAppleAppEmbed && (
<ArticleHeaderToolbar
articleTitle={props.article.title}
articleShareURL={props.highlightsBaseURL}
setShowShareArticleModal={setShowShareModal}
setShowHighlightsModal={props.setShowHighlightsModal}
hasHighlights={props.article.highlights?.length > 0}
/>
)}
</VStack>
<Article
highlightReady={highlightReady}