mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #856 from omnivore-app/main
Web production deployment
This commit is contained in:
commit
85efcd892f
119 changed files with 3062 additions and 2076 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ struct InnerRootView: View {
|
|||
}
|
||||
}
|
||||
#endif
|
||||
.onOpenURL { Authenticator.handleGoogleURL(url: $0) }
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func syncLabelCreation(label: InternalLinkedItemLabel) {
|
||||
enum MutationResult {
|
||||
case saved(label: InternalLinkedItemLabel)
|
||||
|
|
|
|||
|
|
@ -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() }))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -813,7 +813,7 @@ export const searchResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
let results: (SearchItemData | Page)[]
|
||||
let results: SearchItemData[]
|
||||
let totalCount: number
|
||||
|
||||
const searchType = searchQuery.typeFilter
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -38,4 +38,5 @@ RUN yarn install --pure-lockfile
|
|||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["yarn", "start"]
|
||||
ENTRYPOINT ["yarn", "start"]
|
||||
|
||||
|
|
|
|||
51
packages/content-fetch/Dockerfile-local
Normal file
51
packages/content-fetch/Dockerfile-local
Normal 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"]
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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...'))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 doesn’t cut it. 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 weren’t 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 individual’s 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 it’s easier to build those kinds of relationships online rather than in person. It’s 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. It’s 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>
|
||||
1145
packages/readabilityjs/test/test-pages/fast-company/source.html
Normal file
1145
packages/readabilityjs/test/test-pages/fast-company/source.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
https://www.fastcompany.com/90760264/why-your-boss-might-fire-people-rather-than-allow-remote-work
|
||||
|
|
@ -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%',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue