Merge pull request #719 from omnivore-app/fix/pdf-uploading-improvements

This commit is contained in:
Jackson Harper 2022-06-02 16:47:03 -07:00 committed by GitHub
commit 975411d429
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1086 additions and 415 deletions

View file

@ -0,0 +1,177 @@
//
// File.swift
//
//
// Created by Jackson Harper on 6/1/22.
//
import Foundation
import Models
import Services
import Views
class ExtensionSaveService {
let queue: OperationQueue
init() {
self.queue = OperationQueue()
}
private func queueSaveOperation(_ pageScrape: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
guard !expiring else {
self.queue.cancelAllOperations()
self.queue.waitUntilAllOperationsAreFinished()
return
}
let operation = SaveOperation(pageScrapePayload: pageScrape, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
self.queue.addOperation(operation)
self.queue.waitUntilAllOperationsAreFinished()
}
}
public func save(_ extensionContext: NSExtensionContext, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
guard let self = self else { return }
switch result {
case let .success(payload):
DispatchQueue.main.async {
shareExtensionViewModel.status = .saved
let url = URLComponents(string: payload.url)
let hostname = URL(string: payload.url)?.host ?? ""
switch payload.contentType {
case let .html(html: _, title: title, iconURL: iconURL):
shareExtensionViewModel.title = title
shareExtensionViewModel.iconURL = iconURL
shareExtensionViewModel.url = hostname
case .none:
shareExtensionViewModel.url = hostname
shareExtensionViewModel.title = payload.url
if var url = url {
url.path = "/favicon.ico"
shareExtensionViewModel.iconURL = url.url?.absoluteString
}
case let .pdf(localUrl: _):
shareExtensionViewModel.title = payload.url
shareExtensionViewModel.url = hostname
}
}
self.queueSaveOperation(payload, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
case let .failure(error):
DispatchQueue.main.async {
shareExtensionViewModel.status = .failed(error: .unknown(description: "Could not retrieve content"))
}
}
}
}
class SaveOperation: Operation, URLSessionDelegate {
let requestId: String
let services: Services
let pageScrapePayload: PageScrapePayload
let shareExtensionViewModel: ShareExtensionChildViewModel
var queue: OperationQueue?
var uploadTask: URLSessionTask?
enum State: Int {
case created
case started
case finished
}
init(pageScrapePayload: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
self.pageScrapePayload = pageScrapePayload
self.requestId = requestId
self.shareExtensionViewModel = shareExtensionViewModel
self.state = .created
self.services = Services()
}
open var state: State = .created {
willSet {
willChangeValue(forKey: "isReady")
willChangeValue(forKey: "isExecuting")
willChangeValue(forKey: "isFinished")
willChangeValue(forKey: "isCancelled")
}
didSet {
didChangeValue(forKey: "isCancelled")
didChangeValue(forKey: "isFinished")
didChangeValue(forKey: "isExecuting")
didChangeValue(forKey: "isReady")
}
}
override var isAsynchronous: Bool {
true
}
override var isReady: Bool {
true
}
override var isExecuting: Bool {
self.state == .started
}
override var isFinished: Bool {
self.state == .finished
}
override func start() {
guard !isCancelled else { return }
state = .started
queue = OperationQueue()
Task {
await persist(services: self.services, pageScrapePayload: self.pageScrapePayload, requestId: self.requestId)
}
}
override func cancel() {
super.cancel()
}
private func updateStatus(newStatus: ShareExtensionStatus) {
DispatchQueue.main.async {
self.shareExtensionViewModel.status = newStatus
}
}
private func persist(services: Services, pageScrapePayload: PageScrapePayload, requestId: String) async {
do {
try await services.dataService.persistPageScrapePayload(pageScrapePayload, requestId: requestId)
} catch {
updateStatus(newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")))
return
}
do {
updateStatus(newStatus: .saved)
switch pageScrapePayload.contentType {
case .none:
try await services.dataService.syncUrl(id: requestId, url: pageScrapePayload.url)
case let .pdf(localUrl):
try await services.dataService.syncPdf(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)
}
} catch {
print("ERROR SYNCING", error)
updateStatus(newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")))
}
state = .finished
updateStatus(newStatus: .synced)
}
}
}

View file

@ -20,83 +20,35 @@ public extension PlatformViewController {
}
}
final class ShareExtensionViewModel: ObservableObject {
public class ShareExtensionViewModel: ObservableObject {
@Published var title: String?
@Published var status: ShareExtensionStatus = FeatureFlag.enableReadNow ? .processing : .success
@Published var status: ShareExtensionStatus = .processing
@Published var debugText: String?
var subscriptions = Set<AnyCancellable>()
let requestID = UUID().uuidString.lowercased()
init() {}
let saveService = ExtensionSaveService()
let requestId = UUID().uuidString.lowercased()
func handleReadNowAction(extensionContext: NSExtensionContext?) {
#if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestID)")
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)")
application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
}
#endif
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
func savePage(extensionContext: NSExtensionContext?) {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
switch result {
case let .success(payload):
self?.persist(pageScrapePayload: payload, requestId: self?.requestID ?? "")
case let .failure(error):
self?.debugText = error.message
}
func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) {
if let extensionContext = extensionContext {
saveService.save(extensionContext, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
} else {
updateStatus(.failed(error: .unknown(description: "Internal Error")))
}
}
private func persist(pageScrapePayload: PageScrapePayload, requestId: String) {
let services = Services()
guard services.authenticator.hasValidAuthToken else {
status = .failed(error: .unauthorized)
return
}
let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: requestId)
let saveLinkPublisher: AnyPublisher<Void, SaveArticleError> = {
if case let .pdf(data) = pageScrapePayload.contentType {
return services.dataService.uploadPDFPublisher(pageScrapePayload: pageScrapePayload,
data: data,
requestId: requestId)
} else if case let .html(html, title) = pageScrapePayload.contentType {
return services.dataService.savePagePublisher(pageScrapePayload: pageScrapePayload,
html: html,
title: title,
requestId: requestId)
} else {
return services.dataService.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
}
}()
saveLinkPublisher
.sink { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.debugText = "saveArticleError: \(error)"
self?.status = .failed(error: error)
UIApplication.shared.endBackgroundTask(backgroundTask)
} receiveValue: { [weak self] _ in
self?.status = .success
UIApplication.shared.endBackgroundTask(backgroundTask)
}
.store(in: &subscriptions)
// Check connection to get fast feedback for auth/network errors
Task {
let hasConnectionAndValidToken = await services.dataService.hasConnectionAndValidToken()
if !hasConnectionAndValidToken {
DispatchQueue.main.async {
self.debugText = "saveArticleError: No connection or invalid token."
self.status = .failed(error: .unknown(description: ""))
}
}
private func updateStatus(_ newStatus: ShareExtensionStatus) {
DispatchQueue.main.async {
self.status = newStatus
}
}
}
@ -104,13 +56,12 @@ final class ShareExtensionViewModel: ObservableObject {
struct ShareExtensionView: View {
let extensionContext: NSExtensionContext?
@StateObject private var viewModel = ShareExtensionViewModel()
@StateObject private var childViewModel = ShareExtensionChildViewModel()
var body: some View {
ShareExtensionChildView(
debugText: viewModel.debugText,
title: viewModel.title,
status: viewModel.status,
onAppearAction: { viewModel.savePage(extensionContext: extensionContext) },
viewModel: childViewModel,
onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
dismissButtonTappedAction: { _, _ in
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)

View file

@ -29,7 +29,7 @@ import Utils
@State private var shareLink: ShareLink?
init(remoteURL: URL, viewModel: PDFViewerViewModel) {
self.pdfURL = viewModel.dataURL(remoteURL: remoteURL)
self.pdfURL = viewModel.pdfItem.localPdfURL ?? remoteURL
self.viewModel = viewModel
}

View file

@ -17,28 +17,6 @@ public final class PDFViewerViewModel: ObservableObject {
self.pdfItem = pdfItem
}
public func dataURL(remoteURL: URL) -> URL {
if let storedURL = storedURL {
return storedURL
}
guard let data = pdfItem.documentData else { return remoteURL }
let subPath = pdfItem.title.isEmpty ? UUID().uuidString : pdfItem.title
let path = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(subPath)
do {
try data.write(to: path)
storedURL = path
return path
} catch {
return remoteURL
}
}
public func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
}

View file

@ -24,6 +24,8 @@ import Utils
guard let username = username else { 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)
}

View file

@ -12,16 +12,19 @@ struct SafariWebLink: Identifiable {
@Published var articleContent: ArticleContent?
@Published var errorMessage: String?
func loadContent(dataService: DataService, itemID: String) async {
func loadContent(dataService: DataService, itemID: String, retryCount: Int = 0) async {
errorMessage = nil
do {
articleContent = try await dataService.fetchArticleContent(itemID: itemID)
} catch {
if retryCount == 0 {
return await loadContent(dataService: dataService, itemID: itemID, retryCount: 1)
}
if let fetchError = error as? ContentFetchError {
switch fetchError {
case .network:
errorMessage = "We were unable to retrieve your content. Please ccheck network connectivity and try again."
errorMessage = "We were unable to retrieve your content. Please check network connectivity and try again."
default:
errorMessage = "We were unable to parse your content."
}

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
<attribute name="annotation" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -29,7 +29,9 @@
<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="onDeviceImageURLString" optional="YES" attributeType="String"/>
<attribute name="originalHtml" optional="YES" attributeType="String"/>
<attribute name="pageURLString" attributeType="String"/>
<attribute name="pdfData" optional="YES" attributeType="Binary"/>
<attribute name="publishDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -86,7 +88,7 @@
</entity>
<elements>
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="359"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="389"/>
<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

@ -5,7 +5,7 @@ public struct PDFItem {
public let objectID: NSManagedObjectID
public let itemID: String
public let pdfURL: URL?
public let documentData: Data?
public let localPdfURL: URL?
public let title: String
public let slug: String
public let readingProgress: Double
@ -22,7 +22,7 @@ public struct PDFItem {
objectID: item.objectID,
itemID: item.unwrappedID,
pdfURL: URL(string: item.unwrappedPageURLString),
documentData: item.pdfData,
localPdfURL: item.localPdfURL.flatMap { URL(string: $0) },
title: item.unwrappedID,
slug: item.unwrappedSlug,
readingProgress: item.readingProgress,

View file

@ -9,16 +9,10 @@ import UniformTypeIdentifiers
let URLREGEX = #"[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)"#
public struct PageScrapePayload {
public struct HTMLPayload {
let url: String
let title: String?
let html: String
}
public enum ContentType {
case none
case html(html: String, title: String?)
case pdf(data: Data)
case html(html: String, title: String?, iconURL: String?)
case pdf(localUrl: URL)
}
public let url: String
@ -29,14 +23,14 @@ public struct PageScrapePayload {
self.contentType = .none
}
init(url: String, pdfData: Data) {
init(url: String, localUrl: URL) {
self.url = url
self.contentType = .pdf(data: pdfData)
self.contentType = .pdf(localUrl: localUrl)
}
init(url: String, title: String?, html: String) {
init(url: String, title: String?, html: String, iconURL: String? = nil) {
self.url = url
self.contentType = .html(html: html, title: title)
self.contentType = .html(html: html, title: title, iconURL: iconURL)
}
}
@ -248,12 +242,28 @@ private extension PageScrapePayload {
return nil
}
static func sharedContainerURL() -> URL {
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.app.omnivoreapp"
)!
}
static func makeFromURL(_ url: URL) -> PageScrapePayload? {
if url.isFileURL {
let type = try? url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier
if type == UTType.pdf.identifier, let data = try? Data(contentsOf: url) {
return PageScrapePayload(url: url.absoluteString, pdfData: data)
if type == UTType.pdf.identifier {
// Copy PDFs into a temporary file where they are staged for processing.
var dest = sharedContainerURL()
let localFile = UUID().uuidString.lowercased() + ".pdf"
dest.appendPathComponent(localFile)
do {
try FileManager.default.copyItem(at: url, to: dest)
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
return nil
@ -264,8 +274,9 @@ private extension PageScrapePayload {
static func makeFromDictionary(_ dictionary: NSDictionary) -> PageScrapePayload? {
let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary
guard let url = results?["url"] as? String else { return nil }
let html = results?["documentHTML"] as? String
let html = results?["originalHTML"] as? String
let title = results?["title"] as? String
let iconURL = results?["iconURL"] as? String
let contentType = results?["contentType"] as? String
// If we were not able to capture any HTML, treat this as a URL and
@ -281,7 +292,7 @@ private extension PageScrapePayload {
}
if let html = html {
return PageScrapePayload(url: url, title: title, html: html)
return PageScrapePayload(url: url, title: title, html: html, iconURL: iconURL)
}
return PageScrapePayload(url: url)

View file

@ -1,8 +1,11 @@
import Combine
import CoreData
import CoreImage
import Foundation
import Models
import OSLog
import QuickLookThumbnailing
import UIKit
import Utils
let logger = Logger(subsystem: "app.omnivore", category: "data-service")
@ -12,10 +15,10 @@ public final class DataService: ObservableObject {
public static var showIntercomMessenger: (() -> Void)?
public let appEnvironment: AppEnvironment
let networker: Networker
public let networker: Networker
var persistentContainer: PersistentContainer
var backgroundContext: NSManagedObjectContext
public var backgroundContext: NSManagedObjectContext
var subscriptions = Set<AnyCancellable>()
public var viewContext: NSManagedObjectContext {
@ -29,7 +32,7 @@ public final class DataService: ObservableObject {
self.backgroundContext = persistentContainer.newBackgroundContext()
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
if isFirstTimeRunningNewAppVersion() {
if isFirstTimeRunningNewAppBuild() {
resetCoreData()
} else {
persistentContainer.loadPersistentStores { _, error in
@ -88,13 +91,133 @@ public final class DataService: ObservableObject {
backgroundContext = persistentContainer.newBackgroundContext()
}
private func isFirstTimeRunningNewAppVersion() -> Bool {
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")
guard let appVersion = appVersion as? String else { return false }
private func isFirstTimeRunningNewAppBuild() -> Bool {
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
guard let appVersion = appVersion, let buildNumber = buildNumber else { return false }
let lastUsedAppVersion = UserDefaults.standard.string(forKey: UserDefaultKey.lastUsedAppVersion.rawValue)
let isFirstRun = (lastUsedAppVersion ?? "unknown") != appVersion
UserDefaults.standard.set(appVersion, forKey: UserDefaultKey.lastUsedAppVersion.rawValue)
return isFirstRun
let lastUsedAppBuildNumber = UserDefaults.standard.string(forKey: UserDefaultKey.lastUsedAppBuildNumber.rawValue)
UserDefaults.standard.set(buildNumber, forKey: UserDefaultKey.lastUsedAppBuildNumber.rawValue)
let isFirstRunOfVersion = (lastUsedAppVersion ?? "unknown") != appVersion
let isFirstRunWithBuildNumber = (lastUsedAppBuildNumber ?? "unknown") != buildNumber
return isFirstRunOfVersion || isFirstRunWithBuildNumber
}
public func persistPageScrapePayload(_ pageScrape: PageScrapePayload, requestId: String) async throws {
let normalizedURL = normalizeURL(pageScrape.url)
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "pageURLString = %@", normalizedURL)
let currentTime = Date()
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
linkedItem.id = existingItem?.unwrappedID ?? requestId
linkedItem.title = normalizedURL
linkedItem.pageURLString = normalizedURL
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsCreation.rawValue)
linkedItem.savedAt = currentTime
linkedItem.createdAt = currentTime
linkedItem.isArchived = false
linkedItem.imageURLString = nil
linkedItem.onDeviceImageURLString = nil
linkedItem.descriptionText = nil
linkedItem.publisherURLString = nil
linkedItem.author = nil
linkedItem.publishDate = nil
if let currentViewer = self.currentViewer {
linkedItem.slug = "\(currentViewer)/\(requestId)"
} else {
// Technically this is invalid, but I don't think slug is used at all locally anymore
linkedItem.slug = requestId
}
switch pageScrape.contentType {
case let .pdf(localUrl):
linkedItem.contentReader = "PDF"
linkedItem.localPdfURL = localUrl.absoluteString
linkedItem.title = self.titleFromPdfFile(pageScrape.url)
// TODO: Attempt to set thumbnail from PDF data
// let thumbnailUrl = DataService.thumbnailUrl(localUrl: localUrl)
// self.createThumbnailFor(inputUrl: localUrl, at: thumbnailUrl)
// linkedItem.imageURLString = thumbnailUrl.absoluteString
case let .html(html: html, title: title, iconURL: iconURL):
linkedItem.contentReader = "WEB"
linkedItem.originalHtml = html
linkedItem.imageURLString = iconURL
linkedItem.title = title ?? self.titleFromPdfFile(pageScrape.url)
case .none:
print("SAVING URL", linkedItem.unwrappedPageURLString)
linkedItem.contentReader = "WEB"
}
do {
try self.backgroundContext.save()
logger.debug("ArticleContent saved succesfully")
} catch {
self.backgroundContext.rollback()
print("Failed to save ArticleContent", error.localizedDescription, error)
throw error
}
}
}
func titleFromPdfFile(_ urlStr: String) -> String {
let url = URL(string: urlStr)
if let url = url {
return url.lastPathComponent
}
return urlStr
}
func titleFromUrl(_ urlStr: String) -> String {
let url = URL(string: urlStr)
if let url = url {
return url.lastPathComponent
}
return urlStr
}
func thumbnailUrl(localUrl: URL) -> URL {
var thumbnailUrl = localUrl
thumbnailUrl.appendPathExtension(".pdf")
return thumbnailUrl
}
// TODO: we can try to use this to create PDF thumbnails locally
func createThumbnailFor(inputUrl: URL, at outputUrl: URL) {
let size = CGSize(width: 80, height: 80)
let scale = UIScreen.main.scale
// Create the thumbnail request.
let request =
QLThumbnailGenerator.Request(
fileAt: inputUrl,
size: size,
scale: scale,
representationTypes: .all
)
// Retrieve the singleton instance of the thumbnail generator and generate the thumbnails.
let generator = QLThumbnailGenerator.shared
generator.saveBestRepresentation(for: request, to: outputUrl, contentType: UTType.jpeg.identifier) { error in
if let error = error {
print(error.localizedDescription)
}
}
}
}

View file

@ -92,7 +92,7 @@ public extension DataService {
}
let preparedDocument: InputObjects.PreparedDocumentInput? = {
if case let .html(html, title) = pageScrapePayload.contentType {
if case let .html(html, title, _) = pageScrapePayload.contentType {
return InputObjects.PreparedDocumentInput(
document: html,
pageInfo: InputObjects.PageInfoInput(title: OptionalArgument(title))
@ -219,14 +219,3 @@ public extension DataService {
.eraseToAnyPublisher()
}
}
private extension SaveArticleError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
return .unknown(description: httpError.localizedDescription)
}
}
}

View file

@ -3,40 +3,24 @@ import Foundation
import Models
import SwiftGraphQL
public struct UploadFileRequestPayload {
public let uploadID: String?
public let uploadFileID: String?
public let urlString: String?
}
public extension DataService {
func uploadPDFPublisher(
pageScrapePayload: PageScrapePayload,
data: Data,
requestId: String
) -> AnyPublisher<Void, SaveArticleError> {
uploadFileRequestPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
.flatMap { self.uploadFilePublisher(fileUploadConfig: $0, data: data) }
.flatMap { self.saveFilePublisher(pageScrapePayload: pageScrapePayload, uploadFileId: $0, requestId: requestId) }
.catch { _ in self.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private struct UploadFileRequestPayload {
let uploadID: String?
let uploadFileID: String?
let urlString: String?
}
private extension DataService {
// swiftlint:disable:next line_length
func uploadFileRequestPublisher(pageScrapePayload: PageScrapePayload, requestId: String?) -> AnyPublisher<UploadFileRequestPayload, SaveArticleError> {
func uploadFileRequest(id: String, url: String) async throws -> UploadFileRequestPayload {
enum MutationResult {
case success(payload: UploadFileRequestPayload)
case error(errorCode: Enums.UploadFileRequestErrorCode?)
}
let input = InputObjects.UploadFileRequestInput(
url: pageScrapePayload.url,
url: url,
contentType: "application/pdf",
createPageEntry: OptionalArgument(true),
clientRequestId: OptionalArgument(requestId)
clientRequestId: OptionalArgument(id)
)
let selection = Selection<MutationResult, Unions.UploadFileRequestResult> {
@ -61,79 +45,82 @@ private extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .success(payload):
promise(.success(payload))
case let .error(errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.debugDescription)))
}
}
case .failure:
promise(.failure(.badData))
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
continuation.resume(
throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription)
)
return
}
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"))
}
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
continuation.resume(throwing: SaveArticleError.unauthorized)
default:
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode?.rawValue ?? "unknown"))
}
}
case let .failure(error):
continuation.resume(throwing: SaveArticleError.make(from: error))
}
}
}
.eraseToAnyPublisher()
}
// swiftlint:disable:next line_length
func uploadFilePublisher(fileUploadConfig: UploadFileRequestPayload, data: Data) -> AnyPublisher<String, SaveArticleError> {
let url = fileUploadConfig.urlString.flatMap { URL(string: $0) }
guard let url = url else { return Future { $0(.failure(.badData)) }.eraseToAnyPublisher() }
func uploadFile(id _: String, localPdfURL: URL, url: URL) async throws {
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
request.httpBody = data
return networker.urlSession.dataTaskPublisher(for: request)
.tryMap { data, response -> String in
let serverResponse = ServerResponse(data: data, response: response)
if serverResponse.httpUrlResponse?.statusCode == 200, let fileUploadID = fileUploadConfig.uploadID {
return fileUploadID
}
throw ServerError(serverResponse: serverResponse)
}
.mapError { error -> SaveArticleError in
let serverResponse = ServerResponse(error: error)
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
let serverError = ServerError(serverResponse: serverResponse)
switch serverError {
case .noConnection, .timeout:
return .network
case .unauthenticated:
return .unauthorized
case .unknown:
return .unknown(description: "upload to file server failed")
return try await withCheckedThrowingContinuation { continuation in
let task = networker.urlSession.uploadTask(with: request, fromFile: localPdfURL) { _, response, _ in
print("UPLOAD RESPONSE", response)
if let httpResponse = response as? HTTPURLResponse, 200 ... 299 ~= httpResponse.statusCode {
continuation.resume()
} else {
continuation.resume(throwing: SaveArticleError.unknown(description: "Invalid response"))
}
}
.eraseToAnyPublisher()
task.resume()
}
}
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?
print("NOT UPLOADING PDF DOCUMENT YET")
return nil
}
}
// swiftlint:disable:next line_length
func saveFilePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
func saveFilePublisher(requestId: String, uploadFileId: String, url: String) async throws {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
}
let input = InputObjects.SaveFileInput(
url: pageScrapePayload.url,
url: url,
source: "ios-file",
clientRequestId: requestId,
uploadFileId: uploadFileId
@ -153,34 +140,31 @@ private extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveError.make(from: error)))
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
continuation.resume(throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription))
return
}
switch payload.data {
case .saved:
continuation.resume()
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
continuation.resume(throwing: SaveArticleError.unauthorized)
default:
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode.rawValue))
}
}
case let .failure(error):
continuation.resume(throwing: SaveArticleError.make(from: error))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -1,27 +1,28 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next line_length
func savePagePublisher(pageScrapePayload: PageScrapePayload, html: String, title: String?, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
func savePage(id: String, url: String, title: String, originalHtml: String) async throws {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
}
let input = InputObjects.SavePageInput(
url: requestId,
source: html,
clientRequestId: "ios-page",
url: url,
source: "ios-page",
clientRequestId: id,
title: OptionalArgument(title),
originalContent: pageScrapePayload.url
originalContent: originalHtml
)
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") }, saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") },
saveError: .init {
.error(errorCode: (try? $0.errorCodes().first) ?? .unknown)
}
)
}
@ -32,38 +33,36 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveError.make(from: error)))
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
continuation.resume(
throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription)
)
return
}
switch payload.data {
case .saved:
continuation.resume()
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
continuation.resume(throwing: SaveArticleError.unauthorized)
default:
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode.rawValue))
}
}
case let .failure(error):
continuation.resume(throwing: SaveArticleError.make(from: error))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private extension SaveError {
extension SaveArticleError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:

View file

@ -1,25 +1,23 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next line_length
func saveUrlPublisher(pageScrapePayload: PageScrapePayload, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
func saveURL(id: String, url: String) async throws {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
}
let input = InputObjects.SaveUrlInput(
url: pageScrapePayload.url,
url: url,
source: "ios-url",
clientRequestId: requestId
clientRequestId: id
)
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") },
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
)
}
@ -31,44 +29,32 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveError.make(from: error)))
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
continuation.resume(
throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription)
)
return
}
switch payload.data {
case .saved:
continuation.resume()
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
continuation.resume(throwing: SaveArticleError.unauthorized)
default:
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode.rawValue))
}
}
case let .failure(error):
continuation.resume(throwing: SaveArticleError.make(from: error))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private extension SaveError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
return .unknown(description: httpError.localizedDescription)
}
}
}

