Merge pull request #856 from omnivore-app/main

Web production deployment
This commit is contained in:
Jackson Harper 2022-06-23 00:46:02 -07:00 committed by GitHub
commit 85efcd892f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
119 changed files with 3062 additions and 2076 deletions

View file

@ -28,6 +28,14 @@
<string>omnivore</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.267918240109-bdghlau7nsq2480c4l8gdgh6mrarokta</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>

View file

@ -32,6 +32,14 @@
<string>omnivore</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.267918240109-bdghlau7nsq2480c4l8gdgh6mrarokta</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>

View file

@ -1229,7 +1229,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1239,11 +1239,12 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
@ -1260,7 +1261,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1270,10 +1271,11 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
@ -1341,7 +1343,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1352,7 +1354,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1375,7 +1377,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1386,7 +1388,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1441,7 +1443,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1473,7 +1475,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1512,7 +1514,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1538,7 +1540,7 @@
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1551,7 +1553,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1560,6 +1562,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.Omnivore-Extension";
PRODUCT_NAME = SafariExtension;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
@ -1576,7 +1579,7 @@
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1589,7 +1592,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1597,6 +1600,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.Omnivore-Extension";
PRODUCT_NAME = SafariExtension;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
@ -1664,7 +1668,8 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Entitlements/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/ShareExtension.plist;
@ -1674,9 +1679,10 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_COMPILATION_MODE = wholemodule;
@ -1728,7 +1734,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1746,7 +1752,8 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Entitlements/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/ShareExtension.plist;
@ -1756,9 +1763,10 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;

View file

