import CoreData import Models import Services import SwiftUI import Utils import Views public class ShareExtensionViewModel: ObservableObject { @Published public var status: ShareExtensionStatus = .processing @Published public var title: String = "" @Published public var url: String? @Published public var highlightData: HighlightData? @Published public var linkedItem: LinkedItem? @Published public var requestId = UUID().uuidString.lowercased() @Published var debugText: String? let services = Services() let queue = OperationQueue() 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)") application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) } #else if let workspace = NSWorkspace.value(forKeyPath: #keyPath(NSWorkspace.shared)) as? NSWorkspace { let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)") workspace.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) } #endif extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func savePage(extensionContext: NSExtensionContext?) { if let extensionContext = extensionContext { save(extensionContext) } else { DispatchQueue.main.async { self.status = .failed(error: .unknown(description: "Internal Error")) } } } func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) { dataService.archiveLink(objectID: objectID, archived: archived) } func removeLink(dataService: DataService, objectID: NSManagedObjectID) { dataService.removeLink(objectID: objectID) } func submitTitleEdit(dataService: DataService, itemID: String, title: String, description: String) { dataService.updateLinkedItemTitleAndDescription( itemID: itemID, title: title, description: description, author: nil ) } #if os(iOS) func queueSaveOperation(_ payload: PageScrapePayload) { ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in guard !expiring else { self.queue.cancelAllOperations() self.queue.waitUntilAllOperationsAreFinished() return } let operation = ShareExtensionSaveOperation(pageScrapePayload: payload, shareExtensionViewModel: self) self.queue.addOperation(operation) self.queue.waitUntilAllOperationsAreFinished() } } #endif public func save(_ extensionContext: NSExtensionContext) { PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in guard let self = self else { return } switch result { case let .success(payload): DispatchQueue.main.async { self.status = .saved let hostname = URL(string: payload.url)?.host ?? "" switch payload.contentType { case let .html(html: _, title: title, highlightData: highlightData): self.title = title ?? "" self.url = hostname self.highlightData = highlightData case .none: self.url = hostname self.title = payload.url case let .pdf(localUrl: localUrl): self.url = hostname self.title = PDFUtils.titleFromPdfFile(localUrl.absoluteString) } } #if os(iOS) self.queueSaveOperation(payload) #else Task { await self.createPage(pageScrapePayload: payload) } #endif case .failure: DispatchQueue.main.async { self.status = .failed(error: .unknown(description: "Could not retrieve content")) } } } } func createPage(pageScrapePayload: PageScrapePayload) async -> Bool { var newRequestID: String? var linkedItemObjectID: NSManagedObjectID? do { linkedItemObjectID = try await services.dataService.persistPageScrapePayload( pageScrapePayload, requestId: requestId ) } catch { updateStatusOnMain( requestId: nil, newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")) ) return false } do { updateStatusOnMain(requestId: requestId, newStatus: .saved, objectID: linkedItemObjectID) switch pageScrapePayload.contentType { case .none: newRequestID = try await services.dataService.createPageFromUrl(id: requestId, url: pageScrapePayload.url) case let .pdf(localUrl): try await services.dataService.createPageFromPdf( id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url ) case let .html(html, title, _): newRequestID = try await services.dataService.createPage( id: requestId, originalHtml: html, title: title, url: pageScrapePayload.url ) } } catch { updateStatusOnMain( requestId: nil, newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")) ) return false } updateStatusOnMain(requestId: newRequestID, newStatus: .synced) // Prefetch the newly saved content if let itemID = newRequestID, let currentViewer = services.dataService.currentViewer?.username, (try? await services.dataService.loadArticleContentWithRetries(itemID: itemID, username: currentViewer)) != nil { updateStatusOnMain(requestId: requestId, newStatus: .synced, objectID: linkedItemObjectID) } return true } func updateStatusOnMain(requestId: String?, newStatus: ShareExtensionStatus, objectID: NSManagedObjectID? = nil) { DispatchQueue.main.async { self.status = newStatus if let requestId = requestId { self.requestId = requestId } if let objectID = objectID { self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? LinkedItem if let title = self.linkedItem?.title { self.title = title } self.url = self.linkedItem?.pageURLString } } } } public enum ShareExtensionStatus: Equatable { public static func == (lhs: ShareExtensionStatus, rhs: ShareExtensionStatus) -> Bool { lhs.displayMessage == rhs.displayMessage } case processing case saved case synced case failed(error: SaveArticleError) case syncFailed(error: SaveArticleError) var displayMessage: String { switch self { 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)" } } } private extension SaveArticleError { var displayMessage: String { switch self { case .unauthorized: return LocalText.extensionAppUnauthorized case .network: return LocalText.errorNetwork case .badData, .unknown: return LocalText.errorGeneric } } }