View file

@ -1,9 +1,10 @@
import Foundation
import Models
public final class Networker {
public final class Networker: NSObject, URLSessionTaskDelegate {
let urlSession: URLSession
let appEnvironment: AppEnvironment
var uploadQueue: [String: URLSessionUploadTask] = [:]
var defaultHeaders: [String: String] {
var headers = URLRequest.defaultHeaders
@ -15,9 +16,9 @@ public final class Networker {
return headers
}
public init(appEnvironment: AppEnvironment, urlSession: URLSession = .shared) {
public init(appEnvironment: AppEnvironment) {
self.appEnvironment = appEnvironment
self.urlSession = urlSession
self.urlSession = .shared
}
}

View file

@ -2,8 +2,8 @@ import CoreData
import Foundation
import Models
extension DataService {
func syncOfflineItemsWithServerIfNeeded() async throws {
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]()
@ -35,12 +35,108 @@ extension DataService {
}
}
private func updateLinkedItemStatus(id: String, status: ServerSyncStatus) async throws {
try 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 }
linkedItem.serverSyncStatus = Int64(status.rawValue)
}
}
func syncPdf(id: String, localPdfURL: URL, url: String) async throws {
do {
let uploadRequest = try await uploadFileRequest(id: id, url: url)
if let urlString = uploadRequest.urlString, let uploadUrl = URL(string: urlString) {
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 backgroundContext.performAndWait {
try backgroundContext.save()
}
} catch {
backgroundContext.rollback()
throw error
}
}
func syncPage(id: String, originalHtml: String, title: String?, url: String) async throws {
do {
try await savePage(id: id, url: url, title: title ?? url, originalHtml: originalHtml)
try await updateLinkedItemStatus(id: id, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
} catch {
backgroundContext.performAndWait {
backgroundContext.rollback()
}
throw error
}
}
func syncUrl(id: String, url: String) async throws {
do {
try await updateLinkedItemStatus(id: id, status: .isSyncing)
try await saveURL(id: id, url: url)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
} catch {
backgroundContext.performAndWait {
backgroundContext.rollback()
}
throw error
}
}
func syncLocalCreatedLinkedItem(item: LinkedItem) {
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) {
Task {
try await syncPdf(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
let url = item.unwrappedPageURLString
let title = item.unwrappedTitle
let originalHtml = item.originalHtml
Task {
if let originalHtml = originalHtml {
try await syncPage(id: id, originalHtml: originalHtml, title: title, url: url)
} else {
try await syncUrl(id: id, url: url)
}
}
default:
break
}
}
private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) {
for item in unsyncedLinkedItems {
guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue }
switch syncStatus {
case .isNSync, .isSyncing, .needsCreation:
case .needsCreation:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncLocalCreatedLinkedItem(item: item)
case .isNSync, .isSyncing:
break
case .needsDeletion:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
@ -88,4 +184,23 @@ 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

@ -85,6 +85,9 @@ extension DataService {
return cachedContent
}
// If the page was locally created, make sure they are synced before we pull content
await syncUnsyncedArticleContent(itemID: itemID)
enum QueryResult {
case success(result: ArticleProps)
case error(error: String)
@ -111,6 +114,7 @@ extension DataService {
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
),
htmlContent: try $0.content(),
@ -217,7 +221,7 @@ extension DataService {
linkedItem.isArchived = item.isArchived
linkedItem.contentReader = item.contentReader
if linkedItem.isPDF, linkedItem.pdfData == nil {
if linkedItem.isPDF, linkedItem.localPdfURL == nil {
do {
try self.fetchPDFData(slug: linkedItem.unwrappedSlug, pageURLString: linkedItem.unwrappedPageURLString)
} catch {
@ -257,9 +261,16 @@ extension DataService {
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
throw BasicError.message(messageText: errorMessage)
}
linkedItem.pdfData = data
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 {
@ -296,6 +307,49 @@ extension DataService {
)
}
}
public 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.isNSync.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)
}
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.
}
}
}
}
private extension ArticleContentStatus {

View file

@ -110,6 +110,7 @@ public extension DataService {
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
@ -174,6 +175,7 @@ private let libraryArticleSelection = Selection.Article {
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}

View file

@ -21,6 +21,7 @@ struct InternalLinkedItem {
let slug: String
let isArchived: Bool
let contentReader: String?
let originalHtml: String?
var labels: [InternalLinkedItemLabel]
var isPDF: Bool {
@ -51,6 +52,7 @@ struct InternalLinkedItem {
linkedItem.slug = slug
linkedItem.isArchived = isArchived
linkedItem.contentReader = contentReader
linkedItem.originalHtml = originalHtml
for label in labels {
linkedItem.addToLabels(label.asManagedObject(inContext: context))
@ -107,6 +109,7 @@ extension JSONArticle {
slug: slug,
isArchived: isArchived,
contentReader: contentReader,
originalHtml: nil,
labels: []
)

View file

@ -0,0 +1,52 @@
//
// NormalizeURL.swift
//
//
// Created by Jackson Harper on 6/2/22.
//
import Foundation
// based losesly on the normalize-url npm package which we use on the backend
public func normalizeURL(_ dirtyURL: String) -> String {
var urlString = dirtyURL
urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if var urlObject = URLComponents(string: urlString) {
// Remove auth
if /* options.stripAuthentication */ true {
urlObject.user = nil
urlObject.password = nil
}
// Remove hash
if /* options.stripHash */ true {
urlObject.fragment = nil
}
urlObject.queryItems = urlObject.queryItems?.filter { item in
item.name.starts(with: "utm_")
}
if /* options.removeTrailingSlash */ true {
urlObject.path = urlObject.path.replacingRegex(pattern: "/$", replaceWith: "")
}
if let finalUrl = urlObject.url {
return finalUrl.absoluteString
}
}
return dirtyURL
}
private extension String {
func replacingRegex(pattern: String, replaceWith: String = "") -> String {
do {
let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .anchorsMatchLines])
let range = NSRange(location: 0, length: utf16.count)
return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith)
} catch { return self }
}
}

View file

@ -9,4 +9,5 @@ public enum UserDefaultKey: String {
case homeFeedlayoutPreference
case lastSelectedLinkedItemFilter
case lastUsedAppVersion
case lastUsedAppBuildNumber
}

View file

@ -2,23 +2,64 @@ import Models
import SwiftUI
import Utils
public class ShareExtensionChildViewModel: ObservableObject {
@Published public var status: ShareExtensionStatus = .processing
@Published public var title: String?
@Published public var url: String?
@Published public var iconURL: String?
public init() {}
}
public enum ShareExtensionStatus {
case processing
case success
case saved
case synced
case failed(error: SaveArticleError)
case syncFailed(error: SaveArticleError)
var displayMessage: String {
switch self {
case .success:
return LocalText.saveArticleSavedState
case let .failed(error: error):
return error.displayMessage
case .processing:
return LocalText.saveArticleProcessingState
case .saved:
return LocalText.saveArticleSavedState
case .synced:
return "Synced"
case let .failed(error: error):
return "Save failed \(error.displayMessage)"
case let .syncFailed(error: error):
return "Sync failed \(error.displayMessage)"
}
}
}
struct CornerRadiusStyle: ViewModifier {
var radius: CGFloat
var corners: UIRectCorner
struct CornerRadiusShape: Shape {
var radius = CGFloat.infinity
var corners = UIRectCorner.allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}
func body(content: Content) -> some View {
content
.clipShape(CornerRadiusShape(radius: radius, corners: corners))
}
}
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
ModifiedContent(content: self, modifier: CornerRadiusStyle(radius: radius, corners: corners))
}
}
private extension SaveArticleError {
var displayMessage: String {
switch self {
@ -83,32 +124,26 @@ struct CheckmarkButtonView: View {
}
public struct ShareExtensionChildView: View {
let debugText: String?
let title: String?
let status: ShareExtensionStatus
let viewModel: ShareExtensionChildViewModel
let onAppearAction: () -> Void
let readNowButtonAction: () -> Void
let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void
@State var reminderTime: ReminderTime?
@State var hideUntilReminded = false
public init(
debugText: String?,
title: String?,
status: ShareExtensionStatus,
viewModel: ShareExtensionChildViewModel,
onAppearAction: @escaping () -> Void,
readNowButtonAction: @escaping () -> Void,
dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void
) {
self.debugText = debugText
self.title = title
self.status = status
self.viewModel = viewModel
self.onAppearAction = onAppearAction
self.readNowButtonAction = readNowButtonAction
self.dismissButtonTappedAction = dismissButtonTappedAction
}
@State var reminderTime: ReminderTime?
@State var hideUntilReminded = false
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
if selectedTime == reminderTime {
reminderTime = nil
@ -119,101 +154,115 @@ public struct ShareExtensionChildView: View {
}
}
private var titleText: String {
switch viewModel.status {
case .saved, .synced:
return "Saved to Omnivore"
case .processing:
return "Saving to Omnivore"
default:
return "Error saving to Omnivore"
}
}
private var cloudIconName: String {
switch viewModel.status {
case .synced:
return "checkmark.icloud"
case .saved, .processing:
return "icloud"
case .failed(error: _), .syncFailed(error: _):
return "exclamationmark.icloud"
}
}
private var cloudIconColor: Color {
switch viewModel.status {
case .saved, .processing:
return .appGrayText
case .failed(error: _), .syncFailed(error: _):
return .red
case .synced:
return .blue
}
}
public var previewCard: some View {
HStack {
if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) {
AsyncLoadingImage(url: iconURL) { imageStatus in
if case let AsyncImageStatus.loaded(image) = imageStatus {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
.clipped()
} else {
Color.appButtonBackground
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
}
}
} else {
EmptyView()
.frame(width: 61, height: 61)
}
VStack(alignment: .leading) {
Text(viewModel.title ?? "")
.lineLimit(1)
.foregroundColor(.appGrayText)
.font(Font.system(size: 15, weight: .semibold))
Text(viewModel.url ?? "")
.lineLimit(1)
.foregroundColor(.appGrayText)
.font(Font.system(size: 12, weight: .regular))
}
Spacer()
VStack {
Spacer()
Image(systemName: cloudIconName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 12, height: 12, alignment: .trailing)
.foregroundColor(cloudIconColor)
// .padding(.trailing, 6)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8))
}
}
.background(Color(hex: "#363636"))
.frame(maxWidth: .infinity, maxHeight: 61)
.cornerRadius(8)
}
public var body: some View {
VStack(alignment: .leading) {
#if DEBUG
if let debugText = debugText {
Text(debugText)
}
#endif
Text(titleText)
.foregroundColor(.appGrayText)
.font(Font.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 23)
.padding(.bottom, 16)
if let title = title {
Text(title)
.font(.appHeadline)
.lineLimit(1)
.padding(.trailing, 50)
Divider()
}
Rectangle()
.foregroundColor(.appGrayText)
.frame(maxWidth: .infinity, maxHeight: 1)
.opacity(0.06)
.padding(.top, 0)
.padding(.bottom, 16)
previewCard
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
Spacer()
if case ShareExtensionStatus.success = status {
HStack(spacing: 4) {
Text("Saved to Omnivore")
.font(.appTitleThree)
.foregroundColor(.appGrayText)
.padding(.trailing, 16)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(nil)
}
.padding()
} else if case let ShareExtensionStatus.failed(error) = status {
HStack {
Spacer()
Text(error.displayMessage)
Spacer()
}
} else {
HStack {
Spacer()
Text("Saving...")
Spacer()
}
}
ScrollView {
if FeatureFlag.enableRemindersFromShareExtension {
VStack(spacing: 0) {
CheckmarkButtonView(
titleText: "Remind me tonight",
isSelected: reminderTime == .tonight,
action: { handleReminderTimeSelection(.tonight) }
)
Divider()
CheckmarkButtonView(
titleText: "Remind me tomorrow",
isSelected: reminderTime == .tomorrow,
action: { handleReminderTimeSelection(.tomorrow) }
)
Divider()
CheckmarkButtonView(
titleText: "Remind me this weekend",
isSelected: reminderTime == .thisWeekend,
action: { handleReminderTimeSelection(.thisWeekend) }
)
}
.cornerRadius(8)
}
if FeatureFlag.enableSnoozeFromShareExtension {
CheckmarkButtonView(
titleText: "Hide it until then",
isSelected: hideUntilReminded,
action: { hideUntilReminded.toggle() }
)
.cornerRadius(8)
.padding(.top, 16)
}
}
.padding(.horizontal)
HStack {
if case ShareExtensionStatus.success = status, FeatureFlag.enableReadNow {
if FeatureFlag.enableReadNow {
Button(
action: { readNowButtonAction() },
label: { Text("Read Now").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
}
if case ShareExtensionStatus.processing = status, FeatureFlag.enableReadNow {
Button(action: {}, label: { ProgressView().frame(maxWidth: .infinity) })
.buttonStyle(RoundedRectButtonStyle())
}
Button(
action: {
dismissButtonTappedAction(reminderTime, hideUntilReminded)

View file

@ -1,12 +1,23 @@
var ShareExtension = function() {};
function iconURL() {
try {
const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']").content
if (previewImage) { return previewImage }
return document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']").href
} catch {}
return null
}
ShareExtension.prototype = {
run: function(arguments) {
arguments.completionFunction({
'url': window.location.href,
'title': document.title.toString(),
'iconURL': iconURL(),
'contentType': document.contentType,
'documentHTML': new XMLSerializer().serializeToString(document),
'originalHTML': new XMLSerializer().serializeToString(document)
});
}
};

View file

@ -12,7 +12,7 @@ import Utils
embed(
childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext),
heightRatio: 0.3
heightRatio: 0.55
)
}
}

View file

@ -112,7 +112,7 @@ export const uploadFileRequestResolver: ResolverFn<
savedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
state: ArticleSavingRequestStatus.Processing,
state: ArticleSavingRequestStatus.Succeeded,
},
ctx
)

View file

@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import express from 'express'
import {
ArticleSavingRequestStatus,
CreateArticleErrorCode,
PageType,
UploadFileStatus,
} from '../generated/graphql'
import { isSiteBlockedForParse } from '../utils/blocked'
import cors from 'cors'
import { env } from '../env'
import { buildLogger } from '../utils/logger'
import * as jwt from 'jsonwebtoken'
import { corsConfig } from '../utils/corsConfig'
import { createPageSaveRequest } from '../services/create_page_save_request'
import { initModels } from '../server'
import { kx } from '../datalayer/knex_config'
import {
fileNameForFilePath,
generateSlug,
isString,
titleForFilePath,
validateUuid,
} from '../utils/helpers'
import {
generateUploadFilePathName,
generateUploadSignedUrl,
} from '../utils/uploads'
import { Claims } from '../resolvers/types'
import { createPage, getPageByParam, updatePage } from '../elastic/pages'
import { createPubSubClient } from '../datalayer/pubsub'
const logger = buildLogger('app.dispatch')
export function pageRouter() {
const router = express.Router()
// Create a page from an uploaded PDF document
router.options('/pdf', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
router.put('/pdf', cors<express.Request>(corsConfig), async (req, res) => {
const token = req?.cookies?.auth || req?.headers?.authorization
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
}
const claims = jwt.decode(token) as Claims
// Get the content type from the query params
const { url, clientRequestId } = req.query
const contentType = req.headers['content-type']
console.log(
'contentType',
contentType,
'url',
url,
'clientRequestId',
clientRequestId
)
if (
!isString(url) ||
!isString(contentType) ||
!isString(clientRequestId)
) {
console.log(
'creating page from pdf failed',
url,
contentType,
clientRequestId
)
return res.status(400).send({ errorCode: 'BAD_DATA' })
}
if (!validateUuid(clientRequestId)) {
console.log('creating page from pdf failed invalid uuid')
return res.status(400).send({ errorCode: 'BAD_DATA' })
}
const models = initModels(kx, false)
const ctx = {
uid: claims.uid,
pubsub: createPubSubClient(),
}
const title = titleForFilePath(url)
const fileName = fileNameForFilePath(url)
const uploadFileData = await models.uploadFile.create({
url: url,
userId: claims.uid,
fileName: fileName,
status: UploadFileStatus.Initialized,
contentType: 'application/pdf',
})
const uploadFilePathName = generateUploadFilePathName(
uploadFileData.id,
fileName
)
const signedUrl = await generateUploadSignedUrl(
uploadFilePathName,
'application/pdf'
)
const page = await getPageByParam({
userId: claims.uid,
url: url,
})
if (page) {
console.log('updating page')
await updatePage(
page.id,
{
savedAt: new Date(),
archivedAt: null,
},
ctx
)
} else {
console.log('creating page')
const pageId = await createPage(
{
url: signedUrl,
id: clientRequestId,
userId: claims.uid,
title: title,
hash: uploadFilePathName,
content: '',
pageType: PageType.File,
uploadFileId: uploadFileData.id,
slug: generateSlug(uploadFilePathName),
createdAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
state: ArticleSavingRequestStatus.Processing,
},
ctx
)
if (!pageId) {
return res.sendStatus(500)
}
}
console.log('redirecting to signed URL', signedUrl)
return res.redirect(signedUrl)
})
return router
}

View file

@ -15,6 +15,7 @@ import { config, loggers } from 'winston'
import { sentryConfig } from './sentry'
import { makeApolloServer } from './apollo'
import { authRouter } from './routers/auth/auth_router'
import { pageRouter } from './routers/page_router'
import { articleRouter } from './routers/article_router'
import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router'
import { contentServiceRouter } from './routers/svc/content'
@ -105,6 +106,7 @@ export const createApp = (): {
app.get('/_ah/health', (req, res) => res.sendStatus(200))
app.use('/api/auth', authRouter())
app.use('/api/page', pageRouter())
app.use('/api/article', articleRouter())
app.use('/api/mobile-auth', mobileAuthRouter())
app.use('/svc/pubsub/content', contentServiceRouter())

View file

@ -233,6 +233,18 @@ export const validatedDate = (
}
}
export const fileNameForFilePath = (urlStr: string): string => {
const url = normalizeUrl(new URL(urlStr).href, {
stripHash: true,
stripWWW: false,
})
const fileName = decodeURI(path.basename(new URL(url).pathname)).replace(
/[^a-zA-Z0-9-_.]/g,
''
)
return fileName
}
export const titleForFilePath = (url: string): string => {
try {
const title = decodeURI(path.basename(new URL(url).pathname, '.pdf'))
@ -242,3 +254,13 @@ export const titleForFilePath = (url: string): string => {
}
return url
}
export const validateUuid = (str: string): boolean => {
const regexExp =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi
return regexExp.test(str)
}
export const isString = (check: any): check is string => {
return typeof check === 'string' || check instanceof String
}