@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/abseil-cpp-SwiftPM.git",
"state" : {
"revision" : "fffc3c2729be5747390ad02d5100291a0d9ad26a",
"version" : "0.20200225.4"
"revision" : "d302de612e3d57c6f4afaf087da18fba8eac72a7",
"version" : "0.20220203.1"
}
},
{
@ -23,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS.git",
"state" : {
"revision" : "01131d68346c8ae552961c768d583c715fbe1410",
"version" : "1.4.0"
"revision" : "33660c271c961f8ce1084cc13f2ea8195e864f7d",
"version" : "1.5.0"
}
},
{
@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/boringssl-SwiftPM.git",
"state" : {
"revision" : "734a8247442fde37df4364c21f6a0085b6a36728",
"version" : "0.7.2"
"revision" : "79db6516894a932d0ddaff3b05b9da1e4f6c4069",
"version" : "0.9.0"
}
},
{
@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "08686f04881483d2bc098b2696e674c0ba135e47",
"version" : "8.10.0"
"revision" : "111d8d6ad1a1afd6c8e9561d26e55ab1e74fcb42",
"version" : "8.15.0"
}
},
{
@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "9b2f6aca5b4685c45f9f5481f19bee8e7982c538",
"version" : "8.9.1"
"revision" : "ef819db8c58657a6ca367322e73f3b6322afe0a2",
"version" : "8.15.0"
}
},
{
@ -68,8 +68,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "15ccdfd25ac55b9239b82809531ff26605e7556e",
"version" : "9.1.2"
"revision" : "b905c49326b72211531ed9d7baa02d724828a8dc",
"version" : "9.1.4"
}
},
{
"identity" : "googlesignin-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleSignIn-iOS",
"state" : {
"revision" : "9450e779619fc184d360c9f7ce61023587f7e1f4",
"version" : "6.2.2"
}
},
{
@ -77,17 +86,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "b3bb0c5551fb3f80ca939829639ab5b093edd14f",
"version" : "7.7.0"
"revision" : "f4abe56ce62a779e64b525eb133c8fc2a84bbc1f",
"version" : "7.7.1"
}
},
{
"identity" : "grpc-swiftpm",
"identity" : "grpc-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/grpc-SwiftPM.git",
"location" : "https://github.com/grpc/grpc-ios.git",
"state" : {
"revision" : "fb405dd2c7901485f7e158b24e3a0a47e4efd8b5",
"version" : "1.28.4"
"revision" : "2af4f6e9c2b18beae228f50b1198c641be859d2b",
"version" : "1.44.2-grpc"
}
},
{
@ -95,8 +104,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "bc6a19702ac76ac4e488b68148710eb815f9bc56",
"version" : "1.7.0"
"revision" : "4e9bbf2808b8fee444e84a48f5f3c12641987d3e",
"version" : "1.7.2"
}
},
{
"identity" : "gtmappauth",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GTMAppAuth.git",
"state" : {
"revision" : "b9d1683be336ba8c8d1c6867bafeb056a5399700",
"version" : "1.3.0"
}
},
{
@ -131,8 +149,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "611337c330350c9c1823ad6d671e7f936af5ee13",
"version" : "2.0.0"
"revision" : "46c1e6b5ac09d8f82c991061c659f67e573d425d",
"version" : "2.1.0"
}
},
{
@ -141,7 +159,7 @@
"location" : "https://github.com/PSPDFKit/PSPDFKit-SP",
"state" : {
"branch" : "master",
"revision" : "0e18629c443e3f39ecfee0f600d9ef5551ecf488"
"revision" : "b7e5465ab62f5b48735145756e371502fa2a24f0"
}
},
{
@ -176,8 +194,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "7e2c5f3cbbeea68e004915e3a8961e20bd11d824",
"version" : "1.18.0"
"revision" : "e1499bc69b9040b29184f7f2996f7bab467c1639",
"version" : "1.19.0"
}
},
{

View file

@ -32,7 +32,7 @@ let package = Package(
.target(
name: "Services",
dependencies: [
.product(name: "AppAuth", package: "AppAuth-iOS"),
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
"Valet",
.product(name: "SwiftGraphQL", package: "swift-graphql"),
"Models",
@ -63,11 +63,11 @@ var appPackageDependencies: [Target.Dependency] {
var dependencies: [Package.Dependency] {
var deps: [Package.Dependency] = [
.package(url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")),
.package(url: "https://github.com/Square/Valet", from: "4.1.2"),
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"),
.package(url: "git@github.com:segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0"))
.package(url: "git@github.com:segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2")
]
// #if canImport(UIKit)
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", branch: "master"))

View file

@ -1,10 +1,3 @@
//
// File.swift
//
//
// Created by Jackson Harper on 6/1/22.
//
import Foundation
import Models
import Services
@ -18,20 +11,25 @@ class ExtensionSaveService {
self.queue = OperationQueue()
}
private func queueSaveOperation(_ pageScrape: PageScrapePayload, shareExtensionViewModel: ShareExtensionChildViewModel) {
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
guard !expiring else {
self.queue.cancelAllOperations()
#if os(iOS)
private func queueSaveOperation(
_ pageScrape: PageScrapePayload,
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, shareExtensionViewModel: shareExtensionViewModel)
self.queue.addOperation(operation)
self.queue.waitUntilAllOperationsAreFinished()
return
}
let operation = SaveOperation(pageScrapePayload: pageScrape, shareExtensionViewModel: shareExtensionViewModel)
self.queue.addOperation(operation)
self.queue.waitUntilAllOperationsAreFinished()
}
}
#endif
public func save(_ extensionContext: NSExtensionContext, shareExtensionViewModel: ShareExtensionChildViewModel) {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
@ -68,7 +66,10 @@ class ExtensionSaveService {
}
}
}
self.queueSaveOperation(payload, shareExtensionViewModel: shareExtensionViewModel)
#if os(iOS)
// TODO: need alternative call for macos
self.queueSaveOperation(payload, shareExtensionViewModel: shareExtensionViewModel)
#endif
case .failure:
DispatchQueue.main.async {
shareExtensionViewModel.status = .failed(error: .unknown(description: "Could not retrieve content"))
@ -85,6 +86,7 @@ class ExtensionSaveService {
var queue: OperationQueue?
var uploadTask: URLSessionTask?
// swiftlint:disable:next nesting
enum State: Int {
case created
case started
@ -170,7 +172,11 @@ class ExtensionSaveService {
case .none:
requestId = try await services.dataService.createPageFromUrl(id: requestId, url: pageScrapePayload.url)
case let .pdf(localUrl):
try await services.dataService.createPageFromPdf(id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url)
try await services.dataService.createPageFromPdf(
id: requestId,
localPdfURL: localUrl,
url: pageScrapePayload.url
)
case let .html(html, title, _):
requestId = try await services.dataService.createPage(
id: requestId,

View file

@ -1,4 +1,3 @@
import Combine
import Foundation
import Models
import Services
@ -55,7 +54,12 @@ struct ShareExtensionView: View {
var body: some View {
ShareExtensionChildView(
viewModel: childViewModel,
onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
onAppearAction: {
viewModel.savePage(
extensionContext: extensionContext,
shareExtensionViewModel: childViewModel
)
},
readNowButtonAction: { viewModel.handleReadNowAction(requestId: $0, extensionContext: extensionContext) },
dismissButtonTappedAction: { _, _ in
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)

View file

@ -2,6 +2,7 @@ import Combine
import SwiftUI
import Utils
// swiftlint:disable file_length
#if os(iOS)
import PSPDFKit
import PSPDFKitUI
@ -147,7 +148,7 @@ import Utils
pdfStateObject.document = document
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
} else {
errorMessage = "Unable to download PDF: \(pdfURL)"
errorMessage = "Unable to download PDF: \(pdfURL?.description ?? "")"
}
}
}
@ -220,6 +221,7 @@ import Utils
return result
}
// swiftlint:disable:next function_body_length
func highlightSelection(pageView: PDFPageView, selectedText: String, dataService: DataService) -> String {
let highlightID = UUID().uuidString.lowercased()
let quote = quoteFromSelectedText(selectedText)

View file

@ -1,27 +1,23 @@
import Combine
import CoreData
import Foundation
import Models
import Services
import Utils
public final class PDFViewerViewModel: ObservableObject {
@Published public var errorMessage: String?
@Published public var readerView: Bool = false
final class PDFViewerViewModel: ObservableObject {
@Published var errorMessage: String?
@Published var readerView: Bool = false
public let pdfItem: PDFItem
let pdfItem: PDFItem
var subscriptions = Set<AnyCancellable>()
public init(pdfItem: PDFItem) {
init(pdfItem: PDFItem) {
self.pdfItem = pdfItem
}
public func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
}
public func createHighlight(
func createHighlight(
dataService: DataService,
shortId: String,
highlightID: String,
@ -37,7 +33,8 @@ public final class PDFViewerViewModel: ObservableObject {
)
}
public func mergeHighlight(
// swiftlint:disable:next function_parameter_count
func mergeHighlight(
dataService: DataService,
shortId: String,
highlightID: String,
@ -55,13 +52,13 @@ public final class PDFViewerViewModel: ObservableObject {
)
}
public func removeHighlights(dataService: DataService, highlightIds: [String]) {
func removeHighlights(dataService: DataService, highlightIds: [String]) {
highlightIds.forEach { highlightID in
dataService.deleteHighlight(highlightID: highlightID)
}
}
public func updateItemReadProgress(dataService: DataService, percent: Double, anchorIndex: Int) {
func updateItemReadProgress(dataService: DataService, percent: Double, anchorIndex: Int) {
dataService.updateLinkReadingProgress(
itemID: pdfItem.itemID,
readingProgress: percent,
@ -69,7 +66,7 @@ public final class PDFViewerViewModel: ObservableObject {
)
}
public func highlightShareURL(dataService: DataService, shortId: String) -> URL? {
func highlightShareURL(dataService: DataService, shortId: String) -> URL? {
let baseURL = dataService.appEnvironment.serverBaseURL
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
@ -82,29 +79,22 @@ public final class PDFViewerViewModel: ObservableObject {
return components?.url
}
public var itemDownloaded: Bool {
if let localPdfURL = pdfItem.localPdfURL, FileManager.default.fileExists(atPath: localPdfURL.path) {
return true
}
return false
}
public func downloadPDF(dataService: DataService) async -> URL? {
func downloadPDF(dataService: DataService) async -> URL? {
do {
if itemDownloaded {
return pdfItem.localPdfURL
if let localPdfURL = pdfItem.localPdfURL, FileManager.default.fileExists(atPath: localPdfURL.path) {
return localPdfURL
}
if let tempURL = pdfItem.tempPDFURL {
if let localURL = try? PDFUtils.copyToLocal(url: tempURL) {
if (try? PDFUtils.copyToLocal(url: tempURL)) != nil {
return tempURL
}
}
if let localURL = try await dataService.fetchPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL) {
return localURL
}
return try await dataService.loadPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL)
} catch {
print("error downloading PDF", error)
return nil
}
return nil
}
}

View file

@ -102,4 +102,5 @@ public final class Services {
#endif
// Command to simulate BG Task
// swiftlint:disable:next line_length
// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.omnivore.fetchLinkedItems"]

View file

@ -5,6 +5,7 @@ import UserNotifications
import Utils
import Views
// swiftlint:disable file_length
#if os(iOS)
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
@ -196,7 +197,10 @@ import Views
}
.padding(.horizontal)
.sheet(isPresented: $showLabelsSheet) {
FilterByLabelsView(initiallySelected: viewModel.selectedLabels, initiallyNegated: viewModel.negatedLabels) {
FilterByLabelsView(
initiallySelected: viewModel.selectedLabels,
initiallyNegated: viewModel.negatedLabels
) {
self.viewModel.selectedLabels = $0
self.viewModel.negatedLabels = $1
}

View file

@ -62,7 +62,7 @@ import Views
Task { _ = try? await dataService.fetchViewer() }
}
let queryResult = try? await dataService.fetchLinkedItems(
let queryResult = try? await dataService.loadLinkedItems(
limit: 10,
searchQuery: searchQuery,
cursor: isRefresh ? nil : cursor
@ -81,7 +81,7 @@ import Views
let newItems: [LinkedItem] = {
var itemObjects = [LinkedItem]()
dataService.viewContext.performAndWait {
itemObjects = queryResult.items.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
}
return itemObjects
}()

View file

@ -1,4 +1,3 @@
import Combine
import CoreData
import Models
import Services
@ -11,8 +10,6 @@ import Views
let item: LinkedItem?
@Published var webAppWrapperViewModel: WebAppWrapperViewModel?
var subscriptions = Set<AnyCancellable>()
init(linkedItemObjectID: NSManagedObjectID, dataService: DataService) {
if let linkedItem = dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem {
self.pdfItem = PDFItem.make(item: linkedItem)

View file

@ -66,24 +66,26 @@ struct LinkedItemTitleEditView: View {
NavigationView {
editForm
.navigationTitle("Edit Title and Description")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .barTrailing) {
Button(
action: {
viewModel.submit(dataService: dataService, item: item)
presentationMode.wrappedValue.dismiss()
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
)
}
ToolbarItem(placement: .barLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
}
#endif
.toolbar {
ToolbarItem(placement: .barTrailing) {
Button(
action: {
viewModel.submit(dataService: dataService, item: item)
presentationMode.wrappedValue.dismiss()
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
)
}
ToolbarItem(placement: .barLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
}
}
}
.task { viewModel.load(item: item) }
}

View file

@ -5,7 +5,7 @@ import SwiftUI
import Utils
import Views
final class CreateProfileViewModel: ObservableObject {
@MainActor final class CreateProfileViewModel: ObservableObject {
private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil)
var isConfigured = false
@ -39,7 +39,9 @@ final class CreateProfileViewModel: ObservableObject {
switch profileOrError {
case let .left(userProfile):
submitProfile(userProfile: userProfile, authenticator: authenticator)
Task {
await submitProfile(userProfile: userProfile, authenticator: authenticator)
}
case let .right(errorMessage):
validationErrorMessage = errorMessage
}
@ -51,41 +53,37 @@ final class CreateProfileViewModel: ObservableObject {
return
}
dataService.validateUsernamePublisher(username: username).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(usernameError) = completion else { return }
Task {
do {
try await dataService.validateUsernamePublisher(username: username)
} catch {
let usernameError = (error as? UsernameAvailabilityError) ?? .unknown
switch usernameError {
case .tooShort:
self?.potentialUsernameStatus = .tooShort
potentialUsernameStatus = .tooShort
case .tooLong:
self?.potentialUsernameStatus = .tooLong
potentialUsernameStatus = .tooLong
case .invalidPattern:
self?.potentialUsernameStatus = .invalidPattern
potentialUsernameStatus = .invalidPattern
case .nameUnavailable:
self?.potentialUsernameStatus = .unavailable
potentialUsernameStatus = .unavailable
case .internalServer, .unknown:
self?.loginError = .unknown
loginError = .unknown
case .network:
self?.loginError = .network
loginError = .network
}
},
receiveValue: { [weak self] in
self?.potentialUsernameStatus = .available
}
)
.store(in: &subscriptions)
}
}
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) {
authenticator
.createAccount(userProfile: userProfile).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async {
do {
try await authenticator.createAccount(userProfile: userProfile)
} catch {
if let error = error as? LoginError {
loginError = error
}
}
}
func configure(profile: UserProfile, dataService: DataService) {
@ -168,7 +166,7 @@ struct CreateProfileView: View {
.foregroundColor(.red)
}
}
.animation(.default)
.animation(.default, value: 0.35)
VStack(alignment: .leading, spacing: 6) {
Text("Bio (optional)")

View file

@ -1,27 +1,22 @@
import Combine
import Models
import Services
import SwiftUI
import Utils
import Views
final class NewAppleSignupViewModel: ObservableObject {
@MainActor final class NewAppleSignupViewModel: ObservableObject {
@Published var loginError: LoginError?
var subscriptions = Set<AnyCancellable>()
init() {}
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) {
authenticator
.createAccount(userProfile: userProfile).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async {
do {
try await authenticator.createAccount(userProfile: userProfile)
} catch {
if let error = error as? LoginError {
loginError = error
}
}
}
}
@ -48,7 +43,11 @@ struct NewAppleSignupView: View {
VStack {
Button(
action: { viewModel.submitProfile(userProfile: userProfile, authenticator: authenticator) },
action: {
Task {
await viewModel.submitProfile(userProfile: userProfile, authenticator: authenticator)
}
},
label: { Text("Continue") }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))

View file

@ -1,12 +1,11 @@
import AuthenticationServices
import Combine
import Models
import Services
import SwiftUI
import Utils
import Views
final class RegistrationViewModel: ObservableObject {
@MainActor final class RegistrationViewModel: ObservableObject {
enum RegistrationState {
case createProfile(userProfile: UserProfile)
case newAppleSignUp(userProfile: UserProfile)
@ -15,12 +14,10 @@ final class RegistrationViewModel: ObservableObject {
@Published var loginError: LoginError?
@Published var registrationState: RegistrationState?
var subscriptions = Set<AnyCancellable>()
func handleAppleSignInCompletion(result: Result<ASAuthorization, Error>, authenticator: Authenticator) {
switch AppleSigninPayload.parse(authResult: result) {
case let .success(payload):
handleAppleToken(payload: payload, authenticator: authenticator)
Task { await handleAppleToken(payload: payload, authenticator: authenticator) }
case let .failure(error):
switch error {
case .unauthorized, .unknown:
@ -31,67 +28,46 @@ final class RegistrationViewModel: ObservableObject {
}
}
private func handleAppleToken(payload: AppleSigninPayload, authenticator: Authenticator) {
authenticator.submitAppleToken(token: payload.token).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
switch loginError {
case .unauthorized, .unknown:
self?.handleAppleSignUp(authenticator: authenticator, payload: payload)
case .network:
self?.loginError = loginError
}
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
private func handleAppleToken(payload: AppleSigninPayload, authenticator: Authenticator) async {
do {
try await authenticator.submitAppleToken(token: payload.token)
} catch {
let submitTokenError = (error as? LoginError) ?? .unknown
switch submitTokenError {
case .unauthorized, .unknown:
await handleAppleSignUp(authenticator: authenticator, payload: payload)
case .network:
loginError = submitTokenError
}
}
}
private func handleAppleSignUp(authenticator: Authenticator, payload: AppleSigninPayload) {
authenticator
.createPendingAccountUsingApple(token: payload.token, name: payload.fullName)
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { [weak self] userProfile in
if userProfile.name.isEmpty {
self?.registrationState = .createProfile(userProfile: userProfile)
} else {
self?.registrationState = .newAppleSignUp(userProfile: userProfile)
}
}
private func handleAppleSignUp(authenticator: Authenticator, payload: AppleSigninPayload) async {
do {
let pendingUserProfile = try await authenticator.createPendingAccountUsingApple(
token: payload.token,
name: payload.fullName
)
.store(in: &subscriptions)
if pendingUserProfile.name.isEmpty {
registrationState = .createProfile(userProfile: pendingUserProfile)
} else {
registrationState = .newAppleSignUp(userProfile: pendingUserProfile)
}
} catch {
loginError = (error as? LoginError) ?? .unknown
}
}
func handleGoogleAuth(authenticator: Authenticator) {
authenticator
.handleGoogleAuth(presentingViewController: presentingViewController())
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { [weak self] isNewAccount in
if isNewAccount {
self?.registrationState = .createProfile(userProfile: UserProfile(username: "", name: ""))
}
}
)
.store(in: &subscriptions)
func handleGoogleAuth(authenticator: Authenticator) async {
let googleAuthResponse = await authenticator.handleGoogleAuth()
switch googleAuthResponse {
case let .loginError(error):
loginError = error
case .newOmnivoreUser:
registrationState = .createProfile(userProfile: UserProfile(username: "", name: ""))
case .existingOmnivoreUser:
break
}
}
}
private func presentingViewController() -> PlatformViewController? {
#if os(iOS)
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
return scene?.windows
.filter(\.isKeyWindow)
.first?
.rootViewController
#elseif os(macOS)
return nil
#endif
}

View file

@ -97,6 +97,7 @@ struct InnerRootView: View {
}
}
#endif
.onOpenURL { Authenticator.handleGoogleURL(url: $0) }
}
#if os(iOS)

View file

@ -73,6 +73,7 @@ import WebKit
return webView
}
// swiftlint:disable:next cyclomatic_complexity
func updateUIView(_ webView: WKWebView, context: Context) {
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID

View file

@ -10,115 +10,8 @@ import Utils
@Published var errorMessage: String?
func loadItem(dataService: DataService, requestID: String) async {
let username: String? = await {
if let cachedUsername = dataService.currentViewer?.username {
return cachedUsername
}
if let viewerObjectID = try? await dataService.fetchViewer() {
let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer
return viewer?.unwrappedUsername
}
return nil
}()
guard let username = username else { return }
let existing = existingItemOrItemId(dataService: dataService, requestID: requestID)
if let existingItem = existing.existingItem, existingItem.isReadyToRead {
item = existingItem
return
}
// If the page was locally created, make sure they are synced before we pull content
await dataService.syncUnsyncedArticleContent(itemID: existing.itemID)
// Fetch the item and it's content
let item = await fetchLinkedItem(dataService: dataService, requestID: existing.itemID, username: username)
if let item = item, let itemID = item.id {
do {
let articleContent = try await dataService.fetchArticleContent(itemID: itemID, username: username, requestCount: 0)
// We've fetched the article content, now reload the item from core data
if let linkedItem = dataService.viewContext.object(with: item.objectID) as? LinkedItem {
self.item = linkedItem
} else {
self.item = nil
}
} catch {
self.item = nil
}
} else {
self.item = nil
}
}
private func fetchLinkedItem(
dataService: DataService,
requestID: String,
username: String,
requestCount: Int = 1
) async -> LinkedItem? {
guard requestCount < 7 else {
errorMessage = "Unable to fetch item."
return nil
}
print("FETCHING", requestID, requestCount)
if let objectID = try? await dataService.fetchLinkedItem(username: username, itemID: requestID) {
if let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem {
print(" - FROM DATA SERVICE", linkedItem)
return linkedItem
} else {
errorMessage = "Unable to fetch item."
}
return nil
}
// Retry on error
do {
let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000)
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
let existing = existingItemOrItemId(dataService: dataService, requestID: requestID)
if let existingItem = existing.existingItem, existingItem.isReadyToRead {
print(" - FROM CORE DATA SERVICE", existingItem)
return existingItem
}
let result = await fetchLinkedItem(
dataService: dataService,
requestID: existing.itemID,
username: username,
requestCount: requestCount + 1
)
if let result = result {
return result
}
} catch {
errorMessage = "Unable to fetch item."
}
return nil
}
private func existingItemOrItemId(dataService: DataService, requestID: String) -> (existingItem: LinkedItem?, itemID: String) {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID)
if let existingItem = try? dataService.viewContext.fetch(fetchRequest).first {
// If the existing item is synced, we can use it
if let itemID = existingItem.id, existingItem.serverSyncStatus == ServerSyncStatus.isNSync.rawValue {
item = existingItem
return (existingItem: item, itemID: itemID)
}
// If the existing item is not synced, we might have an updated request id
if let existingID = existingItem.id {
return (existingItem: nil, itemID: existingID)
}
}
return (existingItem: nil, itemID: requestID)
guard let objectID = try? await dataService.loadItemContentUsingRequestID(requestID: requestID) else { return }
item = dataService.viewContext.object(with: objectID) as? LinkedItem
}
func trackReadEvent() {
@ -141,8 +34,8 @@ import Utils
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
public var body: some View {
if let item = viewModel.item, item.isReadyToRead {
if let pdfItem = PDFItem.make(item: item), let urlStr = item.pageURLString, let remoteUrl = URL(string: urlStr) {
if let item = viewModel.item {
if let pdfItem = PDFItem.make(item: item) {
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
.navigationBarHidden(true)
.navigationViewStyle(.stack)

View file

@ -16,7 +16,7 @@ struct SafariWebLink: Identifiable {
errorMessage = nil
do {
articleContent = try await dataService.fetchArticleContent(itemID: itemID)
articleContent = try await dataService.loadArticleContentWithRetries(itemID: itemID)
} catch {
if retryCount == 0 {
return await loadContent(dataService: dataService, itemID: itemID, retryCount: 1)

View file

@ -1,4 +1,3 @@
import Combine
import Models
import Services
import SwiftUI
@ -139,7 +138,9 @@ struct WelcomeView: View {
if AppKeys.sharedInstance?.iosClientGoogleId != nil {
GoogleAuthButton {
viewModel.handleGoogleAuth(authenticator: authenticator)
Task {
await viewModel.handleGoogleAuth(authenticator: authenticator)
}
}
}
}

View file

@ -14,9 +14,14 @@ public class PersistentContainer: NSPersistentContainer {
// Store the sqlite file in the app group container.
// This allows shared access for app and app extensions.
let appGroupID = "group.app.omnivoreapp"
#if os(iOS)
let appGroupID = "group.app.omnivoreapp"
#else
let appGroupID = "QJF2XZ86HB.app.omnivore.app"
#endif
let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite")
container.persistentStoreDescriptions.first!.url = appGroupContainerURL
container.viewContext.automaticallyMergesChangesFromParent = true

View file

@ -1,10 +1,11 @@
import CoreData
import Foundation
public enum ArticleContentStatus {
case failed
case processing
case succeeded
case unknown
public enum ArticleContentStatus: String {
case failed = "FAILED"
case processing = "PROCESSING"
case succeeded = "SUCCEEDED"
case unknown = "UNKNOWN"
}
public struct ArticleContent {
@ -12,16 +13,25 @@ public struct ArticleContent {
public let htmlContent: String
public let highlightsJSONString: String
public let contentStatus: ArticleContentStatus
public let objectID: NSManagedObjectID?
public init(
title: String,
htmlContent: String,
highlightsJSONString: String,
contentStatus: ArticleContentStatus
contentStatus: ArticleContentStatus,
objectID: NSManagedObjectID?
) {
self.title = title
self.htmlContent = htmlContent
self.highlightsJSONString = highlightsJSONString
self.contentStatus = contentStatus
self.objectID = objectID
}
}
public extension String {
var asArticleContentStatus: ArticleContentStatus? {
ArticleContentStatus(rawValue: self)
}
}

View file

@ -2,12 +2,12 @@ import CoreData
import Foundation
import Utils
public struct HomeFeedData { // TODO: rename this
public let items: [NSManagedObjectID]
public struct LinkedItemQueryResult {
public let itemIDs: [NSManagedObjectID]
public let cursor: String?
public init(items: [NSManagedObjectID], cursor: String?) {
self.items = items
public init(itemIDs: [NSManagedObjectID], cursor: String?) {
self.itemIDs = itemIDs
self.cursor = cursor
}
}
@ -45,15 +45,6 @@ public extension LinkedItem {
readingProgress >= 0.98
}
var isReadyToRead: Bool {
if isPDF {
// If its a PDF we verify the local file is available
return PDFUtils.exists(filename: localPDF) || PDFUtils.tempExists(tempPDFURL: tempPDFURL)
}
// Check the state and whether we have HTML
return state == "SUCCEEDED"
}
var isPDF: Bool {
if let contentReader = contentReader {
return contentReader == "PDF"

View file

@ -58,11 +58,17 @@ public extension LinkedItemFilter {
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
case .readlater:
// non-archived or deleted items without the Newsletter label
let nonNewsletterLabelPredicate = NSPredicate(format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0")
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate])
let nonNewsletterLabelPredicate = NSPredicate(
format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate
])
case .newsletters:
// non-archived or deleted items with the Newsletter label
let newsletterLabelPredicate = NSPredicate(format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0")
let newsletterLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
case .all:
// include everything undeleted

View file

@ -256,28 +256,14 @@ private extension PageScrapePayload {
let localFile = UUID().uuidString.lowercased() + ".pdf"
dest.appendPathComponent(localFile)
do {
print("EXISTING PDF URL", url)
let attr = try? FileManager.default.attributesOfItem(atPath: url.path)
if let attr = attr {
print("EXISTING FILE SIZE", attr[.size])
}
try FileManager.default.copyItem(at: url, to: dest)
print("COPIED TO URL", dest)
let attr2 = try? FileManager.default.attributesOfItem(atPath: dest.path)
if let attr2 = attr2 {
print("COPIED FILE SIZE", attr2[.size])
}
return PageScrapePayload(url: url.absoluteString, localUrl: dest)
} catch {
print("error copying file locally", error)
}
}
// TODO:
// Don't try to handle file URLs that are not PDFs.
// In the future we can add image and other file type support here
// If file is not a pdf then return nil
return nil
}
return PageScrapePayload(url: url.absoluteString)

View file

@ -1,4 +1,3 @@
import Combine
import Foundation
import Models
@ -6,12 +5,12 @@ public extension Authenticator {
func createPendingAccountUsingApple(
token: String,
name: PersonNameComponents?
) -> AnyPublisher<UserProfile, LoginError> {
) async throws -> UserProfile {
let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name)
return createPendingAccount(params: params)
return try await createPendingAccount(params: params)
}
func createAccount(userProfile: UserProfile) -> AnyPublisher<Void, LoginError> {
func createAccount(userProfile: UserProfile) async throws {
let params = CreateAccountParams(
pendingUserToken: pendingUserToken ?? "",
userProfile: userProfile
@ -19,36 +18,29 @@ public extension Authenticator {
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
return networker
.createAccount(params: encodedParams)
.tryMap { [weak self] in
try ValetKey.authCookieString.setValue($0.commentedAuthCookieString)
try ValetKey.authToken.setValue($0.authToken)
self?.pendingUserToken = nil
self?.isLoggedIn = true
do {
let authPayload = try await networker.createAccount(params: encodedParams)
try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString)
try ValetKey.authToken.setValue(authPayload.authToken)
DispatchQueue.main.async {
self.pendingUserToken = nil
self.isLoggedIn = true
}
.mapError { error in
let serverError = (error as? ServerError) ?? ServerError.unknown
return LoginError.make(serverError: serverError)
}
.eraseToAnyPublisher()
} catch {
let serverError = (error as? ServerError) ?? ServerError.unknown
throw LoginError.make(serverError: serverError)
}
}
}
extension Authenticator {
func createPendingAccount(params: CreatePendingAccountParams) -> AnyPublisher<UserProfile, LoginError> {
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
return networker
.createPendingUser(params: encodedParams)
.tryMap { [weak self] in
self?.pendingUserToken = $0.pendingUserToken
return $0.pendingUserProfile
}
.mapError { error in
let serverError = (error as? ServerError) ?? ServerError.unknown
return LoginError.make(serverError: serverError)
}
.eraseToAnyPublisher()
func createPendingAccount(params: CreatePendingAccountParams) async throws -> UserProfile {
do {
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams)
return pendingUserAuthPayload.pendingUserProfile
} catch {
throw LoginError.make(serverError: (error as? ServerError) ?? .unknown)
}
}
}

View file

@ -1,20 +1,18 @@
import Combine
import Foundation
import Models
public extension Authenticator {
func submitAppleToken(token: String) -> AnyPublisher<Void, LoginError> {
networker
.submitAppleToken(token: token)
.tryMap { [weak self] in
try ValetKey.authCookieString.setValue($0.commentedAuthCookieString)
try ValetKey.authToken.setValue($0.authToken)
self?.isLoggedIn = true
func submitAppleToken(token: String) async throws {
do {
let authPayload = try await networker.submitAppleToken(token: token)
try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString)
try ValetKey.authToken.setValue(authPayload.authToken)
DispatchQueue.main.async {
self.isLoggedIn = true
}
.mapError { error in
let serverError = (error as? ServerError) ?? ServerError.unknown
return LoginError.make(serverError: serverError)
}
.eraseToAnyPublisher()
} catch {
let serverError = (error as? ServerError) ?? ServerError.unknown
throw LoginError.make(serverError: serverError)
}
}
}

View file

@ -1,6 +1,5 @@
import AppAuth
import Combine
import Foundation
import GoogleSignIn
import Models
import Utils
import WebKit
@ -8,6 +7,10 @@ import WebKit
public final class Authenticator: ObservableObject {
public static var unregisterIntercomUser: (() -> Void)?
public static func handleGoogleURL(url: URL) {
GIDSignIn.sharedInstance.handle(url)
}
public enum AuthStatus {
case loggedOut
case pendingUser
@ -15,18 +18,11 @@ public final class Authenticator: ObservableObject {
}
@Published public internal(set) var isLoggedIn: Bool
@Published public var pendinguserProfile = UserProfile(username: "", name: "", bio: nil)
let networker: Networker
var subscriptions = Set<AnyCancellable>()
var currentAuthorizationFlow: OIDExternalUserAgentSession?
var pendingUserToken: String?
#if os(macOS)
var authRedirectHandler: OIDRedirectHTTPHandler?
#endif
public init(networker: Networker) {
self.networker = networker
self.isLoggedIn = ValetKey.authToken.exists

View file

@ -1,123 +1,96 @@
import AppAuth
import Combine
import Foundation
import GoogleSignIn
import Models
import Utils
#if os(iOS)
import UIKit
public enum GoogleAuthResponse {
case loginError(error: LoginError)
case newOmnivoreUser
case existingOmnivoreUser
}
public extension Authenticator {
func handleGoogleAuth(presentingViewController: PlatformViewController?) -> AnyPublisher<Bool, LoginError> {
Future { [weak self] promise in
guard let self = self, let presenting = presentingViewController else { return }
// swiftlint:disable:next line_length
self.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: self.googleAuthRequest(redirectURL: nil), presenting: presenting) { authState, authError in
self.resolveAuthResponse(promise: promise, authState: authState, authError: authError)
}
}
.eraseToAnyPublisher()
extension Authenticator {
public func handleGoogleAuth() async -> GoogleAuthResponse {
let idToken = await withCheckedContinuation { continuation in
googleSignIn { continuation.resume(returning: $0) }
}
}
#endif
#if os(macOS)
import AppKit
guard let idToken = idToken else { return .loginError(error: .unauthorized) }
public extension Authenticator {
func handleGoogleAuth(presentingViewController _: PlatformViewController?) -> AnyPublisher<Bool, LoginError> {
authRedirectHandler = OIDRedirectHTTPHandler(
successURL: URL(string: "https://omnivore.app")!
)
let redirectURL = authRedirectHandler?.startHTTPListener(nil)
let authRequest = googleAuthRequest(redirectURL: redirectURL)
return Future { [weak self] promise in
guard let self = self else { return }
// swiftlint:disable:next line_length
self.authRedirectHandler?.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: authRequest) { authState, authError in
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
self.resolveAuthResponse(promise: promise, authState: authState, authError: authError)
}
do {
let authPayload = try await networker.submitGoogleToken(idToken: idToken)
try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString)
try ValetKey.authToken.setValue(authPayload.authToken)
DispatchQueue.main.async {
self.isLoggedIn = true
}
.eraseToAnyPublisher()
}
}
#endif
return .existingOmnivoreUser
} catch {
let loginError = (error as? LoginError) ?? .unknown
private extension Authenticator {
func resolveAuthResponse(
promise: @escaping (Result<Bool, LoginError>) -> Void,
authState: OIDAuthState?,
authError: Error?
) {
if let idToken = authState?.lastTokenResponse?.idToken {
Task {
do {
let authPayload = try await networker.submitGoogleToken(idToken: idToken)
try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString)
try ValetKey.authToken.setValue(authPayload.authToken)
DispatchQueue.main.async {
self.isLoggedIn = true
}
} catch {
if let error = error as? LoginError {
switch error {
case .unauthorized, .unknown:
self.resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError)
case .network:
promise(.failure(error))
}
self.resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError)
}
}
switch loginError {
case .unauthorized, .unknown:
return await createPendingUser(idToken: idToken)
case .network:
return .loginError(error: .network)
}
} else {
resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError)
}
}
func resolveAuthResponseForAccountCreation(
promise: @escaping (Result<Bool, LoginError>) -> Void,
authState: OIDAuthState?,
authError _: Error?
) {
if let idToken = authState?.lastTokenResponse?.idToken {
func createPendingUser(idToken: String) async -> GoogleAuthResponse {
do {
let params = CreatePendingAccountParams(token: idToken, provider: .google, fullName: nil)
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
networker
.createPendingUser(params: encodedParams)
.sink { completion in
guard case let .failure(serverError) = completion else { return }
promise(.failure(LoginError.make(serverError: serverError)))
} receiveValue: { [weak self] in
self?.pendingUserToken = $0.pendingUserToken
promise(.success(true))
}
.store(in: &subscriptions)
} else {
promise(.failure(.unauthorized))
let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams)
pendingUserToken = pendingUserAuthPayload.pendingUserToken
return .newOmnivoreUser
} catch {
let loginError = LoginError.make(serverError: (error as? ServerError) ?? .unknown)
return .loginError(error: loginError)
}
}
func googleAuthRequest(redirectURL: URL?) -> OIDAuthorizationRequest {
let authEndpoint = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!
let tokenEndpoint = URL(string: "https://www.googleapis.com/oauth2/v4/token")!
let iosClientGoogleId = AppKeys.sharedInstance?.iosClientGoogleId ?? ""
let scopes = ["profile", "email"]
func googleSignIn(completion: @escaping (String?) -> Void) {
#if os(iOS)
let presenting = presentingViewController()
#else
let presenting = NSApplication.shared.windows.first
#endif
return OIDAuthorizationRequest(
configuration: OIDServiceConfiguration(authorizationEndpoint: authEndpoint, tokenEndpoint: tokenEndpoint),
clientId: "\(iosClientGoogleId).apps.googleusercontent.com",
scopes: scopes,
redirectURL: redirectURL ?? URL(
string: "com.googleusercontent.apps.\(iosClientGoogleId):/oauth2redirect/google"
)!,
responseType: "code",
additionalParameters: nil
)
guard let presenting = presenting else {
completion(nil)
return
}
let clientID = "\(AppKeys.sharedInstance?.iosClientGoogleId ?? "").apps.googleusercontent.com"
GIDSignIn.sharedInstance.signIn(
with: GIDConfiguration(clientID: clientID),
presenting: presenting
) { user, error in
guard let user = user, error == nil else {
completion(nil)
return
}
user.authentication.do { authentication, error in
guard let idToken = authentication?.idToken, error == nil else {
completion(nil)
return
}
completion(idToken)
}
}
}
}
private func presentingViewController() -> PlatformViewController? {
#if os(iOS)
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
return scene?.windows
.filter(\.isKeyWindow)
.first?
.rootViewController
#elseif os(macOS)
return nil
#endif
}

View file

@ -0,0 +1,228 @@
import CoreData
import Foundation
import Models
import Utils
struct PendingLink {
let itemID: String
let retryCount: Int
}
extension DataService {
func prefetchPage(pendingLink: PendingLink, username: String) async {
let content = try? await loadArticleContent(username: username, itemID: pendingLink.itemID, useCache: false)
if content?.contentStatus == .processing, pendingLink.retryCount < 7 {
let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000)
do {
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
logger.debug("fetching content for \(pendingLink.itemID). retry count: \(pendingLink.retryCount)")
await prefetchPage(
pendingLink: PendingLink(
itemID: pendingLink.itemID,
retryCount: pendingLink.retryCount + 1
),
username: username
)
} catch {
logger.debug("prefetching task was cancelled")
}
}
}
func loadArticleContent(username: String, itemID: String, useCache: Bool) async throws -> ArticleContent {
var objectID: NSManagedObjectID?
if useCache, let cachedContent = await cachedArticleContent(itemID: itemID) {
return cachedContent
}
// If the page was locally created, make sure they are synced before we pull content
await syncUnsyncedArticleContent(itemID: itemID)
let fetchResult = try await articleContentFetch(username: username, itemID: itemID)
let contentStatus = fetchResult.item.isPDF ? .succeeded : fetchResult.item.state
if contentStatus == .succeeded {
do {
objectID = try await persistArticleContent(articleProps: fetchResult)
} catch {
var message = "unknown error"
let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error")
if case let BasicError.message(messageText) = basicError {
message = messageText
}
throw ContentFetchError.unknown(description: message)
}
}
return ArticleContent(
title: fetchResult.item.title,
htmlContent: fetchResult.htmlContent,
highlightsJSONString: fetchResult.highlights.asJSONString,
contentStatus: fetchResult.item.isPDF ? .succeeded : fetchResult.item.state,
objectID: objectID
)
}
func cachedArticleContent(itemID: String) async -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)
let context = backgroundContext
return await context.perform(schedule: .immediate) {
guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return nil }
guard let htmlContent = linkedItem.htmlContent else { return nil }
let highlights = linkedItem
.highlights
.asArray(of: Highlight.self)
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
return ArticleContent(
title: linkedItem.unwrappedTitle,
htmlContent: htmlContent,
highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString,
contentStatus: .succeeded,
objectID: linkedItem.objectID
)
}
}
// swiftlint:disable:next function_body_length
func persistArticleContent(articleProps: ArticleProps) async throws -> NSManagedObjectID? {
var needsPDFDownload = false
var objectID: NSManagedObjectID?
await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", articleProps.item.id)
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
objectID = linkedItem.objectID
let highlightObjects = articleProps.highlights.map {
$0.asManagedObject(context: self.backgroundContext)
}
linkedItem.addToHighlights(NSSet(array: highlightObjects))
linkedItem.htmlContent = articleProps.htmlContent
linkedItem.id = articleProps.item.id
linkedItem.state = articleProps.item.state.rawValue
linkedItem.title = articleProps.item.title
linkedItem.createdAt = articleProps.item.createdAt
linkedItem.savedAt = articleProps.item.savedAt
linkedItem.readingProgress = articleProps.item.readingProgress
linkedItem.readingProgressAnchor = Int64(articleProps.item.readingProgressAnchor)
linkedItem.imageURLString = articleProps.item.imageURLString
linkedItem.onDeviceImageURLString = articleProps.item.onDeviceImageURLString
linkedItem.pageURLString = articleProps.item.pageURLString
linkedItem.descriptionText = articleProps.item.descriptionText
linkedItem.publisherURLString = articleProps.item.publisherURLString
linkedItem.author = articleProps.item.author
linkedItem.publishDate = articleProps.item.publishDate
linkedItem.slug = articleProps.item.slug
linkedItem.readAt = articleProps.item.readAt
linkedItem.isArchived = articleProps.item.isArchived
linkedItem.contentReader = articleProps.item.contentReader
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
if articleProps.item.isPDF {
needsPDFDownload = true
// Check if we already have the PDF item locally. Either in temporary
// space, or in the documents directory
if let localPDF = existingItem?.localPDF {
if PDFUtils.exists(filename: localPDF) {
linkedItem.localPDF = localPDF
needsPDFDownload = false
}
}
if let tempPDFURL = existingItem?.tempPDFURL {
linkedItem.localPDF = try? PDFUtils.moveToLocal(url: tempPDFURL)
_ = PDFUtils.exists(filename: linkedItem.localPDF)
if linkedItem.localPDF != nil {
needsPDFDownload = false
}
}
}
}
if articleProps.item.isPDF, needsPDFDownload {
_ = try await loadPDFData(slug: articleProps.item.slug, pageURLString: articleProps.item.pageURLString)
}
try await backgroundContext.perform { [weak self] in
do {
try self?.backgroundContext.save()
logger.debug("ArticleContent saved succesfully")
} catch {
self?.backgroundContext.rollback()
logger.debug("Failed to save ArticleContent")
throw error
}
}
return objectID
}
/// Queries CoreData for a LinkedItem using a requestID.
/// - Parameter requestID: A requestID used to check on a newly created item.
/// - Returns: The id of the CoreData object if found.
func linkedItemID(from requestID: String) async -> String? {
await backgroundContext.perform(schedule: .immediate) {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID)
return try? self.backgroundContext.fetch(fetchRequest).first?.unwrappedID
}
}
func syncUnsyncedArticleContent(itemID: String) async {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)
let context = backgroundContext
var id: String?
var url: String?
var title: String?
var originalHtml: String?
var serverSyncStatus: Int64?
backgroundContext.performAndWait {
guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return }
id = linkedItem.unwrappedID
url = linkedItem.unwrappedPageURLString
title = linkedItem.unwrappedTitle
originalHtml = linkedItem.originalHtml
serverSyncStatus = linkedItem.serverSyncStatus
}
guard let id = id, let url = url, let title = title,
let serverSyncStatus = serverSyncStatus,
serverSyncStatus == ServerSyncStatus.needsCreation.rawValue
else {
return
}
do {
if let originalHtml = originalHtml {
_ = try await savePage(id: id, url: url, title: title, originalHtml: originalHtml)
} else {
_ = try await saveURL(id: id, url: url)
}
} catch {
// We don't propogate these errors, we just let it pass through so
// the user can attempt to fetch content again.
print("Error syncUnsyncedArticleContent")
}
}
}

View file

@ -1,13 +1,17 @@
import Combine
import CoreData
import CoreImage
import Foundation
import Models
import OSLog
import QuickLookThumbnailing
import UIKit
import Utils
#if os(iOS)
import UIKit
#else
import AppKit
#endif
let logger = Logger(subsystem: "app.omnivore", category: "data-service")
public final class DataService: ObservableObject {
@ -19,7 +23,6 @@ public final class DataService: ObservableObject {
var persistentContainer: PersistentContainer
public var backgroundContext: NSManagedObjectContext
var subscriptions = Set<AnyCancellable>()
public var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
@ -49,6 +52,19 @@ public final class DataService: ObservableObject {
return try? persistentContainer.viewContext.fetch(fetchRequest).first
}
public func username() async -> String? {
if let cachedUsername = currentViewer?.username {
return cachedUsername
}
if let viewerObjectID = try? await fetchViewer() {
let viewer = backgroundContext.object(with: viewerObjectID) as? Viewer
return viewer?.unwrappedUsername
}
return nil
}
public func switchAppEnvironment(appEnvironment: AppEnvironment) {
do {
try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue)
@ -109,6 +125,7 @@ public final class DataService: ObservableObject {
return isFirstRunOfVersion || isFirstRunWithBuildNumber
}
// swiftlint:disable:next function_body_length
public func persistPageScrapePayload(_ pageScrape: PageScrapePayload, requestId: String) async throws {
let normalizedURL = normalizeURL(pageScrape.url)

View file

@ -25,7 +25,7 @@ extension DataService {
// Fetch the items
for itemID in missingItemIds { // TOOD: run these in parallel
logger.debug("fetching item with ID: \(itemID)")
_ = try await articleContent(username: username, itemID: itemID, useCache: false)
_ = try await loadArticleContent(username: username, itemID: itemID, useCache: false)
fetchedItemCount += 1
logger.debug("done fetching item with ID: \(itemID)")
}

View file

@ -11333,6 +11333,7 @@ extension Objects {
let readAt: [String: DateTime]
let readingProgressAnchorIndex: [String: Int]
let readingProgressPercent: [String: Double]
let savedAt: [String: DateTime]
let shortId: [String: String]
let siteName: [String: String]
let slug: [String: String]
@ -11439,6 +11440,10 @@ extension Objects.SearchItem: Decodable {
if let value = try container.decode(Double?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "savedAt":
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "shortId":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -11512,6 +11517,7 @@ extension Objects.SearchItem: Decodable {
readAt = map["readAt"]
readingProgressAnchorIndex = map["readingProgressAnchorIndex"]
readingProgressPercent = map["readingProgressPercent"]
savedAt = map["savedAt"]
shortId = map["shortId"]
siteName = map["siteName"]
slug = map["slug"]
@ -11798,7 +11804,7 @@ extension Fields where TypeLock == Objects.SearchItem {
}
}
func readingProgressAnchorIndex() throws -> Int? {
func readingProgressAnchorIndex() throws -> Int {
let field = GraphQLField.leaf(
name: "readingProgressAnchorIndex",
arguments: []
@ -11807,13 +11813,16 @@ extension Fields where TypeLock == Objects.SearchItem {
switch response {
case let .decoding(data):
return data.readingProgressAnchorIndex[field.alias!]
if let data = data.readingProgressAnchorIndex[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return nil
return Int.mockValue
}
}
func readingProgressPercent() throws -> Double? {
func readingProgressPercent() throws -> Double {
let field = GraphQLField.leaf(
name: "readingProgressPercent",
arguments: []
@ -11822,9 +11831,30 @@ extension Fields where TypeLock == Objects.SearchItem {
switch response {
case let .decoding(data):
return data.readingProgressPercent[field.alias!]
if let data = data.readingProgressPercent[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return nil
return Double.mockValue
}
}
func savedAt() throws -> DateTime {
let field = GraphQLField.leaf(
name: "savedAt",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.savedAt[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return DateTime.mockValue
}
}
@ -11954,7 +11984,7 @@ extension Fields where TypeLock == Objects.SearchItem {
}
}
func updatedAt() throws -> DateTime {
func updatedAt() throws -> DateTime? {
let field = GraphQLField.leaf(
name: "updatedAt",
arguments: []
@ -11963,12 +11993,9 @@ extension Fields where TypeLock == Objects.SearchItem {
switch response {
case let .decoding(data):
if let data = data.updatedAt[field.alias!] {
return data
}
throw HttpError.badpayload
return data.updatedAt[field.alias!]
case .mocking:
return DateTime.mockValue
return nil
}
}

View file

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

View file

@ -13,7 +13,9 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.UnsubscribeResult> {
try $0.on(
unsubscribeError: .init { .error(errorMessage: (try $0.errorCodes().first ?? .unauthorized).rawValue) },
unsubscribeSuccess: .init { .success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() })) }
unsubscribeSuccess: .init {
.success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() }))
}
)
}

View file

@ -11,7 +11,7 @@ extension DataService {
quote: String,
patch: String,
articleId: String,
overlapHighlightIdList: [String] // TODO: pass in annotation?
overlapHighlightIdList: [String]
) -> [String: Any]? {
let internalHighlight = InternalHighlight(
id: highlightID,
@ -38,6 +38,7 @@ extension DataService {
return internalHighlight.encoded()
}
// swiftlint:disable:next function_body_length
func syncHighlightMerge(highlight: InternalHighlight, articleId: String, overlapHighlightIdList: [String]) {
enum MutationResult {
case saved(highlight: InternalHighlight)

View file

@ -1,221 +0,0 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public enum SaveArticleStatus {
case succeeeded
case processing(jobId: String)
case failed
static func make(jobId: String, savingStatus: Enums.ArticleSavingRequestStatus) -> SaveArticleStatus {
switch savingStatus {
case .processing:
return .processing(jobId: jobId)
case .succeeded:
return .succeeeded
case .failed:
return .failed
}
}
}
public extension Networker {
func articleSaveStatus(jobId: String) -> AnyPublisher<SaveArticleStatus, SaveArticleError> {
enum QueryResult {
case saved(status: SaveArticleStatus)
case error(errorCode: Enums.ArticleSavingRequestErrorCode)
}
let selection = Selection<QueryResult, Unions.ArticleSavingRequestResult> {
try $0.on(
articleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .notFound) },
articleSavingRequestSuccess: .init {
.saved(
status: try $0.articleSavingRequest(
selection: .init {
SaveArticleStatus.make(
jobId: try $0.id(),
savingStatus: try $0.status()
)
}
)
)
}
)
}
let query = Selection.Query {
try $0.articleSavingRequest(id: jobId, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = defaultHeaders
return Deferred {
Future { promise in
send(query, 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 .saved(status):
promise(.success(status))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
case .notFound:
promise(.failure(.badData))
}
}
case let .failure(error):
promise(.failure(SaveArticleError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
public extension DataService {
// swiftlint:disable:next line_length
func saveArticlePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String?) -> AnyPublisher<Void, SaveArticleError> {
enum MutationResult {
case saved(created: Bool)
case error(errorCode: Enums.CreateArticleErrorCode)
}
let preparedDocument: InputObjects.PreparedDocumentInput? = {
if case let .html(html, title, _) = pageScrapePayload.contentType {
return InputObjects.PreparedDocumentInput(
document: html,
pageInfo: InputObjects.PageInfoInput(title: OptionalArgument(title))
)
}
return nil
}()
let input = InputObjects.CreateArticleInput(
preparedDocument: OptionalArgument(preparedDocument),
uploadFileId: uploadFileId != nil ? .present(uploadFileId!) : .null(),
url: pageScrapePayload.url
)
let selection = Selection<MutationResult, Unions.CreateArticleResult> {
try $0.on(
createArticleError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unableToParse) },
createArticleSuccess: .init { .saved(created: try $0.created()) }
)
}
let mutation = Selection.Mutation {
try $0.createArticle(input: input, selection: selection)
}
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(SaveArticleError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func saveArticlePublisher(articleURL: URL) -> AnyPublisher<SaveArticleStatus, SaveArticleError> {
saveArticlePublisher(articleURLString: articleURL.absoluteString)
}
func saveArticlePublisher(articleURLString: String) -> AnyPublisher<SaveArticleStatus, SaveArticleError> {
enum MutationResult {
case saved(status: SaveArticleStatus)
case error(errorCode: Enums.CreateArticleSavingRequestErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateArticleSavingRequestResult> {
try $0.on(
createArticleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .badData) },
createArticleSavingRequestSuccess: .init {
.saved(
status: try $0.articleSavingRequest(
selection: .init {
SaveArticleStatus.make(
jobId: try $0.id(),
savingStatus: try $0.status()
)
}
)
)
}
)
}
let mutation = Selection.Mutation {
try $0.createArticleSavingRequest(
input: InputObjects.CreateArticleSavingRequestInput(url: articleURLString),
selection: selection
)
}
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 .saved(status):
promise(.success(status))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
case .badData:
promise(.failure(.badData))
}
}
case let .failure(error):
promise(.failure(SaveArticleError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -1,4 +1,3 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
@ -11,6 +10,7 @@ public struct UploadFileRequestPayload {
}
public extension DataService {
// swiftlint:disable:next function_body_length
func uploadFileRequest(id: String, url: String) async throws -> UploadFileRequestPayload {
enum MutationResult {
case success(payload: UploadFileRequestPayload)
@ -60,11 +60,7 @@ public extension DataService {
switch payload.data {
case let .success(payload):
if let urlString = payload.urlString, let url = URL(string: urlString) {
continuation.resume(returning: payload)
} else {
continuation.resume(throwing: SaveArticleError.unknown(description: "No upload URL"))
}
continuation.resume(returning: payload)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
@ -85,12 +81,6 @@ public extension DataService {
request.httpMethod = "PUT"
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
print("UPLOADING PDF", localPdfURL)
let attr = try? FileManager.default.attributesOfItem(atPath: localPdfURL.path)
if let attr = attr {
print("UPLOADING ATTR", attr[.size])
}
return try await withCheckedThrowingContinuation { continuation in
let task = networker.urlSession.uploadTask(with: request, fromFile: localPdfURL) { _, response, _ in
if let httpResponse = response as? HTTPURLResponse, 200 ... 299 ~= httpResponse.statusCode {

View file

@ -39,7 +39,7 @@ extension Networker {
)
do {
let authVerification = try await urlSession.performReq(resource: resource)
let authVerification = try await urlSession.performRequest(resource: resource)
return authVerification.authStatus.isAuthenticated
} catch {
return false

View file

@ -1,4 +1,3 @@
import Combine
import Foundation
import Models
import Utils
@ -37,7 +36,7 @@ extension ServerResponse {
struct EmptyResponse: Decodable {}
extension URLSession {
func performReq<ResponseModel>(
func performRequest<ResponseModel>(
resource: ServerResource<ResponseModel>
) async throws -> ResponseModel {
do {
@ -61,30 +60,6 @@ extension URLSession {
throw ServerError(serverResponse: serverResponse)
}
}
// TODO: remove performRequest
// swiftlint:disable:next line_length
func performRequest<ResponseModel>(resource: ServerResource<ResponseModel>) -> AnyPublisher<ResponseModel, ServerError> {
let request = resource.urlRequest
return dataTaskPublisher(for: resource.urlRequest)
.tryMap { data, response -> ResponseModel in
let serverResponse = ServerResponse(data: data, response: response)
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
if let decodedValue = resource.decode(serverResponse) {
return decodedValue
}
throw ServerError(serverResponse: serverResponse)
}
.mapError { error -> ServerError in
let serverResponse = ServerResponse(error: error)
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
return ServerError(serverResponse: serverResponse)
}
.eraseToAnyPublisher()
}
}
extension URLRequest {

View file

@ -1,8 +1,7 @@
import Combine
import Foundation
extension Networker {
func createAccount(params: Data) -> AnyPublisher<AuthPayload, ServerError> {
func createAccount(params: Data) async throws -> AuthPayload {
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/create-account",
@ -14,9 +13,10 @@ extension Networker {
decode: AuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
do {
return try await urlSession.performRequest(resource: resource)
} catch {
throw (error as? ServerError) ?? .unknown
}
}
}

View file

@ -1,9 +1,8 @@
import Combine
import Foundation
import Models
extension Networker {
func createPendingUser(params: Data) -> AnyPublisher<PendingUserAuthPayload, ServerError> {
func createPendingUser(params: Data) async throws -> PendingUserAuthPayload {
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/sign-up",
@ -15,9 +14,10 @@ extension Networker {
decode: PendingUserAuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
do {
return try await urlSession.performRequest(resource: resource)
} catch {
throw (error as? ServerError) ?? .unknown
}
}
}

View file

@ -1,26 +0,0 @@
import Combine
import Foundation
import Models
extension Networker {
func submitAppleToken(token: String) -> AnyPublisher<AuthPayload, ServerError> {
let params = SignInParams(token: token, provider: .apple)
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/sign-in",
requestMethod: .post(params: encodedParams)
)
let resource = ServerResource<AuthPayload>(
urlRequest: urlRequest,
decode: AuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -2,8 +2,17 @@ import Foundation
import Models
extension Networker {
func submitAppleToken(token: String) async throws -> AuthPayload {
let params = SignInParams(token: token, provider: .apple)
return try await submitSignInParams(params: params)
}
func submitGoogleToken(idToken: String) async throws -> AuthPayload {
let params = SignInParams(token: idToken, provider: .google)
return try await submitSignInParams(params: params)
}
func submitSignInParams(params: SignInParams) async throws -> AuthPayload {
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
let urlRequest = URLRequest.create(
@ -18,7 +27,7 @@ extension Networker {
)
do {
return try await urlSession.performReq(resource: resource)
return try await urlSession.performRequest(resource: resource)
} catch {
if let error = error as? ServerError {
throw LoginError.make(serverError: error)

View file

@ -5,7 +5,6 @@ import Utils
public extension DataService {
internal func syncOfflineItemsWithServerIfNeeded() async throws {
// TODO: send a simple request to see if we're online?
var unsyncedLinkedItems = [LinkedItem]()
var unsyncedHighlights = [Highlight]()
@ -118,9 +117,6 @@ public extension DataService {
Task {
try await createPageFromPdf(id: id, localPdfURL: localPdfURL, url: url)
}
} else {
// TODO: This is an invalid object, we should have a way of reflecting that with an error state
// updateLinkedItemStatus(id: id, status: .)
}
case "WEB":
let id = item.unwrappedID
@ -130,9 +126,9 @@ public extension DataService {
Task {
if let originalHtml = originalHtml {
try await createPage(id: id, originalHtml: originalHtml, title: title, url: url)
_ = try await createPage(id: id, originalHtml: originalHtml, title: title, url: url)
} else {
try await createPageFromUrl(id: id, url: url)
_ = try await createPageFromUrl(id: id, url: url)
}
}
default:

View file

@ -0,0 +1,50 @@
import CoreData
import Foundation
import Models
import Utils
public extension DataService {
func prefetchPages(itemIDs: [String], username: String) async {
// TODO: make this concurrent
for itemID in itemIDs {
await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
}
}
func loadArticleContentWithRetries(
itemID: String,
username: String? = nil,
requestCount: Int = 1
) async throws -> ArticleContent {
guard requestCount < 7 else {
throw ContentFetchError.badData
}
guard let username = username ?? currentViewer?.username else {
throw ContentFetchError.unauthorized
}
let fetchedContent = try await loadArticleContent(username: username, itemID: itemID, useCache: true)
switch fetchedContent.contentStatus {
case .failed:
throw ContentFetchError.badData
case .processing:
let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000)
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
logger.debug("fetching content for \(itemID). request count: \(requestCount)")
// Check for an updated itemID
// May have changed in the loadArticleContent call
let updatedItemID = await linkedItemID(from: itemID)
return try await loadArticleContentWithRetries(
itemID: updatedItemID ?? itemID,
username: username,
requestCount: requestCount + 1
)
case .succeeded, .unknown:
return fetchedContent
}
}
}

View file

@ -0,0 +1,54 @@
import CoreData
import Foundation
import Models
public extension DataService {
/// Requests `LinkedItem`s from the server and stores it in CoreData.
/// - Parameters:
/// - limit: max count of items
/// - searchQuery: search terms and filters
/// - cursor: cursor when loading batch for infinite list
/// - Returns: `LinkedItemQueryResult` (managed object IDs and an optional cursor)
func loadLinkedItems(
limit: Int,
searchQuery: String?,
cursor: String?
) async throws -> LinkedItemQueryResult {
// Send offline changes to server before fetching items
try? await syncOfflineItemsWithServerIfNeeded()
let fetchResult = try await fetchLinkedItems(limit: limit, searchQuery: searchQuery, cursor: cursor)
guard let itemIDs = fetchResult.items.persist(context: backgroundContext) else {
throw BasicError.message(messageText: "CoreData error")
}
return LinkedItemQueryResult(itemIDs: itemIDs, cursor: fetchResult.cursor)
}
/// Requests a single `LinkedItem` from the server and stores it in CoreData
/// - Parameters:
/// - username: the Viewer's username
/// - itemID: id of item being requested
/// - Returns: The `NSManagedObjectID` of the `LinkedItem`
func loadLinkedItem(username: String, itemID: String) async throws -> NSManagedObjectID {
let item = try await fetchLinkedItem(username: username, itemID: itemID)
guard let persistedItemID = [item].persist(context: backgroundContext)?.first else {
throw BasicError.message(messageText: "CoreData error")
}
return persistedItemID
}
func loadItemContentUsingRequestID(requestID: String) async throws -> NSManagedObjectID? {
let username: String? = await username()
guard let username = username else { throw BasicError.message(messageText: "unauthorized user") }
// If the page was locally created, make sure they are synced before we pull content
await syncUnsyncedArticleContent(itemID: requestID)
let articleContent = try await loadArticleContentWithRetries(itemID: requestID, username: username, requestCount: 0)
return articleContent.objectID
}
}

View file

@ -0,0 +1,54 @@
import CoreData
import Foundation
import Models
import Utils
public extension DataService {
func loadPDFData(slug: String, pageURLString: String) async throws -> URL? {
guard let url = URL(string: pageURLString) else {
throw BasicError.message(messageText: "No PDF URL found")
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "pdfFetch failed. no data received.")
}
var localPdfURL: URL?
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(UUID().uuidString + ".pdf")
try await backgroundContext.perform { [weak self] in
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
guard let linkedItem = linkedItem else {
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
throw BasicError.message(messageText: errorMessage)
}
do {
try data.write(to: tempPath)
let localPDF = try PDFUtils.moveToLocal(url: tempPath)
localPdfURL = PDFUtils.localPdfURL(filename: localPDF)
linkedItem.tempPDFURL = nil
linkedItem.localPDF = localPDF
try self?.backgroundContext.save()
} catch {
self?.backgroundContext.rollback()
let errorMessage = "pdfFetch failed. core data save failed."
throw BasicError.message(messageText: errorMessage)
}
}
return localPdfURL
}
}

View file

@ -4,91 +4,15 @@ import Models
import SwiftGraphQL
import Utils
public extension DataService {
internal struct PendingLink {
let itemID: String
let retryCount: Int
}
func prefetchPages(itemIDs: [String], username: String) async {
// TODO: make this concurrent
// TODO: make a non-pending page option for BG tasks
for itemID in itemIDs {
await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
}
}
internal func prefetchPage(pendingLink: PendingLink, username: String) async {
let content = try? await articleContent(username: username, itemID: pendingLink.itemID, useCache: false)
if content?.contentStatus == .processing, pendingLink.retryCount < 7 {
let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000)
do {
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
logger.debug("fetching content for \(pendingLink.itemID). retry count: \(pendingLink.retryCount)")
await prefetchPage(
pendingLink: PendingLink(
itemID: pendingLink.itemID,
retryCount: pendingLink.retryCount + 1
),
username: username
)
} catch {
logger.debug("prefetching task was cancelled")
}
}
}
func fetchArticleContent(
itemID: String,
username: String? = nil,
requestCount: Int = 1
) async throws -> ArticleContent {
guard requestCount < 7 else {
throw ContentFetchError.badData
}
guard let username = username ?? currentViewer?.username else {
throw ContentFetchError.unauthorized
}
let fetchedContent = try await articleContent(username: username, itemID: itemID, useCache: true)
switch fetchedContent.contentStatus {
case .failed:
throw ContentFetchError.badData
case .processing:
let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000)
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
logger.debug("fetching content for \(itemID). request count: \(requestCount)")
return try await fetchArticleContent(itemID: itemID, username: username, requestCount: requestCount + 1)
case .succeeded, .unknown:
return fetchedContent
}
}
struct ArticleProps {
let item: InternalLinkedItem
let htmlContent: String
let highlights: [InternalHighlight]
}
extension DataService {
// swiftlint:disable:next function_body_length
func articleContent(
username: String,
itemID: String,
useCache: Bool
) async throws -> ArticleContent {
struct ArticleProps {
let item: InternalLinkedItem
let htmlContent: String
let highlights: [InternalHighlight]
let contentStatus: Enums.ArticleSavingRequestStatus?
}
if useCache, let cachedContent = await cachedArticleContent(itemID: itemID) {
return cachedContent
}
// If the page was locally created, make sure they are synced before we pull content
await syncUnsyncedArticleContent(itemID: itemID)
func articleContentFetch(username: String, itemID: String) async throws -> ArticleProps {
enum QueryResult {
case success(result: ArticleProps)
case error(error: String)
@ -103,7 +27,7 @@ public extension DataService {
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
@ -122,8 +46,7 @@ public extension DataService {
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
),
htmlContent: try $0.content(),
highlights: try $0.highlights(selection: highlightSelection.list),
contentStatus: try $0.state()
highlights: try $0.highlights(selection: highlightSelection.list)
)
}
@ -145,7 +68,7 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let result: ArticleProps = try await withCheckedThrowingContinuation { continuation in
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: ContentFetchError.network)
@ -154,244 +77,11 @@ public extension DataService {
switch payload.data {
case let .success(result: result):
// Default to suceeded since older links will return a nil status
// (but the content is almost always there)
let status = result.contentStatus ?? .succeeded
if status == .failed {
continuation.resume(throwing: ContentFetchError.badData)
return
}
continuation.resume(returning: result)
case .error:
continuation.resume(throwing: ContentFetchError.badData)
}
}
}
let articleContent = ArticleContent(
title: result.item.title,
htmlContent: result.htmlContent,
highlightsJSONString: result.highlights.asJSONString,
contentStatus: result.item.isPDF ? .succeeded : .make(from: result.contentStatus)
)
if result.contentStatus == .succeeded || result.item.isPDF {
do {
try await persistArticleContent(
item: result.item,
htmlContent: result.htmlContent,
highlights: result.highlights
)
} catch {
var message = "unknown error"
let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error")
if case let BasicError.message(messageText) = basicError {
message = messageText
}
throw ContentFetchError.unknown(description: message)
}
}
return articleContent
}
internal func persistArticleContent(item: InternalLinkedItem, htmlContent: String, highlights: [InternalHighlight]) async throws {
var needsPDFDownload = false
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", item.id)
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
let highlightObjects = highlights.map {
$0.asManagedObject(context: self.backgroundContext)
}
linkedItem.addToHighlights(NSSet(array: highlightObjects))
linkedItem.htmlContent = htmlContent
linkedItem.id = item.id
linkedItem.state = item.state
linkedItem.title = item.title
linkedItem.createdAt = item.createdAt
linkedItem.savedAt = item.savedAt
linkedItem.readingProgress = item.readingProgress
linkedItem.readingProgressAnchor = Int64(item.readingProgressAnchor)
linkedItem.imageURLString = item.imageURLString
linkedItem.onDeviceImageURLString = item.onDeviceImageURLString
linkedItem.pageURLString = item.pageURLString
linkedItem.descriptionText = item.descriptionText
linkedItem.publisherURLString = item.publisherURLString
linkedItem.author = item.author
linkedItem.publishDate = item.publishDate
linkedItem.slug = item.slug
linkedItem.readAt = item.readAt
linkedItem.isArchived = item.isArchived
linkedItem.contentReader = item.contentReader
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
if item.isPDF {
needsPDFDownload = true
// Check if we already have the PDF item locally. Either in temporary
// space, or in the documents directory
if let localPDF = existingItem?.localPDF {
if PDFUtils.exists(filename: localPDF) {
linkedItem.localPDF = localPDF
needsPDFDownload = false
}
}
if let tempPDFURL = existingItem?.tempPDFURL {
linkedItem.localPDF = try? PDFUtils.moveToLocal(url: tempPDFURL)
PDFUtils.exists(filename: linkedItem.localPDF)
if linkedItem.localPDF != nil {
needsPDFDownload = false
}
}
}
}
if item.isPDF, needsPDFDownload {
try await fetchPDFData(slug: item.slug, pageURLString: item.pageURLString)
}
try await backgroundContext.perform { [weak self] in
do {
try self?.backgroundContext.save()
logger.debug("ArticleContent saved succesfully")
} catch {
self?.backgroundContext.rollback()
logger.debug("Failed to save ArticleContent")
throw error
}
}
}
func fetchPDFData(slug: String, pageURLString: String) async throws -> URL? {
guard let url = URL(string: pageURLString) else {
throw BasicError.message(messageText: "No PDF URL found")
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "pdfFetch failed. no data received.")
}
var localPdfURL: URL?
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(UUID().uuidString + ".pdf")
try await backgroundContext.perform { [weak self] in
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
guard let linkedItem = linkedItem else {
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
throw BasicError.message(messageText: errorMessage)
}
do {
try data.write(to: tempPath)
let localPDF = try PDFUtils.moveToLocal(url: tempPath)
localPdfURL = PDFUtils.localPdfURL(filename: localPDF)
linkedItem.tempPDFURL = nil
linkedItem.localPDF = localPDF
try self?.backgroundContext.save()
} catch {
self?.backgroundContext.rollback()
let errorMessage = "pdfFetch failed. core data save failed."
throw BasicError.message(messageText: errorMessage)
}
}
return localPdfURL
}
internal func cachedArticleContent(itemID: String) async -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)
let context = backgroundContext
return await context.perform(schedule: .immediate) {
guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return nil }
guard let htmlContent = linkedItem.htmlContent else { return nil }
let highlights = linkedItem
.highlights
.asArray(of: Highlight.self)
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
return ArticleContent(
title: linkedItem.unwrappedTitle,
htmlContent: htmlContent,
highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString,
contentStatus: .succeeded
)
}
}
func syncUnsyncedArticleContent(itemID: String) async {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)
let context = backgroundContext
var id: String?
var url: String?
var title: String?
var originalHtml: String?
var serverSyncStatus: Int64?
backgroundContext.performAndWait {
guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return }
id = linkedItem.unwrappedID
url = linkedItem.unwrappedPageURLString
title = linkedItem.unwrappedTitle
originalHtml = linkedItem.originalHtml
serverSyncStatus = linkedItem.serverSyncStatus
}
if let id = id, let url = url, let title = title,
let serverSyncStatus = serverSyncStatus,
serverSyncStatus == ServerSyncStatus.needsCreation.rawValue
{
do {
if let originalHtml = originalHtml {
try await savePage(id: id, url: url, title: title, originalHtml: originalHtml)
} else {
try await saveURL(id: id, url: url)
}
} catch {
// We don't propogate these errors, we just let it pass through so
// the user can attempt to fetch content again.
print("Error syncUnsyncedArticleContent")
}
}
}
}
private extension ArticleContentStatus {
static func make(from savingRequestStatus: Enums.ArticleSavingRequestStatus?) -> ArticleContentStatus {
guard let savingRequestStatus = savingRequestStatus else { return .unknown }
switch savingRequestStatus {
case .failed:
return .failed
case .processing:
return .processing
case .succeeded:
return .succeeded
}
}
}

View file

@ -1,192 +0,0 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func fetchLinkedItems(
limit: Int,
searchQuery: String?,
cursor: String?
) async throws -> HomeFeedData {
// Send offline changes to server before fetching items
try? await syncOfflineItemsWithServerIfNeeded()
struct InternalHomeFeedData {
let items: [InternalLinkedItem]
let cursor: String?
}
enum QueryResult {
case success(result: InternalHomeFeedData)
case error(error: String)
}
let selection = Selection<QueryResult, Unions.ArticlesResult> {
try $0.on(
articlesError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
articlesSuccess: .init {
QueryResult.success(
result: InternalHomeFeedData(
items: try $0.edges(selection: articleEdgeSelection.list),
cursor: try $0.pageInfo(selection: Selection.PageInfo {
try $0.endCursor()
})
)
)
}
)
}
let query = Selection.Query {
try $0.articles(
after: OptionalArgument(cursor),
first: OptionalArgument(limit),
includePending: OptionalArgument(true),
query: OptionalArgument(searchQuery),
sharedOnly: .present(false),
sort: OptionalArgument(
InputObjects.SortParams(
by: .updatedTime,
order: .present(.descending)
)
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { [weak self] queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .success(result: result):
if let context = self?.backgroundContext, let items = result.items.persist(context: context) {
continuation.resume(returning: HomeFeedData(items: items.map(\.objectID), cursor: result.cursor))
} else {
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
}
case .error:
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
}
}
}
}
func fetchLinkedItem(username: String, itemID: String) async throws -> NSManagedObjectID {
struct ArticleProps {
let item: InternalLinkedItem
}
enum QueryResult {
case success(result: InternalLinkedItem)
case error(error: String)
}
let articleSelection = Selection.Article {
InternalLinkedItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
onDeviceImageURLString: nil,
documentDirectoryPath: nil,
pageURLString: try $0.url(),
descriptionText: try $0.description(),
publisherURLString: try $0.originalArticleUrl(),
siteName: try $0.siteName(),
author: try $0.author(),
publishDate: try $0.publishedAt()?.value,
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
let selection = Selection<QueryResult, Unions.ArticleResult> {
try $0.on(
articleError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
articleSuccess: .init {
QueryResult.success(result: try $0.article(selection: articleSelection))
}
)
}
let query = Selection.Query {
// backend has a hack that allows us to pass in itemID in place of slug
try $0.article(slug: itemID, username: username, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { [weak self] queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: ContentFetchError.network)
return
}
switch payload.data {
case let .success(result: result):
if let context = self?.backgroundContext, let item = [result].persist(context: context)?.first {
continuation.resume(returning: item.objectID)
} else {
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
}
case .error:
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
}
}
}
}
}
private let libraryArticleSelection = Selection.Article {
InternalLinkedItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
onDeviceImageURLString: nil,
documentDirectoryPath: nil,
pageURLString: try $0.url(),
descriptionText: try $0.description(),
publisherURLString: try $0.originalArticleUrl(),
siteName: try $0.siteName(),
author: try $0.author(),
publishDate: try $0.publishedAt()?.value,
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
private let articleEdgeSelection = Selection.ArticleEdge {
try $0.node(selection: libraryArticleSelection)
}

View file

@ -0,0 +1,185 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
struct InternalLinkedItemQueryResult {
let items: [InternalLinkedItem]
let cursor: String?
}
extension DataService {
/// Performs GraphQL request to fetch `InternalLinkedItem`s and a cursor value
/// - Parameters:
/// - limit: max number of items to return
/// - searchQuery: search query used by server to narrow search
/// - cursor: cursor to indicate batch cutoff
/// - Returns: `InternalLinkedItemQueryResult` or a `ContentFetchError` if request fails.
func fetchLinkedItems(
limit: Int,
searchQuery: String?,
cursor: String?
) async throws -> InternalLinkedItemQueryResult {
enum QueryResult {
case success(result: InternalLinkedItemQueryResult)
case error(error: String)
}
let selection = Selection<QueryResult, Unions.SearchResult> {
try $0.on(
searchError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
searchSuccess: .init {
QueryResult.success(
result: InternalLinkedItemQueryResult(
items: try $0.edges(selection: searchItemEdgeSelection.list),
cursor: try $0.pageInfo(selection: Selection.PageInfo {
try $0.endCursor()
})
)
)
}
)
}
let query = Selection.Query {
try $0.search(
after: OptionalArgument(cursor),
first: OptionalArgument(limit),
query: OptionalArgument(searchQuery),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: ContentFetchError.network)
return
}
switch payload.data {
case let .success(result: result):
continuation.resume(returning: result)
case let .error(error):
continuation.resume(throwing: ContentFetchError.unknown(description: error.description))
}
}
}
}
/// Performs GraphQL request to fetch a single `InternalLinkedItem`
/// - Parameters:
/// - username: the Viewer's username
/// - itemID: id of the item being requested
/// - Returns: Returns an `InternalLinkedItem` or throws a `ContentFetchError` if
/// request could not be completed
func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLinkedItem {
struct ArticleProps {
let item: InternalLinkedItem
}
enum QueryResult {
case success(result: InternalLinkedItem)
case error(error: String)
}
let selection = Selection<QueryResult, Unions.ArticleResult> {
try $0.on(
articleError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
articleSuccess: .init {
QueryResult.success(result: try $0.article(selection: libraryArticleSelection))
}
)
}
let query = Selection.Query {
// backend has a hack that allows us to pass in itemID in place of slug
try $0.article(slug: itemID, username: username, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: ContentFetchError.network)
return
}
switch payload.data {
case let .success(result: result):
continuation.resume(returning: result)
case let .error(error):
continuation.resume(throwing: ContentFetchError.unknown(description: error.description))
}
}
}
}
}
private let libraryArticleSelection = Selection.Article {
InternalLinkedItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
onDeviceImageURLString: nil,
documentDirectoryPath: nil,
pageURLString: try $0.url(),
descriptionText: try $0.description(),
publisherURLString: try $0.originalArticleUrl(),
siteName: try $0.siteName(),
author: try $0.author(),
publishDate: try $0.publishedAt()?.value,
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
private let searchItemSelection = Selection.SearchItem {
InternalLinkedItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt()?.value ?? Date(),
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
onDeviceImageURLString: nil,
documentDirectoryPath: nil,
pageURLString: try $0.url(),
descriptionText: try $0.description(),
publisherURLString: try $0.originalArticleUrl(),
siteName: try $0.siteName(),
author: try $0.author(),
publishDate: try $0.publishedAt()?.value,
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
private let searchItemEdgeSelection = Selection.SearchItemEdge {
try $0.node(selection: searchItemSelection)
}

View file

@ -1,30 +1,29 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func validateUsernamePublisher(username: String) -> AnyPublisher<Void, UsernameAvailabilityError> {
func validateUsernamePublisher(username: String) async throws {
let query = Selection.Query {
try $0.validateUsername(username: username)
}
let path = appEnvironment.graphqlPath
return Deferred {
Future { promise in
send(query, to: path) { result in
switch result {
case let .success(payload):
promise(payload.data ? .success(()) : .failure(.nameUnavailable))
case let .failure(error):
promise(.failure(UsernameAvailabilityError.make(from: error)))
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path) { result in
switch result {
case let .success(payload):
if payload.data {
continuation.resume()
} else {
continuation.resume(throwing: UsernameAvailabilityError.nameUnavailable)
}
case let .failure(error):
continuation.resume(throwing: UsernameAvailabilityError.make(from: error))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -9,7 +9,7 @@ struct InternalLinkedItem {
let savedAt: Date
let readAt: Date?
let updatedAt: Date
let state: String
let state: ArticleContentStatus
var readingProgress: Double
var readingProgressAnchor: Int
let imageURLString: String?
@ -31,7 +31,7 @@ struct InternalLinkedItem {
if let contentReader = contentReader {
return contentReader == "PDF"
}
return (pageURLString ?? "").hasSuffix("pdf")
return pageURLString.hasSuffix("pdf")
}
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItem {
@ -44,7 +44,7 @@ struct InternalLinkedItem {
linkedItem.savedAt = savedAt
linkedItem.updatedAt = updatedAt
linkedItem.readAt = readAt
linkedItem.state = state
linkedItem.state = state.rawValue
linkedItem.readingProgress = readingProgress
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
linkedItem.imageURLString = imageURLString
@ -70,7 +70,7 @@ struct InternalLinkedItem {
}
extension Sequence where Element == InternalLinkedItem {
func persist(context: NSManagedObjectContext) -> [LinkedItem]? {
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
var linkedItems: [LinkedItem]?
context.performAndWait {
linkedItems = map { $0.asManagedObject(inContext: context) }
@ -83,7 +83,12 @@ extension Sequence where Element == InternalLinkedItem {
print("Failed to save LinkedItems: \(error.localizedDescription)")
}
}
return linkedItems
if let linkedItems = linkedItems {
return linkedItems.map(\.objectID)
} else {
return nil
}
}
}
@ -104,7 +109,7 @@ extension JSONArticle {
savedAt: savedAt,
readAt: readAt,
updatedAt: updatedAt,
state: "SUCCEEDED",
state: .succeeded,
readingProgress: readingProgressPercent,
readingProgressAnchor: readingProgressAnchorIndex,
imageURLString: image,

View file

@ -1,14 +1,11 @@
//
// PDFUtils.swift
//
//
// Created by Jackson Harper on 6/3/22.
//
import CoreImage
import Foundation
import QuickLookThumbnailing
import UIKit
#if os(iOS)
import UIKit
#else
import AppKit
#endif
public enum PDFUtils {
public static func copyToLocal(url: URL) throws -> String {
@ -47,13 +44,6 @@ public enum PDFUtils {
return false
}
public static func tempExists(tempPDFURL: URL?) -> Bool {
if let tempPDFURL = tempPDFURL {
return FileManager.default.fileExists(atPath: tempPDFURL.path)
}
return false
}
public static func titleFromPdfFile(_ urlStr: String) -> String {
let url = URL(string: urlStr)
if let url = url {
@ -78,7 +68,11 @@ public enum PDFUtils {
public static func createThumbnailFor(inputUrl: URL) async throws -> URL? {
let size = CGSize(width: 80, height: 80)
let scale = await UIScreen.main.scale
#if os(iOS)
let scale = await UIScreen.main.scale
#else
let scale = NSScreen.main?.backingScaleFactor ?? 1
#endif
let outputUrl = thumbnailUrl(localUrl: inputUrl)
// Create the thumbnail request.

View file

@ -168,14 +168,19 @@ public enum WebViewManager {
context.coordinator.needsReload = false
}
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
(webView as? WebView)?.dispatchEvent(.saveAnnotation(annotation: annotation))
}
if sendIncreaseFontSignal {
sendIncreaseFontSignal = false
(webView as? WebView)?.increaseFontSize()
(webView as? WebView)?.updateFontSize()
}
if sendDecreaseFontSignal {
sendDecreaseFontSignal = false
(webView as? WebView)?.decreaseFontSize()
(webView as? WebView)?.updateFontSize()
}
}
}

View file

@ -40,7 +40,9 @@ public final class WebView: WKWebView {
}
public func updateMaxWidthPercentage() {
if let maxWidthPercentage = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) as? Int {
if let maxWidthPercentage = UserDefaults.standard.value(
forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue
) as? Int {
dispatchEvent(.updateMaxWidthPercentage(maxWidthPercentage: maxWidthPercentage))
}
}
@ -84,9 +86,9 @@ public final class WebView: WKWebView {
super.viewDidChangeEffectiveAppearance()
switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) {
case .some(.darkAqua):
dispatchEvent("switchToDarkMode")
dispatchEvent(.updateColorMode(isDark: true))
default:
dispatchEvent("switchToLightMode")
dispatchEvent(.updateColorMode(isDark: false))
}
}
#endif

View file

@ -10,11 +10,14 @@ public enum WebFont: String, CaseIterable {
case roboto = "Roboto"
case crimsontext = "Crimson Text"
case sourceserifpro = "Source Serif Pro"
case openDyslexic = "OpenDyslexic"
var displayValue: String {
switch self {
case .inter, .merriweather, .lora, .opensans, .roboto, .crimsontext, .sourceserifpro:
return rawValue
case .openDyslexic:
return "Open Dyslexic"
case .system:
return "System Default"
}
@ -78,7 +81,9 @@ public struct WebPreferencesPopoverView: View {
}
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.inline)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationTitle("Reader Font")
}
@ -145,9 +150,11 @@ public struct WebPreferencesPopoverView: View {
}
.padding()
.navigationTitle("Reader Preferences")
.navigationBarTitleDisplayMode(.inline)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .barTrailing) {
Button(
action: dismissAction,
label: { Text("Done").foregroundColor(.appGrayTextContrast).padding() }
@ -155,7 +162,9 @@ public struct WebPreferencesPopoverView: View {
}
}
}
.navigationViewStyle(.stack)
#if os(iOS)
.navigationViewStyle(.stack)
#endif
.accentColor(.appGrayTextContrast)
}
}

View file

@ -1,63 +0,0 @@
import Combine
import SwiftUI
#if !os(macOS)
import UIKit
#endif
public extension Publishers {
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
#if os(iOS)
let willShow = NotificationCenter.default
.publisher(for: UIApplication.keyboardWillShowNotification)
.map(\.keyboardHeight)
.eraseToAnyPublisher()
let willHide = NotificationCenter.default
.publisher(for: UIApplication.keyboardWillHideNotification)
.map { _ in CGFloat(0) }
.eraseToAnyPublisher()
return Merge(willShow, willHide)
.eraseToAnyPublisher()
#elseif os(macOS)
Future { $0(.success(CGFloat.zero)) }
.eraseToAnyPublisher()
#endif
}
}
extension Notification {
var keyboardHeight: CGFloat {
#if os(iOS)
(userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
#elseif os(macOS)
0
#endif
}
}
struct KeyboardAdaptive: ViewModifier {
@State private var keyboardHeight: CGFloat = 0
func body(content: Content) -> some View {
content
.padding(.bottom, keyboardHeight)
.onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 }
}
}
extension View {
func keyboardAdaptive() -> some View {
ModifiedContent(content: self, modifier: KeyboardAdaptive())
}
}
extension View {
func hideKeyboard() {
#if os(iOS)
UIApplication.shared
.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
}

File diff suppressed because one or more lines are too long

View file

@ -37,32 +37,6 @@ public enum ShareExtensionStatus {
}
}
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 {
@ -193,9 +167,15 @@ public struct ShareExtensionChildView: View {
}
private func localImage(from url: URL) -> Image? {
if let data = try? Data(contentsOf: url), let img = UIImage(data: data) {
return Image(uiImage: img)
}
#if os(iOS)
if let data = try? Data(contentsOf: url), let img = UIImage(data: data) {
return Image(uiImage: img)
}
#else
if let data = try? Data(contentsOf: url), let img = NSImage(data: data) {
return Image(nsImage: img)
}
#endif
return nil
}

View file

@ -36,7 +36,7 @@ public struct Snackbar: View {
.cornerRadius(5)
.offset(x: 0, y: -8)
.shadow(color: .gray, radius: 2)
.animation(Animation.spring())
.animation(.spring(), value: true)
}
}
}

View file

@ -14,6 +14,7 @@ final class UtilsTests: XCTestCase {
XCTAssertEqual(normalizeURL("https://omnivore.app/"), "https://omnivore.app")
// utm_ removed
// swiftlint:disable:next line_length
XCTAssertEqual(normalizeURL("https://omnivore.app/?aa=a&bb=b&utm_track=track&cc=c"), "https://omnivore.app?aa=a&bb=b&cc=c")
// query params sorted

View file

@ -32,7 +32,7 @@ services:
- http.cors.allow-credentials=true
- http.port=9201
volumes:
- ./.docker/elastic-data:/usr/share/elasticsearch/data
- ./.docker/elastic-test-data:/usr/share/elasticsearch-test/data
ports:
- "9201:9201"

View file

@ -93,7 +93,7 @@ services:
- SSO_JWT_SECRET=some_sso_secret
- CLIENT_URL=http://localhost:3000
- GATEWAY_URL=http://localhost:8080/api
- PUPPETEER_TASK_HANDLER_URL=http://content-fetch:9090/
- PUPPETEER_TASK_HANDLER_URL=http://content-fetch:8080/?token=some_token
- REMINDER_TASK_HANDLER_URL=/svc/reminders/trigger
depends_on:
migrate:
@ -125,12 +125,13 @@ services:
content-fetch:
build:
context: .
dockerfile: ./packages/puppeteer-parse/Dockerfile
dockerfile: ./packages/content-fetch/Dockerfile-local
container_name: "omnivore-content-fetch"
expose:
- 9090
environment:
- JWT_SECRET=some_secret
- VERIFICATION_TOKEN=some_token
- REST_BACKEND_ENDPOINT=http://api:8080/api
depends_on:
api:

View file

@ -24,4 +24,4 @@ GCS_UPLOAD_SA_KEY_FILE_PATH=
TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
REMINDER_TASK_HANDLER_URL=
ELASTIC_URL=http://localhost:9200
ELASTIC_URL=http://localhost:9200

View file

@ -206,7 +206,15 @@ export const searchHighlights = async (
],
from,
size,
_source: ['title', 'slug', 'url', 'createdAt', 'highlights'],
_source: [
'title',
'slug',
'url',
'savedAt',
'highlights',
'readingProgressPercent',
'readingProgressAnchorIndex',
],
}
console.log('searching highlights in elastic', JSON.stringify(searchBody))

View file

@ -104,7 +104,7 @@ export const deleteLabel = async (
index: INDEX_ALIAS,
body: {
script: {
source: `if (ctx._source.highlights != null) {
source: `if (ctx._source.highlights != null && ctx._source.highlights[0].labels != null) {
ctx._source.highlights[0].labels.removeIf(label -> label.name == params.label)
}
if (ctx._source.labels != null) {

View file

@ -9,6 +9,7 @@ import {
} from './types'
import {
DateFilter,
FieldFilter,
HasFilter,
InFilter,
LabelFilter,
@ -17,7 +18,6 @@ import {
SortBy,
SortOrder,
SortParams,
TermFilter,
} from '../utils/search'
import { client, INDEX_ALIAS } from './index'
import { EntityType } from '../datalayer/pubsub'
@ -48,8 +48,8 @@ const appendReadFilter = (body: SearchBody, filter: ReadFilter): void => {
case ReadFilter.UNREAD:
body.query.bool.filter.push({
range: {
readingProgress: {
gte: 98,
readingProgressPercent: {
lt: 98,
},
},
})
@ -57,8 +57,8 @@ const appendReadFilter = (body: SearchBody, filter: ReadFilter): void => {
case ReadFilter.READ:
body.query.bool.filter.push({
range: {
readingProgress: {
lt: 98,
readingProgressPercent: {
gte: 98,
},
},
})
@ -158,7 +158,7 @@ const appendDateFilters = (body: SearchBody, filters: DateFilter[]): void => {
})
}
const appendTermFilters = (body: SearchBody, filters: TermFilter[]): void => {
const appendTermFilters = (body: SearchBody, filters: FieldFilter[]): void => {
filters.forEach((filter) => {
body.query.bool.filter.push({
term: {
@ -168,6 +168,16 @@ const appendTermFilters = (body: SearchBody, filters: TermFilter[]): void => {
})
}
const appendMatchFilters = (body: SearchBody, filters: FieldFilter[]): void => {
filters.forEach((filter) => {
body.query.bool.filter.push({
match: {
[filter.field]: filter.value,
},
})
})
}
export const createPage = async (
page: Page,
ctx: PageContext
@ -337,7 +347,8 @@ export const searchPages = async (
labelFilters: LabelFilter[]
hasFilters: HasFilter[]
dateFilters: DateFilter[]
termFilters?: TermFilter[]
termFilters?: FieldFilter[]
matchFilters?: FieldFilter[]
includePending?: boolean | null
},
userId: string
@ -355,6 +366,7 @@ export const searchPages = async (
hasFilters,
dateFilters,
termFilters,
matchFilters,
} = args
// default order is descending
const sortOrder = sort?.order || SortOrder.DESCENDING
@ -423,6 +435,9 @@ export const searchPages = async (
if (termFilters) {
appendTermFilters(body, termFilters)
}
if (matchFilters) {
appendMatchFilters(body, matchFilters)
}
if (!args.includePending) {
body.query.bool.must_not.push({

View file

@ -14,7 +14,7 @@ export interface SearchBody {
| { exists: { field: string } }
| {
range: {
readingProgress: { gte: number } | { lt: number }
readingProgressPercent: { gte: number } | { lt: number }
}
}
| {
@ -42,6 +42,11 @@ export interface SearchBody {
}
}
}
| {
match: {
[K: string]: string
}
}
)[]
should: {
multi_match: {
@ -162,7 +167,7 @@ export interface Highlight {
suffix?: string | null
annotation?: string | null
sharedAt?: Date | null
updatedAt?: Date | null
updatedAt?: Date
labels?: Label[]
}
@ -219,12 +224,14 @@ export interface SearchItem {
uploadFileId?: string | null
url: string
archivedAt?: Date | null
readingProgressPercent?: number
readingProgressAnchorIndex?: number
readingProgressPercent: number
readingProgressAnchorIndex: number
userId: string
state?: ArticleSavingRequestStatus
language?: string
readAt?: Date
savedAt: Date
updatedAt?: Date
}
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const

View file

@ -1524,8 +1524,9 @@ export type SearchItem = {
publishedAt?: Maybe<Scalars['Date']>;
quote?: Maybe<Scalars['String']>;
readAt?: Maybe<Scalars['Date']>;
readingProgressAnchorIndex?: Maybe<Scalars['Int']>;
readingProgressPercent?: Maybe<Scalars['Float']>;
readingProgressAnchorIndex: Scalars['Int'];
readingProgressPercent: Scalars['Float'];
savedAt: Scalars['Date'];
shortId?: Maybe<Scalars['String']>;
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
@ -1534,7 +1535,7 @@ export type SearchItem = {
title: Scalars['String'];
unsubHttpUrl?: Maybe<Scalars['String']>;
unsubMailTo?: Maybe<Scalars['String']>;
updatedAt: Scalars['Date'];
updatedAt?: Maybe<Scalars['Date']>;
uploadFileId?: Maybe<Scalars['ID']>;
url: Scalars['String'];
};
@ -3852,8 +3853,9 @@ export type SearchItemResolvers<ContextType = ResolverContext, ParentType extend
publishedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
quote?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
readAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
readingProgressAnchorIndex?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
readingProgressPercent?: Resolver<Maybe<ResolversTypes['Float']>, ParentType, ContextType>;
readingProgressAnchorIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
readingProgressPercent?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
savedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
shortId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@ -3862,7 +3864,7 @@ export type SearchItemResolvers<ContextType = ResolverContext, ParentType extend
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
unsubHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
unsubMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;

View file

@ -1082,8 +1082,9 @@ type SearchItem {
publishedAt: Date
quote: String
readAt: Date
readingProgressAnchorIndex: Int
readingProgressPercent: Float
readingProgressAnchorIndex: Int!
readingProgressPercent: Float!
savedAt: Date!
shortId: String
siteName: String
slug: String!
@ -1092,7 +1093,7 @@ type SearchItem {
title: String!
unsubHttpUrl: String
unsubMailTo: String
updatedAt: Date!
updatedAt: Date
uploadFileId: ID
url: String!
}

View file

@ -813,7 +813,7 @@ export const searchResolver = authorized<
},
})
let results: (SearchItemData | Page)[]
let results: SearchItemData[]
let totalCount: number
const searchType = searchQuery.typeFilter

View file

@ -1473,10 +1473,10 @@ const schema = gql`
pageType: PageType!
contentReader: ContentReader!
createdAt: Date!
updatedAt: Date!
updatedAt: Date
isArchived: Boolean!
readingProgressPercent: Float
readingProgressAnchorIndex: Int
readingProgressPercent: Float!
readingProgressAnchorIndex: Int!
author: String
image: String
description: String
@ -1498,6 +1498,7 @@ const schema = gql`
siteName: String
language: String
readAt: Date
savedAt: Date!
}
type SearchItemEdge {

View file

@ -32,7 +32,8 @@ export interface SearchFilter {
sortParams?: SortParams
hasFilters: HasFilter[]
dateFilters: DateFilter[]
termFilters: TermFilter[]
termFilters: FieldFilter[]
matchFilters: FieldFilter[]
}
export enum LabelFilterType {
@ -74,7 +75,7 @@ export interface SortParams {
order?: SortOrder
}
export interface TermFilter {
export interface FieldFilter {
field: string
value: string
}
@ -225,10 +226,10 @@ const parseDateFilter = (
}
}
const parseTermFilter = (
const parseFieldFilter = (
field: string,
str?: string
): TermFilter | undefined => {
): FieldFilter | undefined => {
if (str === undefined) {
return undefined
}
@ -250,6 +251,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
hasFilters: [],
dateFilters: [],
termFilters: [],
matchFilters: [],
}
if (!searchQuery) {
@ -261,6 +263,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
hasFilters: [],
dateFilters: [],
termFilters: [],
matchFilters: [],
}
}
@ -276,6 +279,9 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
'published',
'subscription',
'language',
'title',
'description',
'content',
],
tokenize: true,
})
@ -338,8 +344,16 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
// term filters
case 'subscription':
case 'language': {
const termFilter = parseTermFilter(keyword.keyword, keyword.value)
termFilter && result.termFilters.push(termFilter)
const fieldFilter = parseFieldFilter(keyword.keyword, keyword.value)
fieldFilter && result.termFilters.push(fieldFilter)
break
}
// match filters
case 'title':
case 'description':
case 'content': {
const fieldFilter = parseFieldFilter(keyword.keyword, keyword.value)
fieldFilter && result.matchFilters.push(fieldFilter)
break
}
}

View file

@ -1003,5 +1003,22 @@ describe('Article API', () => {
expect(res.body.data.search.edges[4].node.id).to.eq(highlights[0].id)
})
})
context('when is:unread is in the query', () => {
before(() => {
keyword = 'search is:unread'
})
it('should return unread articles in descending order', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.search.edges.length).to.eq(5)
expect(res.body.data.search.edges[0].node.id).to.eq(pages[4].id)
expect(res.body.data.search.edges[1].node.id).to.eq(pages[3].id)
expect(res.body.data.search.edges[2].node.id).to.eq(pages[2].id)
expect(res.body.data.search.edges[3].node.id).to.eq(pages[1].id)
expect(res.body.data.search.edges[4].node.id).to.eq(pages[0].id)
})
})
})
})

View file

@ -36,7 +36,6 @@ const App = () => {
<ArticleContainer
article={window.omnivoreArticle}
labels={window.omnivoreArticle.labels}
scrollElementRef={React.createRef()}
isAppleAppEmbed={true}
highlightBarDisabled={true}
highlightsBaseURL="https://example.com"

View file

@ -38,4 +38,5 @@ RUN yarn install --pure-lockfile
EXPOSE 8080
ENTRYPOINT ["yarn", "start"]
ENTRYPOINT ["yarn", "start"]

View file

@ -0,0 +1,51 @@
FROM node:14.18-alpine
# Installs latest Chromium (92) package.
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont \
nodejs \
gcc \
g++ \
python3 \
make \
yarn
# Tell Puppeteer to skip installing Chrome. We'll be using the installed package.
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Puppeteer v10.0.0 works with Chromium 92.
RUN yarn add puppeteer@10.0.0
# Add user so we don't need --no-sandbox.
RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \
&& mkdir -p /home/pptruser/Downloads /app \
&& chown -R pptruser:pptruser /home/pptruser \
&& chown -R pptruser:pptruser /app
# Run everything after as non-privileged user.
WORKDIR /app
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
ENV CHROMIUM_PATH /usr/bin/chromium-browser
ENV LAUNCH_HEADLESS=true
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .prettierrc .
COPY .eslintrc .
COPY /packages/content-fetch ./packages/content-fetch
RUN yarn install --pure-lockfile
EXPOSE 8080
CMD ["yarn", "workspace", "@omnivore/content-fetch", "start"]

View file

@ -24,7 +24,7 @@ const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) Apple
const BOT_DESKTOP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4372.0 Safari/537.36'
const NON_BOT_DESKTOP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4372.0 Safari/537.36'
const NON_BOT_HOSTS = ['bloomberg.com', 'forbes.com']
const NON_SCRIPT_HOSTS= ['medium.com']
const NON_SCRIPT_HOSTS= ['medium.com', 'fastcompany.com'];
const ALLOWED_CONTENT_TYPES = ['text/html', 'application/octet-stream', 'text/plain', 'application/pdf'];
@ -64,7 +64,7 @@ const fetchContentWithScrapingBee = async (url) => {
return { title: dom.title, domContent: dom.documentElement.outerHTML, url: url }
}
const enableJavascriptForUrl = async (url) => {
const enableJavascriptForUrl = (url) => {
try {
const u = new URL(url);
for (const host of NON_SCRIPT_HOSTS) {
@ -397,7 +397,7 @@ function getUrl(req) {
}
async function blockResources(page) {
async function blockResources(client) {
const blockedResources = [
// Assets
// '*/favicon.ico',
@ -420,7 +420,7 @@ async function blockResources(page) {
'sp.analytics.yahoo.com',
]
await page._client.send('Network.setBlockedURLs', { urls: blockedResources });
await client.send('Network.setBlockedURLs', { urls: blockedResources });
}
async function retrievePage(url) {
@ -433,7 +433,7 @@ async function retrievePage(url) {
const page = await context.newPage()
if (!enableJavascriptForUrl(url)) {
page.setJavaScriptEnabled(false)
await page.setJavaScriptEnabled(false);
}
await page.setUserAgent(userAgentForUrl(url));
@ -453,7 +453,7 @@ async function retrievePage(url) {
const path = require('path');
const download_path = path.resolve('./download_dir/');
await page._client.send('Page.setDownloadBehavior', {
await client.send('Page.setDownloadBehavior', {
behavior: 'allow',
userDataDir: './',
downloadPath: download_path,
@ -482,7 +482,7 @@ async function retrievePage(url) {
} catch {}
});
await blockResources(page);
await blockResources(client);
/*
* Disallow MathJax from running in Puppeteer and modifying the document,
@ -666,7 +666,7 @@ async function retrieveHtml(page) {
}
console.log('DOM CONTENT')
console.log(domContent)
if (domContent == 'IS_BLOCKED') {
if (domContent === 'IS_BLOCKED') {
return { isBlocked: true };
}
return { domContent, title };

View file

@ -10,10 +10,8 @@ const os = require('os');
exports.mediumHandler = {
shouldPrehandle: (url, env) => {
const MEDIUM_URL_MATCH =
/https?:\/\/(www\.)?medium.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/
const res = MEDIUM_URL_MATCH.test(url.toString())
return res
const u = new URL(url);
return u.hostname.endsWith('medium.com')
},
prehandle: async (url, env) => {

View file

@ -82,7 +82,7 @@ const logAppliedMigrations = (
console.log(` ${actionLabel} ${migration.name}`)
}
} else {
log(`No migrations applied.`)
log(`No Postgres migrations applied.`)
}
}
@ -95,14 +95,21 @@ export const esClient = new Client({
password: process.env.ELASTIC_PASSWORD || '',
},
})
// read index settings from file
const indexSettings = readFileSync(
join(__dirname, 'elastic_migrations', 'index_settings.json'),
'utf8'
)
const INDEX_NAME = 'pages'
const createIndex = async (): Promise<void> => {
// create index
await esClient.indices.create({
index: INDEX_NAME,
body: JSON.parse(indexSettings),
})
}
const updateMappings = async (): Promise<void> => {
// read index settings from file
const indexSettings = readFileSync(
join(__dirname, 'elastic_migrations', 'index_settings.json'),
'utf8'
)
// update mappings
await esClient.indices.putMapping({
index: INDEX_ALIAS,
@ -110,56 +117,73 @@ const updateMappings = async (): Promise<void> => {
})
}
postgrator
// postgres migration
const postgresMigration = postgrator
.migrate(targetMigration)
.then(logAppliedMigrations)
.catch((error) => {
log(`${chalk.red('Migration failed: ')}${error.message}`, chalk.red)
log(
`${chalk.red('Postgres migration failed: ')}${error.message}`,
chalk.red
)
const { appliedMigrations } = error
logAppliedMigrations(appliedMigrations)
process.exit(1)
})
.then(() => console.log('\nExiting...'))
log('Starting updating elasticsearch index mappings...')
updateMappings()
.then(() => console.log('\nUpdating elastic mappings completed.'))
.catch((error) => {
log(`${chalk.red('Updating failed: ')}${error.message}`, chalk.red)
process.exit(1)
// elastic migration
log('Creating elastic index...')
const elasticMigration = esClient.indices
.exists({ index: INDEX_ALIAS })
.then(({ body: exists }) => {
if (!exists) {
return createIndex().then(() => log('Elastic index created.'))
} else {
log('Elastic index already exists.')
}
})
log('Starting adding default state to pages in elasticsearch...')
esClient
.update_by_query({
index: INDEX_ALIAS,
requests_per_second: 250,
scroll_size: 500,
timeout: '30m',
body: {
script: {
source: 'ctx._source.state = params.state',
lang: 'painless',
params: {
state: 'SUCCEEDED',
},
},
query: {
bool: {
must_not: [
{
exists: {
field: 'state',
},
.then(() => {
log('Updating elastic index mappings...')
return updateMappings().then(() => {
log('Elastic index mappings updated.')
})
})
.then(() => {
log('Adding default state to pages in elastic...')
return esClient
.update_by_query({
index: INDEX_ALIAS,
requests_per_second: 250,
scroll_size: 500,
timeout: '30m',
body: {
script: {
source: 'ctx._source.state = params.state',
lang: 'painless',
params: {
state: 'SUCCEEDED',
},
],
},
query: {
bool: {
must_not: [
{
exists: {
field: 'state',
},
},
],
},
},
},
},
},
})
.then(() => log('Default state added.'))
})
.then(() => console.log('\nAdding default state completed.'))
.catch((error) => {
log(`${chalk.red('Adding failed: ')}${error.message}`, chalk.red)
log(`${chalk.red('Elastic migration failed: ')}${error.message}`, chalk.red)
const { appliedMigrations } = error
logAppliedMigrations(appliedMigrations)
process.exit(1)
})
Promise.all([postgresMigration, elasticMigration]).then(() => log('Exiting...'))

View file

@ -15,6 +15,21 @@ var testcaseRoot = path.join(__dirname, "test-pages");
var argURL = process.argv[3]; // Could be undefined, we'll warn if it is if that is an issue.
const NON_SCRIPT_HOSTS= ['medium.com', 'fastcompany.com'];
const enableJavascriptForUrl = (url) => {
try {
const u = new URL(url);
for (const host of NON_SCRIPT_HOSTS) {
if (u.hostname.endsWith(host)) {
return false;
}
}
} catch (e) {
console.log('error getting hostname for url', url, e)
}
return true
};
function generateTestcase(slug) {
var destRoot = path.join(testcaseRoot, slug);
@ -59,6 +74,9 @@ async function fetchSource(url, callbackFn) {
});
const page = await browser.newPage();
if (!enableJavascriptForUrl(url)) {
await page.setJavaScriptEnabled(false);
}
const ua = generateRandomUA();
await page.setUserAgent(ua);
@ -80,7 +98,7 @@ async function fetchSource(url, callbackFn) {
}
});
await page.goto(url, { waitUntil: ['load'] });
await page.goto(url, { waitUntil: ['networkidle2'] });
/* scroll with a 5 second timeout */
await Promise.race([
@ -105,7 +123,6 @@ async function fetchSource(url, callbackFn) {
})()`);
} catch (e) {
console.error('error in scrolling url', { e, url });
logRecord.scrollError = true;
} finally {
resolve(true);
}

View file

@ -0,0 +1,12 @@
{
"title": "Why your boss might fire people rather than allow remote work",
"byline": "Diana Shi",
"dir": null,
"excerpt": "Leaders are weighing the benefits of some forms of remote schedules against company-wide efficiency.",
"siteName": "Fast Company",
"siteIcon": "/apple-touch-icon.png?v=5",
"previewImage": "https://images.fastcompany.net/image/upload/w_1280,f_auto,q_auto,fl_lossy/wp-cms/uploads/2022/06/p-1-why-your-boss-is-weighing-firing-people-over-infinite-remote-work.jpg",
"publishedDate": "2022-06-13T14:00:38.000Z",
"language": "English",
"readerable": true
}

View file

@ -0,0 +1,27 @@
<DIV class="page" id="readability-page-1">
<div id="post__wrapper-90760264" data-id="90760264" data-slug="why-your-boss-might-fire-people-rather-than-allow-remote-work">
<article>
<div>
<div>
<p> There are many people claiming the pandemic will result in “the death of the office.” However, for many jobs, specifically collaborative, high-skill, and high-value roles, working from home doesnt cut it.&nbsp;Working from home has its benefits—freedom and flexibility being just a couple of them—and so it may not become a thing of the past entirely. One solution for the future is an organized hybrid model that recognizes the benefits of flexibility on the productivity and overall well-being of employees. </p>
<p> With that in mind, executives and employees alike need to prepare to return to offices in the near future. After two years of remote work, many companies are already choosing to return to the office. Companies like Citigroup, BNY Mellon, American Express, Goldman Sachs, JPMorgan Chase, Google, and Twitter are all calling workers back to their offices in a hybrid model that might not be so optional. A recent GoodHire <a href="https://www.goodhire.com/resources/articles/the-great-return-manager-survey/" target="_blank" rel="noopener noreferrer">survey</a> found that 77% of managers would take action if workers do not come back to the office. </p>
<p> So what does the future of the workplace look like, and how does the hybrid model fit into that future? </p>
<p> It comes down to a matter of perspective. Who has the power? Businesses werent prepared for the pandemic, so they needed people to work from home. The technological infrastructure that made that possible has come leaps and bounds since that time. All of a sudden, work was no longer a destination that required an often lengthy and hectic commute, but rather a trip to the coffee pot and a seat at the kitchen table. </p>
<p> A more competitive job market, on the other hand, allows employees to determine the future of the work environment. <a href="https://www.prnewswire.com/news-releases/employees-have-upper-hand-in-pandemic-induced-power-shift-according-to-new-beamery-talent-index-301395484.html" target="_blank" rel="noopener noreferrer">Two-thirds</a> of U.S. and U.K. workers who experienced more work flexibility during the pandemic want employers to prioritize work-life balance moving forward. So the question becomes: Do the benefits of working from home—even in a hybrid model—outweigh the consequences? </p>
<p> One <a href="https://hbr.org/2014/01/to-raise-productivity-let-more-employees-work-from-home" target="_blank" rel="noopener noreferrer">case study</a> found that employees who worked from home for a period of nine months were 13.5% more productive than their in-office counterparts. But just because someone claims to be more productive (and genuinely may be), a bigger question is whether it is pushing the business forward? The efficiency of a business ecosystem is larger than one employee, larger even than entire departments. </p>
<p> Collaborative workers, individuals tasked with creative projects in virtual teams, <a href="https://2020.deshowcase.london/projects/course/phd/socio-cognitive-analysis-of-distributed-multidisciplinary-collaboration-shared-understanding-creativity-and-team-effectiveness" target="_blank" rel="noopener noreferrer">report</a> feeling more like “workers” and less like members of the family. <a href="https://www.tandfonline.com/doi/full/10.1080/00223980.2018.1449729" target="_blank" rel="noopener noreferrer">Studies</a> also show that the best creative work takes place when a team is in a state of flow, focusing their collective attention on a single task; but remote work makes it difficult to keep everyone engaged. </p>
<p> This may not affect the productivity of all <i>roles</i> (such as technical jobs that require minimal teamwork), but it certainly affects all <i>employees.</i> Working on-site makes it more likely to have spontaneous communication, increasing workers feeling of connection with their teammates <a href="https://www.adpri.org/assets/on-site-remote-or-hybrid-employee-sentiment-on-the-workplace/" target="_blank" rel="noopener noreferrer">by more than 20%</a> compared to at-home workers. </p>
<p> Many people want more than a paycheck when they come to work. Strengthening company culture means creating that sense of unity and camaraderie through high levels of communication among supervisors and staff, opportunity for employees to provide input and feedback, and a feeling of common values and goals. </p>
<p> Peoples perceptions of their relationships and sense of belonging in the workplace tie directly to the success of the company. The individuals positive perception of the “<a href="https://www.mckinsey.com/business-functions/people-and-organizational-performance/our-insights/the-five-attributes-of-enduring-family-businesses" target="_blank" rel="noopener noreferrer">family approach</a>” leads to increased employee retention and recruitment, improved performance, and overall positive feelings associated with the workplace. </p>
<p> It would be challenging to find an employer that says its easier to build those kinds of relationships online rather than in person. Its as simple as comparing it to online dating—you can only get so far trading direct messages and phone calls until you simply must meet in person to come to a decision. </p>
<p> Going forward, organizations will do well to teach people <i>how to create work—</i>to learn how to make the time in the office beneficial for themselves as well. Its almost an entrepreneurial mindset that needs to come in. If an entrepreneur is forced to spend time in a certain place, they are allocating much of that time to networking, building business, and sharing ideas. Senior people in organizations understand this and are doing it. </p>
<p> The hybrid model may be the future for many organizations, but they will have to work at creating a healthy culture that supports different types of roles and employees. It will be a learning curve to make hybrid work. The question companies need to ask is <i>why</i> are they bringing their employees back? Whatever the answer is, it should drive the way they prepare their people for that return to the office. </p>
</div>
<hr>
<p>
<em>Jim Frawley is a coach, consultant, and the CEO and founder of <a href="https://bellwetherhub.com/" target="_blank" rel="noopener noreferrer">Bellwether</a>, a talent coaching firm. He <span lang="EN-US" xml:lang="EN-US" data-contrast="none"><span>specializes in helping corporations maximize their efficiency</span></span> <span lang="EN-US" data-contrast="none" xml:lang="EN-US"><span>and enhance their growth.</span></span><br></em>
</p>
</div>
</article>
</div>
</DIV>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
https://www.fastcompany.com/90760264/why-your-boss-might-fire-people-rather-than-allow-remote-work

View file

@ -39,10 +39,6 @@ export const ModalContent = styled(Modal, {
width: '90vw',
maxWidth: '600px',
maxHeight: '85vh',
'@media (prefers-reduced-motion: no-preference)': {
animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
willChange: 'transform',
},
'@smDown': {
maxWidth: '95%',
width: '95%',

View file

@ -5,10 +5,6 @@ import { darkenTheme, lightenTheme, updateTheme } from '../../lib/themeUpdater'
import { AvatarDropdown } from './../elements/AvatarDropdown'
import { ThemeId } from './../tokens/stitches.config'
import { useCallback, useEffect, useState } from 'react'
import {
ScrollOffsetChangeset,
useScrollWatcher,
} from '../../lib/hooks/useScrollWatcher'
import { useRouter } from 'next/router'
import { useKeyboardShortcuts } from '../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { primaryCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
@ -21,7 +17,6 @@ type HeaderProps = {
hideHeader?: boolean
profileImageURL?: string
isTransparent: boolean
scrollElementRef?: React.RefObject<HTMLDivElement>
toolbarControl?: JSX.Element
alwaysDisplayToolbar?: boolean
setShowLogoutConfirmation: (showShareModal: boolean) => void
@ -48,19 +43,26 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
})
)
const setScrollWatchedElement = useScrollWatcher(
(changeset: ScrollOffsetChangeset) => {
const isScrolledBeyondMinThreshold = changeset.current.y >= 50
const isScrollingDown = changeset.current.y > changeset.previous.y
/*
useRegisterActions([
{
id: 'lightTheme',
section: 'Preferences',
name: 'Change theme (lighter) ',
shortcut: ['v', 'l'],
keywords: 'light theme',
perform: () => lightenTheme(),
},
0
)
useEffect(() => {
if (props.scrollElementRef) {
setScrollWatchedElement(props.scrollElementRef.current)
}
}, [props.scrollElementRef, setScrollWatchedElement])
{
id: 'darkTheme',
section: 'Preferences',
name: 'Change theme (darker) ',
shortcut: ['v', 'd'],
keywords: 'dark theme',
perform: () => darkenTheme(),
},
])
*/
const initAnalytics = useCallback(() => {
setupAnalytics(props.user)

View file

@ -21,7 +21,6 @@ type PrimaryLayoutProps = {
pageTestId: string
hideHeader?: boolean
pageMetaDataProps?: PageMetaDataProps
scrollElementRef?: MutableRefObject<HTMLDivElement | null>
headerToolbarControl?: JSX.Element
alwaysDisplayToolbar?: boolean
}
@ -63,8 +62,8 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
<PageMetaData {...props.pageMetaDataProps} />
) : null}
<Box css={{
height: '100vh',
width: '100vw',
height: '100vh',
bg: 'transparent',
'@smDown': {
bg: '$grayBase',
@ -76,24 +75,19 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
userInitials={viewerData?.me?.name.charAt(0) ?? ''}
profileImageURL={viewerData?.me?.profile.pictureUrl}
isTransparent={true}
scrollElementRef={props.scrollElementRef}
toolbarControl={props.headerToolbarControl}
alwaysDisplayToolbar={props.alwaysDisplayToolbar}
setShowLogoutConfirmation={setShowLogoutConfirmation}
setShowKeyboardCommandsModal={setShowKeyboardCommandsModal}
/>
<Box
ref={props.scrollElementRef}
css={{
position: 'fixed',
overflowY: 'auto',
height: '100%',
width: '100vw',
bg: '$grayBase',
}}
>
<Box
ref={props.scrollElementRef}
<Box
css={{
height: '48px',
bg: '$grayBase',

View file

@ -25,7 +25,6 @@ export type ArticleProps = {
initialAnchorIndex: number
initialReadingProgress?: number
highlightHref: MutableRefObject<string | null>
scrollElementRef: MutableRefObject<HTMLDivElement | null>
articleMutations: ArticleMutations
}
@ -89,16 +88,9 @@ export function Article(props: ArticleProps): JSX.Element {
}
}, [readingProgress])
const setScrollWatchedElement = useScrollWatcher(
useScrollWatcher(
(changeset: ScrollOffsetChangeset) => {
const scrollContainer = props.scrollElementRef.current
if (scrollContainer) {
const newReadingProgress =
(changeset.current.y + scrollContainer.clientHeight) /
scrollContainer.scrollHeight
debouncedSetReadingProgress(newReadingProgress * 100)
} else if (window && window.document.scrollingElement) {
if (window && window.document.scrollingElement) {
const newReadingProgress =
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
@ -131,10 +123,6 @@ export function Article(props: ArticleProps): JSX.Element {
[]
)
useEffect(() => {
setScrollWatchedElement(props.scrollElementRef.current)
}, [props.scrollElementRef, setScrollWatchedElement])
// Scroll to initial anchor position
useEffect(() => {
if (typeof window === 'undefined') {
@ -175,17 +163,11 @@ export function Article(props: ArticleProps): JSX.Element {
}
const calculatedOffset = calculateOffset(anchorElement)
if (props.scrollElementRef.current) {
props.scrollElementRef.current?.scroll(0, calculatedOffset - 100)
} else {
window.document.documentElement.scroll(0, calculatedOffset - 100)
}
window.document.documentElement.scroll(0, calculatedOffset - 100)
}
}
}, [
props.highlightReady,
props.scrollElementRef,
props.initialAnchorIndex,
props.initialReadingProgress,
shouldScrollToInitialPosition,

View file

@ -7,16 +7,15 @@ import { Box, SpanBox } from "../../elements/LayoutPrimitives"
import { TooltipWrapped } from "../../elements/Tooltip"
import { styled, theme } from "../../tokens/stitches.config"
import { SetLabelsControl } from "./SetLabelsControl"
import { ReaderSettingsControl } from "./ReaderSettingsControl"
import { DisplaySettingsModal } from "./DisplaySettingsModal"
import { useReaderSettings } from "../../../lib/hooks/useReaderSettings"
import { useRef } from "react"
export type ArticleActionsMenuLayout = 'top' | 'side'
type ArticleActionsMenuProps = {
article?: ArticleAttributes
layout: ArticleActionsMenuLayout
lineHeight: number
marginWidth: number
fontFamily: string
showReaderDisplaySettings?: boolean
articleActionHandler: (action: string, arg?: unknown) => void
}
@ -56,6 +55,9 @@ const ActionDropdown = (props: ActionDropdownProps): JSX.Element => {
}
export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element {
const readerSettings = useReaderSettings()
const displaySettingsButtonRef = useRef<HTMLElement | null>(null)
return (
<>
<Box
@ -70,29 +72,19 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
>
{props.showReaderDisplaySettings && (
<>
<ActionDropdown
layout={props.layout}
triggerElement={
<TooltipWrapped
tooltipContent="Adjust Display Settings"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<TextAa size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
}
<Button style='articleActionIcon' onClick={() => readerSettings.setShowEditDisplaySettingsModal(true)}>
<TooltipWrapped
tooltipContent="Adjust Display Settings"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<ReaderSettingsControl
fontFamily={props.fontFamily}
lineHeight={props.lineHeight}
marginWidth={props.marginWidth}
articleActionHandler={props.articleActionHandler}
/>
</ActionDropdown>
<MenuSeparator layout={props.layout} />
<SpanBox ref={displaySettingsButtonRef}>
<TextAa size={24} color={theme.colors.readerFont.toString()} />
</SpanBox>
</TooltipWrapped>
</Button>
<MenuSeparator layout={props.layout} />
</>
)}
<SpanBox css={{
'display': 'flex',
'@smDown': {
@ -190,6 +182,14 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
<DotsThree size={24} color={theme.colors.readerFont.toString()} />
</Button> */}
</Box>
{readerSettings.showEditDisplaySettingsModal && (
<DisplaySettingsModal
centerX={props.layout != 'side'}
triggerElementRef={displaySettingsButtonRef}
articleActionHandler={props.articleActionHandler}
onOpenChange={() => readerSettings.setShowEditDisplaySettingsModal(false)}
/>
)}
</>
)
}

View file

@ -24,7 +24,6 @@ type ArticleContainerProps = {
article: ArticleAttributes
labels: Label[]
articleMutations: ArticleMutations
scrollElementRef: MutableRefObject<HTMLDivElement | null>
isAppleAppEmbed: boolean
highlightBarDisabled: boolean
highlightsBaseURL: string
@ -281,7 +280,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
articleId={props.article.id}
content={props.article.content}
initialAnchorIndex={props.article.readingProgressAnchorIndex}
scrollElementRef={props.scrollElementRef}
articleMutations={props.articleMutations}
/>
<Button

View file

@ -1,61 +1,39 @@
import { X } from 'phosphor-react'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { UserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { Button } from '../../elements/Button'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import { VStack } from '../../elements/LayoutPrimitives'
import {
ModalRoot,
ModalOverlay,
ModalContent,
} from '../../elements/ModalPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { ReaderSettingsControl } from './ReaderSettingsControl'
type DisplaySettingsModalProps = {
centerX: boolean
onOpenChange: (open: boolean) => void
lineHeight: number
marginWidth: number
fontFamily: string
triggerElementRef?: React.RefObject<HTMLElement>
articleActionHandler: (action: string, arg?: number | string) => void
}
export function DisplaySettingsModal(props: DisplaySettingsModalProps): JSX.Element {
const top = props.triggerElementRef?.current?.getBoundingClientRect().bottom ?? 0
const left = props.triggerElementRef?.current?.getBoundingClientRect().left ?? 0
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
css={{ overflow: 'auto' }}
css={{
width: '245px',
top: props.triggerElementRef?.current ? top : '50%',
left: props.triggerElementRef?.current ? (left - (props.centerX ? 265 / 2 : 0)) : '50%',
transform: props.triggerElementRef?.current ? 'unset' : 'translate(-50%, -50%)',
}}
onPointerDownOutside={(event) => {
event.preventDefault()
props.onOpenChange(false)
}}
>
<VStack css={{ width: '100%' }}>
<HStack
distribution="between"
alignment="center"
css={{ width: '100%' }}
>
<StyledText style="modalHeadline" css={{ pl: '16px' }}>Labels</StyledText>
<Button
css={{ pt: '16px', pr: '16px' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<CrossIcon
size={14}
strokeColor={theme.colors.grayText.toString()}
/>
</Button>
</HStack>
<ReaderSettingsControl
lineHeight={props.lineHeight}
marginWidth={props.marginWidth}
fontFamily={props.fontFamily}
articleActionHandler={props.articleActionHandler}
/>
</VStack>

Some files were not shown because too many files have changed in this diff Show more