diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift new file mode 100644 index 000000000..943dd94ec --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift @@ -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) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift index d3fa98d5f..a5ea021e1 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift @@ -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() - 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 = { - 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) diff --git a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift index ea7b59c0f..e4a80b185 100644 --- a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift +++ b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewerViewModel.swift b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewerViewModel.swift index 2d0b70cbf..978063551 100644 --- a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewerViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewerViewModel.swift @@ -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 ?? "" }) } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index 087be99c0..046d0673a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -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) } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 1a03c96dd..8255a1289 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -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." } diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 3556a931c..3b0ea0639 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -29,7 +29,9 @@ + + @@ -86,7 +88,7 @@ - + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift index 2953a2f73..3380e48f1 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift @@ -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, diff --git a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift index 27b051346..ee4435cc2 100644 --- a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift +++ b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 8c8f02ed1..b2db01cab 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -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() 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 = 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) + } + } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift index cc7352b41..9406867ad 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift @@ -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) - } - } -} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift index 4267b5a6f..c8051e48e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift @@ -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 { - 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 { + 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 { @@ -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 { - 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 { + 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() } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift index b3ae4fc12..8108ae05c 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift @@ -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 { + 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 { 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: diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveUrl.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveUrl.swift index 3a2ba77e7..887e412c5 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveUrl.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveUrl.swift @@ -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 { + 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 { 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) - } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift index 4f2869e23..5da9691de 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift @@ -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 } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift index e64cee327..5f029fb1e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift @@ -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 = 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 = 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) + } + } + } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index b954c3a82..29aca5822 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -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 = 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 { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift index edc905154..b36e266bb 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift @@ -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) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/SaveService.swift b/apple/OmnivoreKit/Sources/Services/DataService/SaveService.swift new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/SaveService.swift @@ -0,0 +1 @@ + diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift index d050c5e4f..8927f7b63 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift @@ -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: [] ) diff --git a/apple/OmnivoreKit/Sources/Utils/NormalizeURL.swift b/apple/OmnivoreKit/Sources/Utils/NormalizeURL.swift new file mode 100644 index 000000000..3d31dbe52 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Utils/NormalizeURL.swift @@ -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 } + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index a9e418c80..a615e8512 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -9,4 +9,5 @@ public enum UserDefaultKey: String { case homeFeedlayoutPreference case lastSelectedLinkedItemFilter case lastUsedAppVersion + case lastUsedAppBuildNumber } diff --git a/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift index 8cfa95fbb..14f25c748 100644 --- a/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift @@ -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) diff --git a/apple/Sources/ShareExtension/ShareExtension.js b/apple/Sources/ShareExtension/ShareExtension.js index fd7c4c0b6..1b2f22b4d 100644 --- a/apple/Sources/ShareExtension/ShareExtension.js +++ b/apple/Sources/ShareExtension/ShareExtension.js @@ -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) }); } }; diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index ba45041e6..96a9c88ce 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -12,7 +12,7 @@ import Utils embed( childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext), - heightRatio: 0.3 + heightRatio: 0.55 ) } } diff --git a/packages/api/src/resolvers/upload_files/index.ts b/packages/api/src/resolvers/upload_files/index.ts index 0fb38ea06..5e49aa0ac 100644 --- a/packages/api/src/resolvers/upload_files/index.ts +++ b/packages/api/src/resolvers/upload_files/index.ts @@ -112,7 +112,7 @@ export const uploadFileRequestResolver: ResolverFn< savedAt: new Date(), readingProgressPercent: 0, readingProgressAnchorIndex: 0, - state: ArticleSavingRequestStatus.Processing, + state: ArticleSavingRequestStatus.Succeeded, }, ctx ) diff --git a/packages/api/src/routers/page_router.ts b/packages/api/src/routers/page_router.ts new file mode 100644 index 000000000..39ae6295a --- /dev/null +++ b/packages/api/src/routers/page_router.ts @@ -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({ ...corsConfig, maxAge: 600 })) + router.put('/pdf', cors(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 +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index d65bbcdd8..64f0326c0 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -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()) diff --git a/packages/api/src/utils/helpers.ts b/packages/api/src/utils/helpers.ts index 0a5305e13..c4db77d44 100644 --- a/packages/api/src/utils/helpers.ts +++ b/packages/api/src/utils/helpers.ts @@ -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 +}