mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
commit
d05c4afec4
1485 changed files with 188409 additions and 0 deletions
8
Makefile
Normal file
8
Makefile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
open_ios:
|
||||
$(MAKE) -C apple open
|
||||
|
||||
apple_graphql_gen:
|
||||
$(MAKE) -C apple graphql_gen
|
||||
|
||||
apple_extension_gen:
|
||||
$(MAKE) -C apple extension_gen
|
||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Omnivore
|
||||
|
||||
[Omnivore](https://omnivore.app) is a complete, open source read-it-later solution for people who like text.
|
||||
|
||||
We built Omnivore because we love reading and we want it to be more social. Join us!
|
||||
|
||||
- Highlighting, notes, search, and sharing
|
||||
- Full keyboard navigation
|
||||
- Automatically saves your place in long articles
|
||||
- Add articles via email (with substack support!)
|
||||
- PDF support
|
||||
- [Web app](https://omnivore.app/) written in node and typescript
|
||||
- [Native iOS app](https://discord.gg/nyqRrjujNe)
|
||||
- Progressive web app for Android users
|
||||
- [Browser extensions](https://omnivore.app/install/chrome) for Chrome, Safari, Firefox, and Edge
|
||||
- Tagging (coming soon!)
|
||||
- Offline support (coming soon!)
|
||||
|
||||
Every single part is fully open source! Fork it, extend it, or deploy it to your own server.
|
||||
|
||||
We also have a free hosted version of Omnivore at [omnivore.app](https://omnivore.app/) -- try it now!
|
||||
|
||||
## Join us on Discord!
|
||||
|
||||
We're building our community on Discord. [Join us!](https://discord.gg/nyqRrjujNe)
|
||||
|
||||
## How to setup local development
|
||||
|
||||
FIXME: Jackson to fill this in
|
||||
|
||||
## How to deploy to your own server
|
||||
|
||||
FIXME: Jackson to fill this in
|
||||
|
||||
## License
|
||||
|
||||
Omnivore and our extensions to Readability.js are under the AGPL-3.0 license.
|
||||
20
apple/.gitignore
vendored
Normal file
20
apple/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.swiftpm/
|
||||
|
||||
*.xcuserstate
|
||||
.DS_Store
|
||||
*.xcuserdatad
|
||||
|
||||
.build
|
||||
/build
|
||||
*/xcuserdata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
.bundle
|
||||
output/
|
||||
/vendor
|
||||
*.dSYM.zip
|
||||
|
||||
OmnivoreKit/Sources/Utils/Resources/Keys.json
|
||||
6
apple/.swiftformat
Normal file
6
apple/.swiftformat
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
--exclude build,.build,vendor,docs,.bundle
|
||||
--indent 2
|
||||
--tabwidth 2
|
||||
--commas inline
|
||||
--self init-only
|
||||
--swiftversion 5.5
|
||||
11
apple/BuildTools/.swiftlint.yml
Normal file
11
apple/BuildTools/.swiftlint.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
excluded:
|
||||
- build
|
||||
- .build
|
||||
- vendor
|
||||
- docs
|
||||
- .bundle
|
||||
- BuildTools
|
||||
|
||||
identifier_name:
|
||||
excluded:
|
||||
- id
|
||||
1
apple/BuildTools/Empty.swift
Normal file
1
apple/BuildTools/Empty.swift
Normal file
|
|
@ -0,0 +1 @@
|
|||
// Empty
|
||||
88
apple/BuildTools/Package.resolved
Normal file
88
apple/BuildTools/Package.resolved
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "Files",
|
||||
"repositoryURL": "https://github.com/JohnSundell/Files",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "d273b5b7025d386feef79ef6bad7de762e106eaf",
|
||||
"version": "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SourceKitten",
|
||||
"repositoryURL": "https://github.com/jpsim/SourceKitten.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "7f4be006fe73211b0fd9666c73dc2f2303ffa756",
|
||||
"version": "0.31.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-argument-parser",
|
||||
"repositoryURL": "https://github.com/apple/swift-argument-parser",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc",
|
||||
"version": "0.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-graphql",
|
||||
"repositoryURL": "https://github.com/maticzav/swift-graphql",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "c3dee58c57543db473b2d5fd76871e282adedc9d",
|
||||
"version": "2.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftFormat",
|
||||
"repositoryURL": "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "a9bdfd2548ebe518a693a5c6341d27c42b7e7a0a",
|
||||
"version": "0.48.11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftLint",
|
||||
"repositoryURL": "https://github.com/realm/SwiftLint",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "e820e750b08bd67bc9d98f4817868e9bc3d5d865",
|
||||
"version": "0.44.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftyTextTable",
|
||||
"repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3",
|
||||
"version": "0.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SWXMLHash",
|
||||
"repositoryURL": "https://github.com/drmohundro/SWXMLHash.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "9183170d20857753d4f331b0ca63f73c60764bf3",
|
||||
"version": "5.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Yams",
|
||||
"repositoryURL": "https://github.com/jpsim/Yams.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa",
|
||||
"version": "4.0.6"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
13
apple/BuildTools/Package.swift
Normal file
13
apple/BuildTools/Package.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// swift-tools-version:5.5
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "BuildTools",
|
||||
platforms: [.macOS(.v10_11)],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.48.11"),
|
||||
.package(url: "https://github.com/realm/SwiftLint", from: "0.44.0"),
|
||||
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.2.1")
|
||||
],
|
||||
targets: [.target(name: "BuildTools", path: "")]
|
||||
)
|
||||
26
apple/Entitlements/MacOmnivore.entitlements
Normal file
26
apple/Entitlements/MacOmnivore.entitlements
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>QJF2XZ86HB.app.omnivore.app</string>
|
||||
</array>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.omnivoreapp.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
26
apple/Entitlements/Omnivore.entitlements
Normal file
26
apple/Entitlements/Omnivore.entitlements
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.omnivoreapp</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.omnivore.shared</string>
|
||||
</array>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:omnivore.app</string>
|
||||
<string>applinks:dev.omnivore.app</string>
|
||||
<string>applinks:demo.omnivore.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
22
apple/Entitlements/SafariExtension-Mac.entitlements
Normal file
22
apple/Entitlements/SafariExtension-Mac.entitlements
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>QJF2XZ86HB.app.omnivore.app</string>
|
||||
</array>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.omnivoreapp.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
14
apple/Entitlements/ShareExtension.entitlements
Normal file
14
apple/Entitlements/ShareExtension.entitlements
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.omnivoreapp</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.omnivore.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
22
apple/Entitlements/ShareExtensionMac.entitlements
Normal file
22
apple/Entitlements/ShareExtensionMac.entitlements
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>QJF2XZ86HB.app.omnivore.app</string>
|
||||
</array>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.omnivoreapp.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
46
apple/InfoPlists/MacOmnivore.plist
Normal file
46
apple/InfoPlists/MacOmnivore.plist
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.omnivore</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>omnivore</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
22
apple/InfoPlists/MacUITests.plist
Normal file
22
apple/InfoPlists/MacUITests.plist
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
22
apple/InfoPlists/MacUnitTests.plist
Normal file
22
apple/InfoPlists/MacUnitTests.plist
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
73
apple/InfoPlists/Omnivore.plist
Normal file
73
apple/InfoPlists/Omnivore.plist
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Omnivore</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.omnivore</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>omnivore</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>FirebaseAppDelegateProxyEnabled</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Pictures captured with the camera can be added to the document as image annotations.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Recording sound annotations requires the microphone.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Images from documents and annotations can be saved to the photo library.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Images from your photo library can be chosen by you to send as feedback.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
22
apple/InfoPlists/OmnivoreTests.plist
Normal file
22
apple/InfoPlists/OmnivoreTests.plist
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
13
apple/InfoPlists/SafariExtension-Mac.plist
Normal file
13
apple/InfoPlists/SafariExtension-Mac.plist
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.Safari.web-extension</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
13
apple/InfoPlists/SafariExtension-iOS.plist
Normal file
13
apple/InfoPlists/SafariExtension-iOS.plist
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.Safari.web-extension</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
54
apple/InfoPlists/ShareExtension.plist
Normal file
54
apple/InfoPlists/ShareExtension.plist
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Omnivore</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>
|
||||
SUBQUERY (
|
||||
extensionItems,
|
||||
$extensionItem,
|
||||
SUBQUERY (
|
||||
$extensionItem.attachments,
|
||||
$attachment,
|
||||
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text"
|
||||
).@count > 0
|
||||
).@count >= 1
|
||||
</string>
|
||||
<key>NSExtensionActivationUsesStrictMatching</key>
|
||||
<integer>2</integer>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>ShareExtension</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>ShareExtensionViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
52
apple/InfoPlists/ShareExtensionMac.plist
Normal file
52
apple/InfoPlists/ShareExtensionMac.plist
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Omnivore</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>ShareExtension</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
22
apple/InfoPlists/iOSUITests.plist
Normal file
22
apple/InfoPlists/iOSUITests.plist
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
10
apple/Makefile
Normal file
10
apple/Makefile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
open:
|
||||
open Omnivore.xcworkspace
|
||||
|
||||
graphql_gen:
|
||||
scripts/graphql_gen.sh
|
||||
|
||||
extension_gen:
|
||||
pushd ../pkg/extension/ && yarn build-prod && popd
|
||||
cp -r ../pkg/extension/dist/* Sources/SafariExtension/Resources/.
|
||||
cat ../pkg/extension/dist/manifest.json | jq '.background.persistent = false' > Sources/SafariExtension/Resources/manifest.json
|
||||
40
apple/Omnivore-Mac.xctestplan
Normal file
40
apple/Omnivore-Mac.xctestplan
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "00A0264D-0F05-4754-86EC-F9739B63A279",
|
||||
"name" : "Configuration 1",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"codeCoverage" : false,
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:Omnivore.xcodeproj",
|
||||
"identifier" : "048ECFFB26A0B1CB00469E57",
|
||||
"name" : "Omnivore"
|
||||
}
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"parallelizable" : true,
|
||||
"target" : {
|
||||
"containerPath" : "container:Omnivore.xcodeproj",
|
||||
"identifier" : "0411792626A22860004AE24F",
|
||||
"name" : "UnitTests-MacApp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skippedTests" : [
|
||||
"MacUILaunchTest\/testLaunchPerformance()"
|
||||
],
|
||||
"target" : {
|
||||
"containerPath" : "container:Omnivore.xcodeproj",
|
||||
"identifier" : "0480E71026D939BF006CAE2F",
|
||||
"name" : "UITests-Mac"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
40
apple/Omnivore-iOS.xctestplan
Normal file
40
apple/Omnivore-iOS.xctestplan
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "B3384F57-C0C0-4F6C-AA64-BE1CE084907B",
|
||||
"name" : "Configuration 1",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"codeCoverage" : false,
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:Omnivore.xcodeproj",
|
||||
"identifier" : "8260E9C026EF983F8970EC05",
|
||||
"name" : "Omnivore-iOS"
|
||||
}
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"parallelizable" : true,
|
||||
"target" : {
|
||||
"containerPath" : "container:Omnivore.xcodeproj",
|
||||
"identifier" : "B46CFFB2A582C337CB0AAAEF",
|
||||
"name" : "OmnivoreTests-iOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skippedTests" : [
|
||||
"IosLaunchTest\/testLaunchPerformance()"
|
||||
],
|
||||
"target" : {
|
||||
"containerPath" : "container:Omnivore.xcodeproj",
|
||||
"identifier" : "042F48D826DFD10E00BF98FC",
|
||||
"name" : "UITests-iOS"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
2032
apple/Omnivore.xcodeproj/project.pbxproj
Normal file
2032
apple/Omnivore.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load diff
7
apple/Omnivore.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
apple/Omnivore.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>classNames</key>
|
||||
<dict>
|
||||
<key>IosLaunchTest</key>
|
||||
<dict>
|
||||
<key>testLaunchPerformance()</key>
|
||||
<dict>
|
||||
<key>com.apple.dt.XCTMetric_ApplicationLaunch-AppLaunch.duration</key>
|
||||
<dict>
|
||||
<key>baselineAverage</key>
|
||||
<real>3.7507</real>
|
||||
<key>baselineIntegrationDisplayName</key>
|
||||
<string>Local Baseline</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>runDestinationsByUUID</key>
|
||||
<dict>
|
||||
<key>54965375-310F-4852-A475-362908352C47</key>
|
||||
<dict>
|
||||
<key>localComputer</key>
|
||||
<dict>
|
||||
<key>busSpeedInMHz</key>
|
||||
<integer>0</integer>
|
||||
<key>cpuCount</key>
|
||||
<integer>1</integer>
|
||||
<key>cpuKind</key>
|
||||
<string>Apple M1</string>
|
||||
<key>cpuSpeedInMHz</key>
|
||||
<integer>0</integer>
|
||||
<key>logicalCPUCoresPerPackage</key>
|
||||
<integer>8</integer>
|
||||
<key>modelCode</key>
|
||||
<string>MacBookAir10,1</string>
|
||||
<key>physicalCPUCoresPerPackage</key>
|
||||
<integer>8</integer>
|
||||
<key>platformIdentifier</key>
|
||||
<string>com.apple.platform.macosx</string>
|
||||
</dict>
|
||||
<key>targetArchitecture</key>
|
||||
<string>arm64</string>
|
||||
<key>targetDevice</key>
|
||||
<dict>
|
||||
<key>modelCode</key>
|
||||
<string>iPhone13,1</string>
|
||||
<key>platformIdentifier</key>
|
||||
<string>com.apple.platform.iphonesimulator</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>classNames</key>
|
||||
<dict>
|
||||
<key>MacUILaunchTest</key>
|
||||
<dict>
|
||||
<key>testLaunchPerformance()</key>
|
||||
<dict>
|
||||
<key>com.apple.dt.XCTMetric_ApplicationLaunch-ApplicationLaunch.duration</key>
|
||||
<dict>
|
||||
<key>baselineAverage</key>
|
||||
<real>2</real>
|
||||
<key>baselineIntegrationDisplayName</key>
|
||||
<string>Local Baseline</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>runDestinationsByUUID</key>
|
||||
<dict>
|
||||
<key>B88E4913-1A28-4133-9F9E-A4B706684B27</key>
|
||||
<dict>
|
||||
<key>localComputer</key>
|
||||
<dict>
|
||||
<key>busSpeedInMHz</key>
|
||||
<integer>0</integer>
|
||||
<key>cpuCount</key>
|
||||
<integer>1</integer>
|
||||
<key>cpuKind</key>
|
||||
<string>Apple M1</string>
|
||||
<key>cpuSpeedInMHz</key>
|
||||
<integer>0</integer>
|
||||
<key>logicalCPUCoresPerPackage</key>
|
||||
<integer>8</integer>
|
||||
<key>modelCode</key>
|
||||
<string>MacBookAir10,1</string>
|
||||
<key>physicalCPUCoresPerPackage</key>
|
||||
<integer>8</integer>
|
||||
<key>platformIdentifier</key>
|
||||
<string>com.apple.platform.macosx</string>
|
||||
</dict>
|
||||
<key>targetArchitecture</key>
|
||||
<string>arm64</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1300"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:Omnivore-Mac.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0411792626A22860004AE24F"
|
||||
BuildableName = "UnitTests-MacApp.xctest"
|
||||
BlueprintName = "UnitTests-MacApp"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0480E71026D939BF006CAE2F"
|
||||
BuildableName = "UITests-Mac.xctest"
|
||||
BlueprintName = "UITests-Mac"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1300"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:Omnivore-iOS.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B46CFFB2A582C337CB0AAAEF"
|
||||
BuildableName = "OmnivoreTests.xctest"
|
||||
BlueprintName = "OmnivoreTests-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "042F48D826DFD10E00BF98FC"
|
||||
BuildableName = "UITests-iOS.xctest"
|
||||
BlueprintName = "UITests-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
apple/Omnivore.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
apple/Omnivore.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Omnivore.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
205
apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
205
apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "abseil",
|
||||
"repositoryURL": "https://github.com/firebase/abseil-cpp-SwiftPM.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "fffc3c2729be5747390ad02d5100291a0d9ad26a",
|
||||
"version": "0.20200225.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "AppAuth",
|
||||
"repositoryURL": "https://github.com/openid/AppAuth-iOS.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "01131d68346c8ae552961c768d583c715fbe1410",
|
||||
"version": "1.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "BoringSSL-GRPC",
|
||||
"repositoryURL": "https://github.com/firebase/boringssl-SwiftPM.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "734a8247442fde37df4364c21f6a0085b6a36728",
|
||||
"version": "0.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Files",
|
||||
"repositoryURL": "https://github.com/JohnSundell/Files",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "d273b5b7025d386feef79ef6bad7de762e106eaf",
|
||||
"version": "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Firebase",
|
||||
"repositoryURL": "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "08686f04881483d2bc098b2696e674c0ba135e47",
|
||||
"version": "8.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "GoogleAppMeasurement",
|
||||
"repositoryURL": "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "9b2f6aca5b4685c45f9f5481f19bee8e7982c538",
|
||||
"version": "8.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "GoogleDataTransport",
|
||||
"repositoryURL": "https://github.com/google/GoogleDataTransport.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "15ccdfd25ac55b9239b82809531ff26605e7556e",
|
||||
"version": "9.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "GoogleUtilities",
|
||||
"repositoryURL": "https://github.com/google/GoogleUtilities.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "b3bb0c5551fb3f80ca939829639ab5b093edd14f",
|
||||
"version": "7.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "gRPC",
|
||||
"repositoryURL": "https://github.com/firebase/grpc-SwiftPM.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "fb405dd2c7901485f7e158b24e3a0a47e4efd8b5",
|
||||
"version": "1.28.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "GTMSessionFetcher",
|
||||
"repositoryURL": "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "bc6a19702ac76ac4e488b68148710eb815f9bc56",
|
||||
"version": "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Intercom",
|
||||
"repositoryURL": "https://github.com/intercom/intercom-ios",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "c782c99bf164cc38210dc98a926b67b8b61d8195",
|
||||
"version": "10.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "leveldb",
|
||||
"repositoryURL": "https://github.com/firebase/leveldb.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
||||
"version": "1.22.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "nanopb",
|
||||
"repositoryURL": "https://github.com/firebase/nanopb.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "7ee9ef9f627d85cbe1b8c4f49a3ed26eed216c77",
|
||||
"version": "2.30908.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Promises",
|
||||
"repositoryURL": "https://github.com/google/promises.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "611337c330350c9c1823ad6d671e7f936af5ee13",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "PSPDFKit",
|
||||
"repositoryURL": "https://github.com/PSPDFKit/PSPDFKit-SP",
|
||||
"state": {
|
||||
"branch": "master",
|
||||
"revision": "344c895fea62eb42c6b56e6a060d5dff73cc5bc2",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-argument-parser",
|
||||
"repositoryURL": "https://github.com/apple/swift-argument-parser",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "e1465042f195f374b94f915ba8ca49de24300a0d",
|
||||
"version": "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-graphql",
|
||||
"repositoryURL": "https://github.com/maticzav/swift-graphql",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "716973b9af4fcce8cb5dd6ac5fda80aff1458b84",
|
||||
"version": "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftProtobuf",
|
||||
"repositoryURL": "https://github.com/apple/swift-protobuf.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "7e2c5f3cbbeea68e004915e3a8961e20bd11d824",
|
||||
"version": "1.18.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftFormat",
|
||||
"repositoryURL": "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "872e7034f54aeee3f20acf790ecc13e1383f7360",
|
||||
"version": "0.48.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Introspect",
|
||||
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
|
||||
"version": "0.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Valet",
|
||||
"repositoryURL": "https://github.com/Square/Valet",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2bf3329055f5d71d42a12801dd69d1d770fafa5e",
|
||||
"version": "4.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Yams",
|
||||
"repositoryURL": "https://github.com/jpsim/Yams.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa",
|
||||
"version": "4.0.6"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
5
apple/OmnivoreKit/.gitignore
vendored
Normal file
5
apple/OmnivoreKit/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
53
apple/OmnivoreKit/Package.swift
Normal file
53
apple/OmnivoreKit/Package.swift
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// swift-tools-version:5.5
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OmnivoreKit",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v14),
|
||||
.macOS(.v11)
|
||||
],
|
||||
products: [
|
||||
.library(name: "Binders", targets: ["Binders"]),
|
||||
.library(name: "Views", targets: ["Views"]),
|
||||
.library(name: "Services", targets: ["Services"]),
|
||||
.library(name: "Models", targets: ["Models"]),
|
||||
.library(name: "Utils", targets: ["Utils"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "AppAuth", 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.0"),
|
||||
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3")
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Binders", dependencies: ["Views", "Services", "Models", "Utils"]),
|
||||
.testTarget(name: "BindersTests", dependencies: ["Binders"]),
|
||||
.target(
|
||||
name: "Views",
|
||||
dependencies: [
|
||||
"Models",
|
||||
.product(name: "Introspect", package: "SwiftUI-Introspect")
|
||||
],
|
||||
resources: [.process("Resources")]
|
||||
),
|
||||
.testTarget(name: "ViewsTests", dependencies: ["Views"]),
|
||||
.target(
|
||||
name: "Services",
|
||||
dependencies: [
|
||||
"AppAuth",
|
||||
"Valet",
|
||||
.product(name: "SwiftGraphQL", package: "swift-graphql"),
|
||||
"Models",
|
||||
"Utils"
|
||||
]
|
||||
),
|
||||
.testTarget(name: "ServicesTests", dependencies: ["Services"]),
|
||||
.target(name: "Models", dependencies: ["Utils"]),
|
||||
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
|
||||
.target(name: "Utils", dependencies: [], resources: [.process("Resources")]),
|
||||
.testTarget(name: "UtilsTests", dependencies: ["Utils"])
|
||||
]
|
||||
)
|
||||
1
apple/OmnivoreKit/README.md
Normal file
1
apple/OmnivoreKit/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# OmnivoreKit
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public extension PlatformViewController {
|
||||
static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController {
|
||||
let viewModel = ShareExtensionViewModel.make(extensionContext: extensionContext)
|
||||
let rootView = ShareExtensionView(viewModel: viewModel)
|
||||
let hostingController = PlatformHostingController(rootView: rootView)
|
||||
#if os(iOS)
|
||||
hostingController.view.layer.cornerRadius = 12
|
||||
hostingController.view.layer.masksToBounds = true
|
||||
hostingController.view.layer.isOpaque = false
|
||||
#endif
|
||||
return hostingController
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareExtensionViewModel {
|
||||
static func make(extensionContext: NSExtensionContext?) -> ShareExtensionViewModel {
|
||||
let viewModel = ShareExtensionViewModel()
|
||||
viewModel.bind(extensionContext: extensionContext)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(extensionContext: NSExtensionContext?) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .savePage(requestID):
|
||||
self?.savePage(extensionContext: extensionContext, requestId: requestID)
|
||||
case let .dismissButtonTapped(reminderTime, hideUntilReminded):
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
//
|
||||
// guard let reminderTime = reminderTime, let self = self else {
|
||||
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let services = Services()
|
||||
//
|
||||
// services.dataService.createReminderPublisher(
|
||||
// hideUntilReminded: hideUntilReminded,
|
||||
// reminderTime: reminderTime,
|
||||
// reminderItemId: .clientRequest(id: self.requestID)
|
||||
// )
|
||||
// .sink { _ in
|
||||
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
// } receiveValue: { _ in
|
||||
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
// }
|
||||
// .store(in: &self.subscriptions)
|
||||
//
|
||||
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
case .copyLinkButtonTapped:
|
||||
print("copy link button tapped")
|
||||
case .readNowButtonTapped:
|
||||
#if os(iOS)
|
||||
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
|
||||
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(self?.requestID ?? "")")
|
||||
application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
|
||||
}
|
||||
#endif
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
// TODO: implement macos
|
||||
case .archiveButtonTapped:
|
||||
print("archive button tapped")
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func savePage(extensionContext: NSExtensionContext?, requestId: String) {
|
||||
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
self?.persist(pageScrapePayload: payload, requestId: requestId)
|
||||
case let .failure(error):
|
||||
self?.debugText = error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func persist(pageScrapePayload: PageScrapePayload, requestId: String) {
|
||||
let services = Services()
|
||||
|
||||
guard services.authenticator.hasValidAuthToken else {
|
||||
status = .failed(error: .unauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
let saveLinkPublisher: AnyPublisher<Void, SaveArticleError> = {
|
||||
if pageScrapePayload.contentType == .pdf {
|
||||
return services.dataService.uploadPDFPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
|
||||
} else if pageScrapePayload.html != nil {
|
||||
return services.dataService.savePagePublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
|
||||
} else {
|
||||
return services.dataService.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
|
||||
}
|
||||
}()
|
||||
|
||||
saveLinkPublisher
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.debugText = "saveArticleError: \(error)"
|
||||
self?.status = .failed(error: error)
|
||||
} receiveValue: { [weak self] _ in
|
||||
self?.status = .successfullySaved
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Using viewerPublisher to get fast feedback for auth/network errors
|
||||
services.dataService.viewerPublisher()
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.debugText = "saveArticleError: \(error)"
|
||||
self?.status = .failed(error: .unknown(description: ""))
|
||||
} receiveValue: { _ in }
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
|
||||
public final class PDFViewerViewModel: ObservableObject {
|
||||
@Published public var errorMessage: String?
|
||||
@Published public var readerView: Bool = false
|
||||
|
||||
public var feedItem: FeedItem
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
let services: Services
|
||||
|
||||
public init(services: Services, feedItem: FeedItem) {
|
||||
self.services = services
|
||||
self.feedItem = feedItem
|
||||
}
|
||||
|
||||
public func allHighlights() -> [Highlight] {
|
||||
var resultSet = [String: Highlight]()
|
||||
|
||||
for highlight in services.dataService.cachedHighlights(pdfID: feedItem.id) {
|
||||
resultSet[highlight.id] = highlight
|
||||
}
|
||||
for highlight in feedItem.highlights ?? [] {
|
||||
resultSet[highlight.id] = highlight
|
||||
}
|
||||
for highlightId in services.dataService.fetchRemovedHighlightIds(pdfID: feedItem.id) {
|
||||
resultSet.removeValue(forKey: highlightId)
|
||||
}
|
||||
return Array(resultSet.values)
|
||||
}
|
||||
|
||||
public func createHighlight(shortId: String, highlightID: String, quote: String, patch: String) {
|
||||
services.dataService.persistHighlight(
|
||||
pdfID: feedItem.id,
|
||||
highlight: Highlight(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil
|
||||
)
|
||||
)
|
||||
|
||||
services.dataService
|
||||
.createHighlightPublisher(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: feedItem.id
|
||||
)
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.errorMessage = error.localizedDescription
|
||||
} receiveValue: { value in
|
||||
print("highlight value", value)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
public func mergeHighlight(
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
quote: String,
|
||||
patch: String,
|
||||
overlapHighlightIdList: [String]
|
||||
) {
|
||||
services.dataService.persistHighlight(
|
||||
pdfID: feedItem.id,
|
||||
highlight: Highlight(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil
|
||||
)
|
||||
)
|
||||
|
||||
removeLocalHighlights(highlightIds: overlapHighlightIdList)
|
||||
|
||||
services.dataService
|
||||
.mergeHighlightPublisher(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: feedItem.id,
|
||||
overlapHighlightIdList: overlapHighlightIdList
|
||||
)
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.errorMessage = error.localizedDescription
|
||||
} receiveValue: { value in
|
||||
print("highlight value", value)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
public func removeHighlights(highlightIds: [String]) {
|
||||
removeLocalHighlights(highlightIds: highlightIds)
|
||||
|
||||
highlightIds.forEach { highlightId in
|
||||
services.dataService.deleteHighlightPublisher(highlightId: highlightId)
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.errorMessage = error.localizedDescription
|
||||
} receiveValue: { value in
|
||||
print("remove highlight value", value)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateItemReadProgress(percent: Double, anchorIndex: Int) {
|
||||
services.dataService
|
||||
.updateArticleReadingProgressPublisher(
|
||||
itemID: feedItem.id,
|
||||
readingProgress: percent,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
.sink { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
} receiveValue: { _ in
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
public func highlightShareURL(shortId: String) -> URL? {
|
||||
let baseURL = services.dataService.appEnvironment.serverBaseURL
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||
|
||||
if let viewer = services.dataService.currentViewer?.username {
|
||||
components?.path = "/\(viewer)/\(feedItem.slug)/highlights/\(shortId)"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return components?.url
|
||||
}
|
||||
|
||||
private func removeLocalHighlights(highlightIds: [String]) {
|
||||
feedItem.highlights?.removeAll { highlightIds.contains($0.id) }
|
||||
services.dataService.removeHighlights(pdfID: feedItem.id, highlightIds: highlightIds)
|
||||
}
|
||||
}
|
||||
279
apple/OmnivoreKit/Sources/Binders/RootViewModel.swift
Normal file
279
apple/OmnivoreKit/Sources/Binders/RootViewModel.swift
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
#if os(iOS)
|
||||
let isMacApp = false
|
||||
#elseif os(macOS)
|
||||
let isMacApp = true
|
||||
#endif
|
||||
|
||||
public final class RootViewModel: ObservableObject {
|
||||
let services = Services()
|
||||
|
||||
@Published public var showPushNotificationPrimer = false
|
||||
@Published fileprivate var webLinkPath: SafariWebLinkPath?
|
||||
@Published fileprivate var snackbarMessage: String?
|
||||
@Published fileprivate var showSnackbar = false
|
||||
|
||||
public enum Action {}
|
||||
|
||||
public var subscriptions = Set<AnyCancellable>()
|
||||
public let performActionSubject = PassthroughSubject<Action, Never>()
|
||||
|
||||
public init() {
|
||||
registerFonts()
|
||||
}
|
||||
|
||||
func updateWaitlistStatus() {
|
||||
services.dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
},
|
||||
receiveValue: { [weak self] viewer in
|
||||
guard let self = self else { return }
|
||||
let isWaitlisted = viewer.isWaitlisted
|
||||
self.services.authenticator.updateWaitlistStatus(isWaitlistedUser: isWaitlisted)
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) {
|
||||
PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in
|
||||
guard let self = self else { return AnyView(Text("")) }
|
||||
return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem))
|
||||
}
|
||||
}
|
||||
|
||||
func webAppWrapperViewModel(webLinkPath: String) -> WebAppWrapperViewModel {
|
||||
let baseURL = services.dataService.appEnvironment.webAppBaseURL
|
||||
|
||||
let urlRequest = URLRequest.webRequest(
|
||||
baseURL: services.dataService.appEnvironment.webAppBaseURL,
|
||||
urlPath: webLinkPath,
|
||||
queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"]
|
||||
)
|
||||
|
||||
return WebAppWrapperViewModel(
|
||||
webViewURLRequest: urlRequest,
|
||||
baseURL: baseURL,
|
||||
rawAuthCookie: services.authenticator.omnivoreAuthCookieString
|
||||
)
|
||||
}
|
||||
|
||||
func onOpenURL(url: URL) {
|
||||
guard let linkRequestID = DeepLink.make(from: url)?.linkRequestID else { return }
|
||||
|
||||
if let username = services.dataService.currentViewer?.username {
|
||||
let path = linkRequestPath(username: username, requestID: linkRequestID)
|
||||
webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
|
||||
return
|
||||
}
|
||||
|
||||
services.dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
},
|
||||
receiveValue: { [weak self] viewer in
|
||||
let path = self?.linkRequestPath(username: viewer.username, requestID: linkRequestID) ?? ""
|
||||
self?.webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func triggerPushNotificationRequestIfNeeded() {
|
||||
guard FeatureFlag.enablePushNotifications else { return }
|
||||
|
||||
if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .notDetermined:
|
||||
DispatchQueue.main.async {
|
||||
self?.showPushNotificationPrimer = true
|
||||
}
|
||||
case .authorized, .provisional, .ephemeral, .denied:
|
||||
return
|
||||
@unknown default:
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func handlePushNotificationPrimerAcceptance() {
|
||||
showPushNotificationPrimer = false
|
||||
UNUserNotificationCenter.current().requestAuth()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func linkRequestPath(username: String, requestID: String) -> String {
|
||||
"/app/\(username)/link-request/\(requestID)"
|
||||
}
|
||||
}
|
||||
|
||||
private struct SafariWebLinkPath: Identifiable {
|
||||
let id: UUID
|
||||
let path: String
|
||||
}
|
||||
|
||||
public struct RootView: View {
|
||||
@ObservedObject private var viewModel: RootViewModel
|
||||
@ObservedObject private var authenticator: Authenticator
|
||||
|
||||
private var primaryViewModel: PrimaryContentViewModel
|
||||
|
||||
public init(
|
||||
pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?,
|
||||
intercomProvider: IntercomProvider?
|
||||
) {
|
||||
let rootViewModel = RootViewModel()
|
||||
self.viewModel = rootViewModel
|
||||
self.authenticator = rootViewModel.services.authenticator
|
||||
self.primaryViewModel = PrimaryContentViewModel.make(services: rootViewModel.services)
|
||||
|
||||
#if DEBUG
|
||||
if CommandLine.arguments.contains("--uitesting") {
|
||||
authenticator.logout()
|
||||
}
|
||||
#endif
|
||||
|
||||
if let pdfViewerProvider = pdfViewerProvider {
|
||||
viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider)
|
||||
}
|
||||
|
||||
if let intercomProvider = intercomProvider {
|
||||
DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger
|
||||
DataService.registerIntercomUser = intercomProvider.registerIntercomUser
|
||||
Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var innerBody: some View {
|
||||
if authenticator.isLoggedIn {
|
||||
if authenticator.isWaitlisted {
|
||||
WaitlistView(viewModel: WaitlistViewModel.make(services: viewModel.services))
|
||||
} else {
|
||||
PrimaryContentView(viewModel: primaryViewModel)
|
||||
.onAppear {
|
||||
viewModel.updateWaitlistStatus()
|
||||
viewModel.triggerPushNotificationRequestIfNeeded()
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(item: $viewModel.webLinkPath, content: { safariLinkPath in
|
||||
NavigationView {
|
||||
FullScreenWebAppView(
|
||||
viewModel: viewModel.webAppWrapperViewModel(webLinkPath: safariLinkPath.path),
|
||||
handleClose: { viewModel.webLinkPath = nil }
|
||||
)
|
||||
}
|
||||
})
|
||||
#endif
|
||||
.snackBar(
|
||||
isShowing: $viewModel.showSnackbar,
|
||||
text: Text(viewModel.snackbarMessage ?? "")
|
||||
)
|
||||
#if os(iOS)
|
||||
.customAlert(isPresented: $viewModel.showPushNotificationPrimer) {
|
||||
pushNotificationPrimerView
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
WelcomeView(viewModel: WelcomeViewModel.make(services: viewModel.services))
|
||||
.accessibilityElement()
|
||||
.accessibilityIdentifier("welcomeView")
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
innerBody
|
||||
#elseif os(macOS)
|
||||
innerBody
|
||||
.frame(minWidth: 400, idealWidth: 1200, minHeight: 400, idealHeight: 1200)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.onOpenURL { url in
|
||||
withoutAnimation {
|
||||
if viewModel.webLinkPath != nil {
|
||||
viewModel.webLinkPath = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
viewModel.onOpenURL(url: url)
|
||||
}
|
||||
} else {
|
||||
viewModel.onOpenURL(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NSNotification.operationSuccessPublisher) { notification in
|
||||
if let message = notification.userInfo?["message"] as? String {
|
||||
viewModel.showSnackbar = true
|
||||
viewModel.snackbarMessage = message
|
||||
}
|
||||
}
|
||||
.onReceive(NSNotification.operationFailedPublisher) { notification in
|
||||
if let message = notification.userInfo?["message"] as? String {
|
||||
viewModel.showSnackbar = true
|
||||
viewModel.snackbarMessage = message
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var pushNotificationPrimerView: PushNotificationPrimer {
|
||||
PushNotificationPrimer(
|
||||
acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() },
|
||||
denyAction: {
|
||||
UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue)
|
||||
viewModel.showPushNotificationPrimer = false
|
||||
}
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public struct IntercomProvider {
|
||||
public init(
|
||||
registerIntercomUser: @escaping (String) -> Void,
|
||||
unregisterIntercomUser: @escaping () -> Void,
|
||||
showIntercomMessenger: @escaping () -> Void
|
||||
) {
|
||||
self.registerIntercomUser = registerIntercomUser
|
||||
self.unregisterIntercomUser = unregisterIntercomUser
|
||||
self.showIntercomMessenger = showIntercomMessenger
|
||||
}
|
||||
|
||||
public let registerIntercomUser: (String) -> Void
|
||||
public let unregisterIntercomUser: () -> Void
|
||||
public let showIntercomMessenger: () -> Void
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// Allows us to present a sheet without animation
|
||||
// Used to configure full screen modal view coming from share extension read now button action
|
||||
private extension View {
|
||||
func withoutAnimation(_ completion: @escaping () -> Void) {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
completion()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
|
||||
UIView.setAnimationsEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension CreateProfileViewModel {
|
||||
static func make(services: Services, pendingUserProfile: UserProfile) -> CreateProfileViewModel {
|
||||
let viewModel = CreateProfileViewModel(initialUserProfile: pendingUserProfile)
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .submitProfile(userProfile):
|
||||
self?.submitProfile(userProfile: userProfile, authenticator: services.authenticator)
|
||||
case let .validateUsername(username: username):
|
||||
self?.validateUsername(username: username, dataService: services.dataService)
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func validateUsername(username: String, dataService: DataService) {
|
||||
if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) {
|
||||
potentialUsernameStatus = status
|
||||
return
|
||||
}
|
||||
|
||||
dataService.validateUsernamePublisher(username: username).sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case let .failure(usernameError) = completion else { return }
|
||||
switch usernameError {
|
||||
case .tooShort:
|
||||
self?.potentialUsernameStatus = .tooShort
|
||||
case .tooLong:
|
||||
self?.potentialUsernameStatus = .tooLong
|
||||
case .invalidPattern:
|
||||
self?.potentialUsernameStatus = .invalidPattern
|
||||
case .nameUnavailable:
|
||||
self?.potentialUsernameStatus = .unavailable
|
||||
case .internalServer, .unknown:
|
||||
self?.loginError = .unknown
|
||||
case .network:
|
||||
self?.loginError = .network
|
||||
}
|
||||
},
|
||||
receiveValue: { [weak self] in
|
||||
self?.potentialUsernameStatus = .available
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
extension DebugMenuViewModel {
|
||||
static func make(services: Services) -> DebugMenuViewModel {
|
||||
let viewModel = DebugMenuViewModel(initialEnvironment: services.dataService.appEnvironment)
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { action in
|
||||
switch action {
|
||||
case let .applyChanges(environment):
|
||||
switch environment {
|
||||
case .dev:
|
||||
services.switchAppEnvironment(to: .dev)
|
||||
case .demo:
|
||||
services.switchAppEnvironment(to: .demo)
|
||||
case .prod:
|
||||
services.switchAppEnvironment(to: .prod)
|
||||
case .local:
|
||||
services.switchAppEnvironment(to: .local)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
191
apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift
Normal file
191
apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension HomeFeedViewModel {
|
||||
static func make(services: Services) -> HomeFeedViewModel {
|
||||
let viewModel = HomeFeedViewModel { feedItem in
|
||||
LinkItemDetailViewModel.make(feedItem: feedItem, services: services)
|
||||
}
|
||||
|
||||
viewModel.bind(services: services)
|
||||
viewModel.loadItems(dataService: services.dataService, searchQuery: nil)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .loadItems(query):
|
||||
self?.loadItems(dataService: services.dataService, searchQuery: query)
|
||||
case let .archive(linkId):
|
||||
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true)
|
||||
case let .unarchive(linkId):
|
||||
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false)
|
||||
case let .remove(linkId):
|
||||
self?.removeLink(dataService: services.dataService, linkId: linkId)
|
||||
case let .snooze(linkId, until, successMessage):
|
||||
self?.snoozeUntil(
|
||||
dataService: services.dataService,
|
||||
linkId: linkId,
|
||||
until: until,
|
||||
successMessage: successMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func loadItems(dataService: DataService, searchQuery: String?) {
|
||||
// Clear offline highlights since we'll be populating new FeedItems with the correct highlights set
|
||||
dataService.clearHighlights()
|
||||
|
||||
let thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
|
||||
isLoading = true
|
||||
startNetworkActivityIndicator()
|
||||
|
||||
// Cache the viewer
|
||||
if dataService.currentViewer == nil {
|
||||
dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in }
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
dataService.libraryItemsPublisher(
|
||||
limit: 100,
|
||||
sortDescending: true,
|
||||
searchQuery: searchQuery,
|
||||
cursor: nil
|
||||
)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
print(error)
|
||||
},
|
||||
receiveValue: { [weak self] result in
|
||||
// Search results aren't guaranteed to return in order so this
|
||||
// will discard old results that are returned while a user is typing.
|
||||
// For example if a user types 'Canucks', often the search results
|
||||
// for 'C' are returned after 'Canucks' because it takes the backend
|
||||
// much longer to compute.
|
||||
if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 {
|
||||
return
|
||||
}
|
||||
self?.items = result.items
|
||||
self?.isLoading = false
|
||||
self?.receivedIdx = thisSearchIdx
|
||||
stopNetworkActivityIndicator()
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) {
|
||||
isLoading = true
|
||||
startNetworkActivityIndicator()
|
||||
|
||||
// First remove the link from the internal list,
|
||||
// then make a call to remove it. The isLoading block should
|
||||
// prevent our local change from being overwritten, but we
|
||||
// might need to cache a local list of archived links
|
||||
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
|
||||
items.remove(at: itemIndex)
|
||||
}
|
||||
|
||||
dataService.archiveLinkPublisher(itemID: linkId, archived: archived)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
print(error)
|
||||
NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox")
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func removeLink(dataService: DataService, linkId: String) {
|
||||
isLoading = true
|
||||
startNetworkActivityIndicator()
|
||||
|
||||
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
|
||||
items.remove(at: itemIndex)
|
||||
}
|
||||
|
||||
dataService.removeLinkPublisher(itemID: linkId)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
print(error)
|
||||
NSNotification.operationFailed(message: "Failed to remove link")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
NSNotification.operationSuccess(message: "Link removed")
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) {
|
||||
isLoading = true
|
||||
startNetworkActivityIndicator()
|
||||
|
||||
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
|
||||
items.remove(at: itemIndex)
|
||||
}
|
||||
|
||||
dataService.createReminderPublisher(
|
||||
reminderItemId: .link(id: linkId),
|
||||
remindAt: until
|
||||
)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
print(error)
|
||||
NSNotification.operationFailed(message: "Failed to snooze")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
if let message = successMessage {
|
||||
NSNotification.operationSuccess(message: message)
|
||||
}
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: replace these deprecated calls with a progress bar or custom spinner
|
||||
private func startNetworkActivityIndicator() {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.isNetworkActivityIndicatorVisible = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func stopNetworkActivityIndicator() {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.isNetworkActivityIndicatorVisible = false
|
||||
#endif
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension LinkItemDetailViewModel {
|
||||
static func make(feedItem: FeedItem, services: Services) -> LinkItemDetailViewModel {
|
||||
let viewModel = LinkItemDetailViewModel(item: feedItem)
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case .load:
|
||||
self?.loadWebAppWrapper(services: services)
|
||||
case let .updateReadStatus(markAsRead: markAsRead):
|
||||
self?.updateItemReadStatus(markAsRead: markAsRead, dataService: services.dataService)
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func updateItemReadStatus(markAsRead: Bool, dataService: DataService) {
|
||||
dataService
|
||||
.updateArticleReadingProgressPublisher(
|
||||
itemID: item.id,
|
||||
readingProgress: markAsRead ? 100 : 0,
|
||||
anchorIndex: 0
|
||||
)
|
||||
.sink { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
} receiveValue: { [weak self] feedItem in
|
||||
self?.item.readingProgress = feedItem.readingProgress
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func loadWebAppWrapper(services: Services) {
|
||||
// Attempt to get `Viewer` from DataService
|
||||
if let currentViewer = services.dataService.currentViewer {
|
||||
createWebAppWrapperViewModel(username: currentViewer.username, services: services)
|
||||
return
|
||||
}
|
||||
|
||||
services.dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
},
|
||||
receiveValue: { [weak self] viewer in
|
||||
self?.createWebAppWrapperViewModel(username: viewer.username, services: services)
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func createWebAppWrapperViewModel(username: String, services: Services) {
|
||||
let baseURL = services.dataService.appEnvironment.webAppBaseURL
|
||||
|
||||
let urlRequest = URLRequest.webRequest(
|
||||
baseURL: services.dataService.appEnvironment.webAppBaseURL,
|
||||
urlPath: "/app/\(username)/\(item.slug)",
|
||||
queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"]
|
||||
)
|
||||
|
||||
let newWebAppWrapperViewModel = WebAppWrapperViewModel(
|
||||
webViewURLRequest: urlRequest,
|
||||
baseURL: baseURL,
|
||||
rawAuthCookie: services.authenticator.omnivoreAuthCookieString
|
||||
)
|
||||
|
||||
newWebAppWrapperViewModel.performActionSubject.sink { action in
|
||||
switch action {
|
||||
case let .shareHighlight(highlightID):
|
||||
print("show share modal for highlight with id: \(highlightID)")
|
||||
}
|
||||
}
|
||||
.store(in: &newWebAppWrapperViewModel.subscriptions)
|
||||
|
||||
webAppWrapperViewModel = newWebAppWrapperViewModel
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension NewAppleSignupViewModel {
|
||||
static func make(
|
||||
services: Services,
|
||||
userProfile: UserProfile,
|
||||
showProfileEditView: @escaping () -> Void
|
||||
) -> NewAppleSignupViewModel {
|
||||
let viewModel = NewAppleSignupViewModel(userProfile: userProfile)
|
||||
viewModel.bind(services: services, showProfileEditView: showProfileEditView)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services, showProfileEditView: @escaping () -> Void) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .acceptProfile(userProfile: userProfile):
|
||||
self?.submitProfile(userProfile: userProfile, authenticator: services.authenticator)
|
||||
case .changeProfile:
|
||||
showProfileEditView()
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
extension PrimaryContentViewModel {
|
||||
static func make(services: Services) -> PrimaryContentViewModel {
|
||||
let viewModel = PrimaryContentViewModel(
|
||||
homeFeedViewModel: HomeFeedViewModel.make(services: services),
|
||||
profileContainerViewModel: ProfileContainerViewModel.make(services: services)
|
||||
)
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services _: Services) {
|
||||
performActionSubject.sink { action in
|
||||
switch action {
|
||||
case .nothing:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
extension ProfileContainerViewModel {
|
||||
static func make(services: Services) -> ProfileContainerViewModel {
|
||||
let viewModel = ProfileContainerViewModel()
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case .logout:
|
||||
services.authenticator.logout()
|
||||
case .loadProfileData:
|
||||
self?.loadProfileData(dataService: services.dataService)
|
||||
case .showIntercomMessenger:
|
||||
DataService.showIntercomMessenger?()
|
||||
case .deleteAccount:
|
||||
print("delete account")
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func loadProfileData(dataService: DataService) {
|
||||
dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { [weak self] viewer in
|
||||
self?.profileCardData = ProfileCardData(
|
||||
name: viewer.name,
|
||||
username: viewer.username,
|
||||
imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
116
apple/OmnivoreKit/Sources/Binders/Scenes/RegistrationScene.swift
Normal file
116
apple/OmnivoreKit/Sources/Binders/Scenes/RegistrationScene.swift
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import AuthenticationServices
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension RegistrationViewModel {
|
||||
static func make(services: Services) -> RegistrationViewModel {
|
||||
let viewModel = RegistrationViewModel()
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
self?.loginError = nil
|
||||
|
||||
switch action {
|
||||
case .googleButtonTapped:
|
||||
self?.handleGoogleAuth(services: services)
|
||||
case let .appleSignInCompleted(result: result):
|
||||
switch AppleSigninPayload.parse(authResult: result) {
|
||||
case let .success(payload):
|
||||
self?.handleAppleToken(payload: payload, services: services)
|
||||
case let .failure(error):
|
||||
switch error {
|
||||
case .unauthorized, .unknown:
|
||||
break
|
||||
case .network:
|
||||
self?.loginError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleAppleToken(payload: AppleSigninPayload, services: Services) {
|
||||
services.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(services: services, payload: payload)
|
||||
case .network:
|
||||
self?.loginError = loginError
|
||||
}
|
||||
},
|
||||
receiveValue: { _ in }
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleAppleSignUp(services: Services, payload: AppleSigninPayload) {
|
||||
services.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?.showProfileEditView(services: services, pendingUserProfile: userProfile)
|
||||
} else {
|
||||
self?.newAppleSignupViewModel = NewAppleSignupViewModel.make(
|
||||
services: services,
|
||||
userProfile: userProfile,
|
||||
showProfileEditView: {
|
||||
self?.showProfileEditView(services: services, pendingUserProfile: userProfile)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleGoogleAuth(services: Services) {
|
||||
services.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 {
|
||||
let pendingUserProfile = UserProfile(username: "", name: "")
|
||||
self?.showProfileEditView(services: services, pendingUserProfile: pendingUserProfile)
|
||||
}
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func showProfileEditView(services: Services, pendingUserProfile: UserProfile) {
|
||||
createProfileViewModel = CreateProfileViewModel.make(
|
||||
services: services,
|
||||
pendingUserProfile: pendingUserProfile
|
||||
)
|
||||
newAppleSignupViewModel = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func presentingViewController() -> PlatformViewController? {
|
||||
#if os(iOS)
|
||||
return UIApplication.shared.windows
|
||||
.filter(\.isKeyWindow)
|
||||
.first?
|
||||
.rootViewController
|
||||
#elseif os(macOS)
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
39
apple/OmnivoreKit/Sources/Binders/Scenes/WaitlistScene.swift
Normal file
39
apple/OmnivoreKit/Sources/Binders/Scenes/WaitlistScene.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension WaitlistViewModel {
|
||||
static func make(services: Services) -> WaitlistViewModel {
|
||||
let viewModel = WaitlistViewModel()
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case .logout:
|
||||
services.authenticator.logout()
|
||||
case .checkStatus:
|
||||
self?.updateWaitlistStatus(services: services)
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func updateWaitlistStatus(services: Services) {
|
||||
services.dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
},
|
||||
receiveValue: { viewer in
|
||||
let isWaitlisted = viewer.isWaitlisted
|
||||
services.authenticator.updateWaitlistStatus(isWaitlistedUser: isWaitlisted)
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
26
apple/OmnivoreKit/Sources/Binders/Scenes/WelcomeScene.swift
Normal file
26
apple/OmnivoreKit/Sources/Binders/Scenes/WelcomeScene.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension WelcomeViewModel {
|
||||
static func make(services: Services) -> WelcomeViewModel {
|
||||
let registrationViewModel = RegistrationViewModel.make(services: services)
|
||||
let viewModel = WelcomeViewModel(registrationViewModel: registrationViewModel)
|
||||
viewModel.bind(services: services)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case .hiddenGesturePerformed:
|
||||
if !Bundle.main.isAppStoreBuild {
|
||||
self?.debugMenuViewModel = DebugMenuViewModel.make(services: services)
|
||||
self?.showDebugModal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
19
apple/OmnivoreKit/Sources/Binders/Services.swift
Normal file
19
apple/OmnivoreKit/Sources/Binders/Services.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
|
||||
public final class Services {
|
||||
public let authenticator: Authenticator
|
||||
public let dataService: DataService
|
||||
|
||||
public init(appEnvironment: AppEnvironment = PublicValet.storedAppEnvironment ?? .initialAppEnvironment) {
|
||||
let networker = Networker(appEnvironment: appEnvironment)
|
||||
self.authenticator = Authenticator(networker: networker)
|
||||
self.dataService = DataService(appEnvironment: appEnvironment, networker: networker)
|
||||
}
|
||||
|
||||
public func switchAppEnvironment(to appEnvironment: AppEnvironment) {
|
||||
authenticator.logout()
|
||||
dataService.switchAppEnvironment(appEnvironment: appEnvironment)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
public extension UIViewController {
|
||||
func embed(childViewController child: UIViewController, heightRatio: CGFloat? = nil) {
|
||||
addChild(child)
|
||||
|
||||
child.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(child.view)
|
||||
|
||||
var constraints = [
|
||||
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
]
|
||||
|
||||
if let heightRatio = heightRatio {
|
||||
constraints.append(child.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: heightRatio))
|
||||
} else {
|
||||
constraints.append(child.view.topAnchor.constraint(equalTo: view.topAnchor))
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate(constraints)
|
||||
|
||||
child.didMove(toParent: self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSViewController {
|
||||
func embed(childViewController child: NSViewController) {
|
||||
addChild(child)
|
||||
|
||||
child.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(child.view)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
child.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
62
apple/OmnivoreKit/Sources/Models/AppEnvironment.swift
Normal file
62
apple/OmnivoreKit/Sources/Models/AppEnvironment.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Foundation
|
||||
import Utils
|
||||
|
||||
public enum AppEnvironment: String {
|
||||
case local
|
||||
case dev
|
||||
case prod
|
||||
case demo
|
||||
case test
|
||||
|
||||
public static let initialAppEnvironment: AppEnvironment = {
|
||||
#if DEBUG
|
||||
#if targetEnvironment(simulator)
|
||||
return .demo // could also return .local here
|
||||
#else
|
||||
return .demo
|
||||
#endif
|
||||
#else
|
||||
return .prod
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
||||
private let devBaseURL = "https://api-dev.omnivore.app"
|
||||
private let demoBaseURL = "https://api-demo.omnivore.app"
|
||||
private let prodBaseURL = "https://api-prod.omnivore.app"
|
||||
|
||||
private let devWebURL = "https://web-dev.omnivore.app"
|
||||
private let demoWebURL = "https://demo.omnivore.app"
|
||||
private let prodWebURL = "https://web-prod.omnivore.app"
|
||||
|
||||
public extension AppEnvironment {
|
||||
var graphqlPath: String {
|
||||
"\(serverBaseURL.absoluteString)/api/graphql"
|
||||
}
|
||||
|
||||
var serverBaseURL: URL {
|
||||
switch self {
|
||||
case .dev:
|
||||
return URL(string: devBaseURL)!
|
||||
case .demo:
|
||||
return URL(string: demoBaseURL)!
|
||||
case .prod:
|
||||
return URL(string: prodBaseURL)!
|
||||
case .test, .local:
|
||||
return URL(string: "http://localhost:4000")!
|
||||
}
|
||||
}
|
||||
|
||||
var webAppBaseURL: URL {
|
||||
switch self {
|
||||
case .dev:
|
||||
return URL(string: devWebURL)!
|
||||
case .demo:
|
||||
return URL(string: demoWebURL)!
|
||||
case .prod:
|
||||
return URL(string: prodWebURL)!
|
||||
case .test, .local:
|
||||
return URL(string: "http://localhost:3000")!
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apple/OmnivoreKit/Sources/Models/AppleSigninPayload.swift
Normal file
48
apple/OmnivoreKit/Sources/Models/AppleSigninPayload.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import AuthenticationServices
|
||||
import Foundation
|
||||
|
||||
public struct AppleSigninPayload {
|
||||
public let token: String
|
||||
public let email: String?
|
||||
public let appleUserId: String
|
||||
public let fullName: PersonNameComponents?
|
||||
|
||||
static func make(from authValue: ASAuthorization) -> AppleSigninPayload? {
|
||||
let credential = authValue.credential as? ASAuthorizationAppleIDCredential
|
||||
let jwt = credential?.identityToken.flatMap { String(data: $0, encoding: .utf8) }
|
||||
guard let jwt = jwt, let userId = credential?.user else { return nil }
|
||||
|
||||
return AppleSigninPayload(
|
||||
token: jwt,
|
||||
email: credential?.email,
|
||||
appleUserId: userId,
|
||||
fullName: credential?.fullName
|
||||
)
|
||||
}
|
||||
|
||||
public static func parse(authResult: Result<ASAuthorization, Error>) -> Result<AppleSigninPayload, LoginError> {
|
||||
switch authResult {
|
||||
case let .success(authValue):
|
||||
if let payload = AppleSigninPayload.make(from: authValue) {
|
||||
return Result.success(payload)
|
||||
} else {
|
||||
return Result.failure(.unknown)
|
||||
}
|
||||
case let .failure(error):
|
||||
return Result.failure(LoginError.make(from: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension LoginError {
|
||||
static func make(from authError: Error) -> LoginError {
|
||||
guard let error = authError as? ASAuthorizationError else { return .unknown }
|
||||
|
||||
switch error.code {
|
||||
case .invalidResponse, .failed:
|
||||
return .unauthorized
|
||||
default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
35
apple/OmnivoreKit/Sources/Models/DeepLinkDecoder.swift
Normal file
35
apple/OmnivoreKit/Sources/Models/DeepLinkDecoder.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import Foundation
|
||||
|
||||
public enum DeepLink {
|
||||
case webAppLinkRequest(requestID: String)
|
||||
}
|
||||
|
||||
public extension DeepLink {
|
||||
var linkRequestID: String? {
|
||||
if case let DeepLink.webAppLinkRequest(requestID) = self {
|
||||
return requestID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func make(from url: URL) -> DeepLink? {
|
||||
if url.scheme == "omnivore" {
|
||||
return deepLinkFromOmnivoreScheme(url: url)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func deepLinkFromOmnivoreScheme(url: URL) -> DeepLink? {
|
||||
switch url.host {
|
||||
case "shareExtensionRequestID":
|
||||
let requestID = url.path.replacingOccurrences(of: "/", with: "")
|
||||
return .webAppLinkRequest(requestID: requestID)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example deep links
|
||||
// "omnivore://shareExtensionRequestID/sampleRequestID"
|
||||
5
apple/OmnivoreKit/Sources/Models/EmptyResponse.swift
Normal file
5
apple/OmnivoreKit/Sources/Models/EmptyResponse.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
public struct EmptyResponse {
|
||||
public init() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
public enum BasicError: Error {
|
||||
case message(messageText: String)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
public enum DocumentSaveError: Error {
|
||||
case saveFailed
|
||||
case nonPDFItem
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
public enum LoginError: Error {
|
||||
case unauthorized
|
||||
case network
|
||||
case unknown
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
public enum SaveArticleError: Error {
|
||||
case unauthorized
|
||||
case network
|
||||
case badData
|
||||
case unknown(description: String)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
public enum SaveError: Error {
|
||||
case network
|
||||
case unauthorized
|
||||
case unknown(description: String)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import Foundation
|
||||
|
||||
public enum PotentialUsernameStatus {
|
||||
case tooShort
|
||||
case tooLong
|
||||
case invalidPattern
|
||||
case unavailable
|
||||
case available
|
||||
case noUsername
|
||||
}
|
||||
|
||||
public enum UsernameAvailabilityError: Error {
|
||||
case tooShort
|
||||
case tooLong
|
||||
case invalidPattern
|
||||
case nameUnavailable
|
||||
case internalServer
|
||||
case network
|
||||
case unknown
|
||||
}
|
||||
|
||||
public extension PotentialUsernameStatus {
|
||||
static func validationError(username: String) -> PotentialUsernameStatus? {
|
||||
if username.count == 0 { return .noUsername }
|
||||
if username.count < 4 { return .tooShort }
|
||||
if username.count > 15 { return .tooLong }
|
||||
if hasInvalidPattern(username: username) { return .invalidPattern }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func hasInvalidPattern(username: String) -> Bool {
|
||||
let usernamePattern = #"^[a-z0-9][a-z0-9_]+[a-z0-9]$"#
|
||||
|
||||
let match = username.range(
|
||||
of: usernamePattern,
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
return match == nil
|
||||
}
|
||||
}
|
||||
120
apple/OmnivoreKit/Sources/Models/FeedItem.swift
Normal file
120
apple/OmnivoreKit/Sources/Models/FeedItem.swift
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import Foundation
|
||||
|
||||
public struct HomeFeedData {
|
||||
public let items: [FeedItem]
|
||||
|
||||
public init(items: [FeedItem]) {
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
|
||||
public struct FeedItem: Identifiable, Hashable, Decodable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public var readingProgress: Double
|
||||
public var readingProgressAnchor: Int
|
||||
public let imageURLString: String?
|
||||
public let onDeviceImageURLString: String?
|
||||
public let documentDirectoryPath: String?
|
||||
public let pageURLString: String
|
||||
public let description: String?
|
||||
public let publisherURLString: String?
|
||||
public let author: String?
|
||||
public let publishDate: Date?
|
||||
public let slug: String
|
||||
public let isArchived: Bool
|
||||
public let contentReader: String?
|
||||
public var highlights: [Highlight]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
readingProgress: Double,
|
||||
readingProgressAnchor: Int,
|
||||
imageURLString: String?,
|
||||
onDeviceImageURLString: String?,
|
||||
documentDirectoryPath: String?,
|
||||
pageURLString: String,
|
||||
description: String?,
|
||||
publisherURLString: String?,
|
||||
author: String?,
|
||||
publishDate: Date?,
|
||||
slug: String,
|
||||
isArchived: Bool,
|
||||
contentReader: String?,
|
||||
highlights: [Highlight]?
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.readingProgress = readingProgress
|
||||
self.readingProgressAnchor = readingProgressAnchor
|
||||
self.imageURLString = imageURLString
|
||||
self.onDeviceImageURLString = onDeviceImageURLString
|
||||
self.documentDirectoryPath = documentDirectoryPath
|
||||
self.pageURLString = pageURLString
|
||||
self.description = description
|
||||
self.publisherURLString = publisherURLString
|
||||
self.author = author
|
||||
self.publishDate = publishDate
|
||||
self.slug = slug
|
||||
self.isArchived = isArchived
|
||||
self.contentReader = contentReader
|
||||
self.highlights = highlights
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
description = try container.decode(String?.self, forKey: .title)
|
||||
imageURLString = try container.decode(String?.self, forKey: .image)
|
||||
readingProgress = try container.decode(Double.self, forKey: .readingProgressPercent)
|
||||
readingProgressAnchor = try container.decode(Int.self, forKey: .readingProgressAnchorIndex)
|
||||
slug = try container.decode(String.self, forKey: .slug)
|
||||
contentReader = try container.decode(String.self, forKey: .contentReader)
|
||||
pageURLString = try container.decode(String.self, forKey: .url)
|
||||
isArchived = try container.decode(Bool.self, forKey: .isArchived)
|
||||
|
||||
self.onDeviceImageURLString = nil
|
||||
self.documentDirectoryPath = nil
|
||||
self.publisherURLString = nil
|
||||
self.author = nil
|
||||
self.publishDate = nil
|
||||
}
|
||||
|
||||
public static func fromJsonArticle(linkData: Data) -> FeedItem? {
|
||||
if let item = try? JSONDecoder().decode(FeedItem.self, from: linkData) {
|
||||
return item
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var isRead: Bool {
|
||||
readingProgress >= 0.98
|
||||
}
|
||||
|
||||
public var isPDF: Bool {
|
||||
if let contentReader = contentReader {
|
||||
return contentReader == "PDF"
|
||||
}
|
||||
return pageURLString.hasSuffix("pdf")
|
||||
}
|
||||
|
||||
public var publisherHostname: String? {
|
||||
URL(string: publisherURLString ?? pageURLString)?.host
|
||||
}
|
||||
|
||||
public var imageURL: URL? {
|
||||
imageURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
public var pdfURL: URL? {
|
||||
guard isPDF else { return nil }
|
||||
let documentDirectoryURL = documentDirectoryPath.flatMap { URL(string: $0) }
|
||||
return documentDirectoryURL ?? URL(string: pageURLString)
|
||||
}
|
||||
}
|
||||
29
apple/OmnivoreKit/Sources/Models/Highlight.swift
Normal file
29
apple/OmnivoreKit/Sources/Models/Highlight.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
public struct Highlight: Identifiable, Hashable {
|
||||
public let id: String
|
||||
public let shortId: String
|
||||
public let quote: String
|
||||
public let prefix: String?
|
||||
public let suffix: String?
|
||||
public let patch: String
|
||||
public let annotation: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
shortId: String,
|
||||
quote: String,
|
||||
prefix: String?,
|
||||
suffix: String?,
|
||||
patch: String,
|
||||
annotation: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.shortId = shortId
|
||||
self.quote = quote
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.patch = patch
|
||||
self.annotation = annotation
|
||||
}
|
||||
}
|
||||
221
apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift
Normal file
221
apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import MobileCoreServices
|
||||
#endif
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
let URLREGEX = #"[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)"#
|
||||
|
||||
public struct PageScrapePayload {
|
||||
public enum ContentType {
|
||||
case html
|
||||
case pdf
|
||||
}
|
||||
|
||||
public let title: String?
|
||||
public let html: String?
|
||||
public let url: String
|
||||
public let contentType: ContentType
|
||||
|
||||
init(url: String, title: String?, html: String?) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
self.html = html
|
||||
self.contentType = url.hasSuffix(".pdf") ? .pdf : .html
|
||||
}
|
||||
}
|
||||
|
||||
public struct PageScrapeError: Error {
|
||||
public let message: String
|
||||
}
|
||||
|
||||
public enum PageScraper {
|
||||
public static func scrape(
|
||||
extensionContext: NSExtensionContext?,
|
||||
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
|
||||
) {
|
||||
let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem
|
||||
let extensionAttachments = extensionItem?.attachments
|
||||
|
||||
guard let attachments = extensionAttachments else {
|
||||
completion(.failure(PageScrapeError(message: "no attachments")))
|
||||
return
|
||||
}
|
||||
|
||||
var pageScrapePayload: PageScrapePayload?
|
||||
let propertyListKey = String(kUTTypePropertyList)
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
for attachment in attachments where attachment.hasItemConformingToTypeIdentifier(propertyListKey) {
|
||||
group.enter()
|
||||
attachment.loadItem(
|
||||
forTypeIdentifier: propertyListKey,
|
||||
options: nil
|
||||
) { item, _ in
|
||||
if let payload = PageScrapePayload.make(item: item) {
|
||||
pageScrapePayload = payload
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
if let payload = pageScrapePayload {
|
||||
completion(.success(payload))
|
||||
} else {
|
||||
scrapeURLOnly(extensionContext: extensionContext, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func scrapeURLOnly(
|
||||
extensionContext: NSExtensionContext?,
|
||||
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
|
||||
) {
|
||||
let urlKey = String(kUTTypeURL)
|
||||
|
||||
// First look for a URL type
|
||||
let urlFound = extensionContext?.inputItems.first { inputItem in
|
||||
let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in
|
||||
attachment.hasItemConformingToTypeIdentifier(urlKey)
|
||||
})
|
||||
let hasPublicURL = itemProvider?.hasItemConformingToTypeIdentifier(urlKey) == true
|
||||
if hasPublicURL {
|
||||
itemProvider?.loadItem(forTypeIdentifier: urlKey, options: nil) { item, _ in
|
||||
let shareURL = item as? URL
|
||||
let urlString = (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
|
||||
let shareUrlFromData = urlString.flatMap { URL(string: $0) }
|
||||
let url = shareURL ?? shareUrlFromData
|
||||
let pageScrapePayload = PageScrapePayload.make(url: url)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let payload = pageScrapePayload {
|
||||
return completion(.success(payload))
|
||||
} else {
|
||||
return completion(.failure(PageScrapeError(message: "could not extract url")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasPublicURL
|
||||
}
|
||||
|
||||
if urlFound != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let textKey = String(kUTTypeText)
|
||||
// We didn't find a URL type, so check for a string that contains a URL
|
||||
let textUrlFound = extensionContext?.inputItems.first { inputItem in
|
||||
let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in
|
||||
attachment.hasItemConformingToTypeIdentifier(textKey)
|
||||
})
|
||||
let hasPublicText = itemProvider?.hasItemConformingToTypeIdentifier(textKey) == true
|
||||
if hasPublicText {
|
||||
itemProvider?.loadItem(forTypeIdentifier: textKey, options: nil) { item, _ in
|
||||
var url: URL?
|
||||
if let item = item as? String {
|
||||
if let range = item.range(of: URLREGEX, options: .regularExpression) {
|
||||
let urlStr = item[range]
|
||||
url = URL(string: String(urlStr))
|
||||
}
|
||||
}
|
||||
|
||||
let pageScrapePayload = PageScrapePayload.make(url: url)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let payload = pageScrapePayload {
|
||||
return completion(.success(payload))
|
||||
} else {
|
||||
return completion(.failure(PageScrapeError(message: "could not extract url")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasPublicText
|
||||
}
|
||||
|
||||
if textUrlFound != nil {
|
||||
return
|
||||
}
|
||||
|
||||
completion(.failure(PageScrapeError(message: "could not find a link to save")))
|
||||
}
|
||||
|
||||
private static func tryScrapeUrlFromText(
|
||||
extensionContext: NSExtensionContext?,
|
||||
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
|
||||
) {
|
||||
let urlKey = String(kUTTypeURL)
|
||||
let textKey = String(kUTTypeUTF16PlainText)
|
||||
let inputItem = extensionContext?.inputItems.first as? NSExtensionItem
|
||||
let itemProvider = inputItem?.attachments?.first(where: { attachment in
|
||||
attachment.hasItemConformingToTypeIdentifier(urlKey)
|
||||
})
|
||||
let hasPublicURL = itemProvider?.hasItemConformingToTypeIdentifier(urlKey) == true
|
||||
let hasPublicText = itemProvider?.hasItemConformingToTypeIdentifier(textKey) == true
|
||||
|
||||
guard hasPublicURL || hasPublicText else {
|
||||
completion(.failure(PageScrapeError(message: "no public url")))
|
||||
return
|
||||
}
|
||||
|
||||
if hasPublicURL {
|
||||
itemProvider?.loadItem(forTypeIdentifier: urlKey, options: nil) { item, _ in
|
||||
let shareURL = item as? URL
|
||||
let urlString = (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
|
||||
let shareUrlFromData = urlString.flatMap { URL(string: $0) }
|
||||
let url = shareURL ?? shareUrlFromData
|
||||
let pageScrapePayload = PageScrapePayload.make(url: url)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let payload = pageScrapePayload {
|
||||
return completion(.success(payload))
|
||||
} else {
|
||||
return completion(.failure(PageScrapeError(message: "could not extract url")))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
itemProvider?.loadItem(forTypeIdentifier: textKey, options: nil) { item, _ in
|
||||
var url: URL?
|
||||
if let item = item as? String {
|
||||
if let range = item.range(of: URLREGEX, options: .regularExpression) {
|
||||
let urlStr = item[range]
|
||||
url = URL(string: String(urlStr))
|
||||
}
|
||||
}
|
||||
|
||||
let pageScrapePayload = PageScrapePayload.make(url: url)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let payload = pageScrapePayload {
|
||||
return completion(.success(payload))
|
||||
} else {
|
||||
return completion(.failure(PageScrapeError(message: "could not extract url")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PageScrapePayload {
|
||||
static func make(url: URL?) -> PageScrapePayload? {
|
||||
guard let url = url else { return nil }
|
||||
return PageScrapePayload(url: url.absoluteString, title: nil, html: nil)
|
||||
}
|
||||
|
||||
static func make(item: NSSecureCoding?) -> PageScrapePayload? {
|
||||
let dictionary = item as? NSDictionary
|
||||
let results = dictionary?[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary
|
||||
guard let url = results?["url"] as? String else { return nil }
|
||||
let html = results?["documentHTML"] as? String
|
||||
let title = results?["title"] as? String
|
||||
|
||||
return PageScrapePayload(url: url, title: title, html: html)
|
||||
}
|
||||
}
|
||||
7
apple/OmnivoreKit/Sources/Models/ReminderTime.swift
Normal file
7
apple/OmnivoreKit/Sources/Models/ReminderTime.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
public enum ReminderTime: Equatable {
|
||||
case tonight
|
||||
case tomorrow
|
||||
case thisWeekend
|
||||
}
|
||||
68
apple/OmnivoreKit/Sources/Models/UserProfile.swift
Normal file
68
apple/OmnivoreKit/Sources/Models/UserProfile.swift
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Foundation
|
||||
import Utils
|
||||
|
||||
public struct UserProfile: Codable {
|
||||
public let username: String
|
||||
public let name: String
|
||||
public let bio: String?
|
||||
|
||||
public init(
|
||||
username: String,
|
||||
name: String,
|
||||
bio: String? = nil
|
||||
) {
|
||||
self.username = username
|
||||
self.name = name
|
||||
self.bio = bio
|
||||
}
|
||||
}
|
||||
|
||||
public extension UserProfile {
|
||||
static func make(
|
||||
username: String,
|
||||
name: String,
|
||||
bio: String?
|
||||
) -> Either<UserProfile, String> {
|
||||
let userProfile = UserProfile(
|
||||
username: username,
|
||||
name: name,
|
||||
bio: bio
|
||||
)
|
||||
|
||||
if let errorMessage = userProfile.validationErrorMessage {
|
||||
return .right(errorMessage)
|
||||
} else {
|
||||
return .left(userProfile)
|
||||
}
|
||||
}
|
||||
|
||||
private var validationErrorMessage: String? {
|
||||
if name.isEmpty {
|
||||
return "The name field is missing."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct Viewer {
|
||||
public let userID: String
|
||||
public let username: String
|
||||
public let name: String
|
||||
public let profileImageURL: String?
|
||||
public let isWaitlisted: Bool
|
||||
|
||||
public init(
|
||||
username: String,
|
||||
name: String,
|
||||
profileImageURL: String?,
|
||||
isWaitlisted: Bool,
|
||||
userID: String
|
||||
) {
|
||||
self.username = username
|
||||
self.name = name
|
||||
self.profileImageURL = profileImageURL
|
||||
self.isWaitlisted = isWaitlisted
|
||||
self.userID = userID
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
public extension Authenticator {
|
||||
func createPendingAccountUsingApple(
|
||||
token: String,
|
||||
name: PersonNameComponents?
|
||||
) -> AnyPublisher<UserProfile, LoginError> {
|
||||
let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name)
|
||||
return createPendingAccount(params: params)
|
||||
}
|
||||
|
||||
func createAccount(userProfile: UserProfile) -> AnyPublisher<Void, LoginError> {
|
||||
let params = CreateAccountParams(
|
||||
pendingUserToken: pendingUserToken ?? "",
|
||||
userProfile: userProfile
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
.mapError { error in
|
||||
let serverError = (error as? ServerError) ?? ServerError.unknown
|
||||
return LoginError.make(serverError: serverError)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
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
|
||||
}
|
||||
.mapError { error in
|
||||
let serverError = (error as? ServerError) ?? ServerError.unknown
|
||||
return LoginError.make(serverError: serverError)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
struct CreatePendingAccountParams: Encodable {
|
||||
let token: String
|
||||
let provider: AuthProvider
|
||||
let name: String?
|
||||
|
||||
init(token: String, provider: AuthProvider, fullName: PersonNameComponents?) {
|
||||
self.token = token
|
||||
self.provider = provider
|
||||
self.name = fullName?.formattedMediumLengthName
|
||||
}
|
||||
}
|
||||
|
||||
struct SignInParams: Encodable {
|
||||
let token: String
|
||||
let provider: AuthProvider
|
||||
}
|
||||
|
||||
enum AuthProvider: String, Encodable {
|
||||
case apple = "APPLE"
|
||||
case google = "GOOGLE"
|
||||
}
|
||||
|
||||
struct AuthPayload: Decodable {
|
||||
let authCookieString: String
|
||||
let authToken: String
|
||||
}
|
||||
|
||||
struct CreateAccountParams: Encodable {
|
||||
let pendingUserToken: String
|
||||
let userProfile: UserProfile
|
||||
}
|
||||
|
||||
struct PendingUserAuthPayload: Decodable {
|
||||
let pendingUserToken: String
|
||||
let pendingUserProfile: UserProfile
|
||||
}
|
||||
|
||||
extension AuthPayload {
|
||||
var commentedAuthCookieString: String {
|
||||
authCookieString.replacingOccurrences(of: "HttpOnly", with: "comment=ios-webview-cookie; HttpOnly")
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginError {
|
||||
static func make(serverError: ServerError) -> LoginError {
|
||||
switch serverError {
|
||||
case .noConnection, .timeout:
|
||||
return .network
|
||||
case .unauthenticated:
|
||||
return .unauthorized
|
||||
case .unknown:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PersonNameComponents {
|
||||
var formattedMediumLengthName: String {
|
||||
let formatter = PersonNameComponentsFormatter()
|
||||
formatter.style = .medium
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import AppAuth
|
||||
import Combine
|
||||
import Foundation
|
||||
import Utils
|
||||
import WebKit
|
||||
|
||||
public final class Authenticator: ObservableObject {
|
||||
public static var unregisterIntercomUser: (() -> Void)?
|
||||
|
||||
public enum AuthStatus {
|
||||
case loggedOut
|
||||
case pendingUser
|
||||
case loggedIn
|
||||
}
|
||||
|
||||
@Published public internal(set) var isLoggedIn: Bool
|
||||
@Published public internal(set) var isWaitlisted = false
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
public var omnivoreAuthCookieString: String? {
|
||||
ValetKey.authCookieString.value()
|
||||
}
|
||||
|
||||
public var authToken: String? {
|
||||
ValetKey.authToken.value()
|
||||
}
|
||||
|
||||
public func logout() {
|
||||
clearCreds()
|
||||
Authenticator.unregisterIntercomUser?()
|
||||
isLoggedIn = false
|
||||
}
|
||||
|
||||
public func clearCreds() {
|
||||
clearCookies()
|
||||
ValetKey.removeAllKeys()
|
||||
UserDefaults.standard.removeObject(forKey: Keys.userIdKey)
|
||||
}
|
||||
|
||||
public var hasValidAuthToken: Bool {
|
||||
guard let authToken = ValetKey.authToken.value() else { return false }
|
||||
// We could do a more thorough check by decoding the token and reading it's properties
|
||||
// but then we would have to store the jwt secret client side.
|
||||
return !authToken.isEmpty
|
||||
}
|
||||
|
||||
public func updateWaitlistStatus(isWaitlistedUser: Bool) {
|
||||
isWaitlisted = isWaitlistedUser
|
||||
}
|
||||
|
||||
private func clearCookies() {
|
||||
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
|
||||
|
||||
WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
|
||||
records.forEach { record in
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import AppAuth
|
||||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private extension Authenticator {
|
||||
func resolveAuthResponse(
|
||||
promise: @escaping (Result<Bool, LoginError>) -> Void,
|
||||
authState: OIDAuthState?,
|
||||
authError: Error?
|
||||
) {
|
||||
if let idToken = authState?.lastTokenResponse?.idToken {
|
||||
networker.submitGoogleToken(idToken: idToken)
|
||||
.sink { completion in
|
||||
guard case let .failure(loginError) = completion else { return }
|
||||
switch loginError {
|
||||
case .unauthorized, .unknown:
|
||||
self.resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError)
|
||||
case .network:
|
||||
promise(.failure(loginError))
|
||||
}
|
||||
} receiveValue: { [weak self] in
|
||||
do {
|
||||
try ValetKey.authCookieString.setValue($0.commentedAuthCookieString)
|
||||
try ValetKey.authToken.setValue($0.authToken)
|
||||
self?.isLoggedIn = true
|
||||
promise(.success(false))
|
||||
} catch {
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
} 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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import Foundation
|
||||
import SwiftGraphQL
|
||||
|
||||
struct DateTime: Codec {
|
||||
private var data: Date?
|
||||
|
||||
init(from date: Date) {
|
||||
self.data = date
|
||||
}
|
||||
|
||||
// MARK: - Public interface
|
||||
|
||||
var value: Date? {
|
||||
data
|
||||
}
|
||||
|
||||
// MARK: - Codec conformance
|
||||
|
||||
// MARK: - Decoder
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let dateString = try container.decode(String.self)
|
||||
|
||||
let formatter = DateFormatter.formatterISO8601
|
||||
|
||||
self.data = formatter.date(from: dateString)
|
||||
}
|
||||
|
||||
// MARK: - Encoder
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
if let data = data {
|
||||
try container.encode(DateFormatter.formatterISO8601.string(from: data))
|
||||
} else {
|
||||
throw EncodingError.invalidValue(
|
||||
data as Any, .init(codingPath: [], debugDescription: "trying to encodea DateTime value")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock value
|
||||
|
||||
static var mockValue = DateTime(from: Date())
|
||||
}
|
||||
|
||||
public extension DateFormatter {
|
||||
static let formatterISO8601: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
public final class DataService {
|
||||
public static var registerIntercomUser: ((String) -> Void)?
|
||||
public static var showIntercomMessenger: (() -> Void)?
|
||||
|
||||
public let appEnvironment: AppEnvironment
|
||||
public internal(set) var currentViewer: Viewer?
|
||||
let networker: Networker
|
||||
|
||||
let highlightsCache = NSCache<AnyObject, CachedPDFHighlights>()
|
||||
let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent)
|
||||
|
||||
public init(appEnvironment: AppEnvironment, networker: Networker) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.networker = networker
|
||||
}
|
||||
|
||||
public func clearHighlights() {
|
||||
highlightsCache.removeAllObjects()
|
||||
}
|
||||
|
||||
public func switchAppEnvironment(appEnvironment: AppEnvironment) {
|
||||
do {
|
||||
try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue)
|
||||
fatalError("App environment changed -- restarting app")
|
||||
} catch {
|
||||
fatalError("Unable to write to Keychain: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
public enum ServerError: String, Error {
|
||||
case noConnection
|
||||
case unauthenticated
|
||||
case timeout
|
||||
case unknown
|
||||
}
|
||||
|
||||
extension ServerError {
|
||||
init(serverResponse: ServerResponse) {
|
||||
switch serverResponse.httpUrlResponse?.statusCode {
|
||||
case 401?, 403?:
|
||||
self = .unauthenticated
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
switch serverResponse.urlError?.code {
|
||||
case .some(.timedOut):
|
||||
self = .timeout
|
||||
return
|
||||
case .some(.notConnectedToInternet):
|
||||
self = .noConnection
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
17017
apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift
Normal file
17017
apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,60 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func archiveLinkPublisher(
|
||||
itemID: String,
|
||||
archived: Bool
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case success(linkId: String)
|
||||
case error(errorCode: Enums.ArchiveLinkErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.ArchiveLinkResult> {
|
||||
try $0.on(
|
||||
archiveLinkSuccess: .init { .success(linkId: try $0.linkId()) },
|
||||
archiveLinkError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLinkArchived(
|
||||
input: InputObjects.ArchiveLinkInput(
|
||||
linkId: itemID,
|
||||
archived: archived
|
||||
),
|
||||
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 payload.errors != nil {
|
||||
promise(.failure(.message(messageText: "Error archiving link")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(linkId):
|
||||
promise(.success(linkId))
|
||||
case .error:
|
||||
promise(.failure(.message(messageText: "Error archiving link")))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "Error archiving link")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func createHighlightPublisher(
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
quote: String,
|
||||
patch: String,
|
||||
articleId: String
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.CreateHighlightErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.CreateHighlightResult> {
|
||||
try $0.on(
|
||||
createHighlightSuccess: .init {
|
||||
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
|
||||
},
|
||||
createHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.createHighlight(
|
||||
input: InputObjects.CreateHighlightInput(
|
||||
id: highlightID, shortId: shortId, articleId: articleId, patch: patch, quote: quote
|
||||
),
|
||||
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(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public enum ReminderItemId {
|
||||
case clientRequest(id: String)
|
||||
case link(id: String)
|
||||
|
||||
var linkId: String? {
|
||||
switch self {
|
||||
case .clientRequest:
|
||||
return nil
|
||||
case let .link(id):
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
var clientRequestId: String? {
|
||||
switch self {
|
||||
case .link:
|
||||
return nil
|
||||
case let .clientRequest(id):
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func createReminderPublisher(
|
||||
reminderItemId: ReminderItemId,
|
||||
remindAt: Date
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case complete(id: String)
|
||||
case error(errorCode: Enums.CreateReminderErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.CreateReminderResult> {
|
||||
try $0.on(
|
||||
createReminderSuccess: .init {
|
||||
.complete(id: try $0.reminder(selection: Selection.Reminder { try $0.id() }))
|
||||
},
|
||||
createReminderError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.createReminder(
|
||||
input: InputObjects.CreateReminderInput(
|
||||
linkId: OptionalArgument(reminderItemId.linkId),
|
||||
clientRequestId: OptionalArgument(reminderItemId.clientRequestId),
|
||||
archiveUntil: true,
|
||||
sendNotification: true,
|
||||
remindAt: DateTime(from: remindAt)
|
||||
),
|
||||
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(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .complete(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func deleteHighlightPublisher(
|
||||
highlightId: String
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.DeleteHighlightErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.DeleteHighlightResult> {
|
||||
try $0.on(
|
||||
deleteHighlightSuccess: .init {
|
||||
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
|
||||
},
|
||||
deleteHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .unauthorized) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.deleteHighlight(
|
||||
highlightId: highlightId.lowercased(),
|
||||
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(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public enum DeviceTokenOperation {
|
||||
case addToken(token: String)
|
||||
case deleteToken(tokenID: String)
|
||||
|
||||
var token: String? {
|
||||
switch self {
|
||||
case let .addToken(token):
|
||||
return token
|
||||
case .deleteToken:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var tokenID: String? {
|
||||
switch self {
|
||||
case .addToken:
|
||||
return nil
|
||||
case let .deleteToken(tokenID):
|
||||
return tokenID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func deviceTokenPublisher(
|
||||
deviceTokenOperation: DeviceTokenOperation
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResultNew {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.SetDeviceTokenErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResultNew, Unions.SetDeviceTokenResult> {
|
||||
try $0.on(
|
||||
setDeviceTokenSuccess: .init {
|
||||
.saved(id: try $0.deviceToken(selection: Selection.DeviceToken { try $0.id() }))
|
||||
},
|
||||
setDeviceTokenError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setDeviceToken(
|
||||
input: InputObjects.SetDeviceTokenInput(
|
||||
id: OptionalArgument(deviceTokenOperation.tokenID),
|
||||
token: OptionalArgument(deviceTokenOperation.token)
|
||||
),
|
||||
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(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func mergeHighlightPublisher(
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
quote: String,
|
||||
patch: String,
|
||||
articleId: String,
|
||||
overlapHighlightIdList: [String]
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.MergeHighlightErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.MergeHighlightResult> {
|
||||
try $0.on(
|
||||
mergeHighlightSuccess: .init {
|
||||
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
|
||||
},
|
||||
mergeHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.mergeHighlight(
|
||||
input: InputObjects.MergeHighlightInput(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
articleId: articleId,
|
||||
patch: patch,
|
||||
quote: quote,
|
||||
prefix: .absent(),
|
||||
suffix: .absent(),
|
||||
annotation: .absent(),
|
||||
overlapHighlightIdList: overlapHighlightIdList
|
||||
),
|
||||
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(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
let setBookmarkedArticleSelection = Selection.Article {
|
||||
try $0.id()
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func removeLinkPublisher(
|
||||
itemID: String
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case success(linkId: String)
|
||||
case error(errorCode: Enums.SetBookmarkArticleErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SetBookmarkArticleResult> {
|
||||
try $0.on(
|
||||
setBookmarkArticleSuccess: .init {
|
||||
.success(
|
||||
linkId: try $0.bookmarkedArticle(selection: setBookmarkedArticleSelection)
|
||||
)
|
||||
},
|
||||
setBookmarkArticleError: .init { .error(errorCode: try $0.errorCodes().first ?? .notFound) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setBookmarkArticle(
|
||||
input: InputObjects.SetBookmarkArticleInput(
|
||||
articleId: itemID,
|
||||
bookmark: false
|
||||
),
|
||||
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 payload.errors != nil {
|
||||
promise(.failure(.message(messageText: "Error removing link")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(item):
|
||||
promise(.success(item))
|
||||
case .error(errorCode: _):
|
||||
promise(.failure(.message(messageText: "Error removing link")))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "Error removing link")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
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(
|
||||
articleSavingRequestSuccess: .init {
|
||||
.saved(
|
||||
status: try $0.articleSavingRequest(
|
||||
selection: .init {
|
||||
SaveArticleStatus.make(
|
||||
jobId: try $0.id(),
|
||||
savingStatus: try $0.status()
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
articleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .notFound) }
|
||||
)
|
||||
}
|
||||
|
||||
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? = {
|
||||
guard let html = pageScrapePayload.html, let title = pageScrapePayload.title else { return nil }
|
||||
return InputObjects.PreparedDocumentInput(
|
||||
document: html,
|
||||
pageInfo: InputObjects.PageInfoInput(title: OptionalArgument(title))
|
||||
)
|
||||
}()
|
||||
|
||||
let input = InputObjects.CreateArticleInput(
|
||||
url: pageScrapePayload.url,
|
||||
preparedDocument: OptionalArgument(preparedDocument),
|
||||
uploadFileId: uploadFileId != nil ? .present(uploadFileId!) : .null()
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.CreateArticleResult> {
|
||||
try $0.on(
|
||||
createArticleSuccess: .init { .saved(created: try $0.created()) },
|
||||
createArticleError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unableToParse) }
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
createArticleSavingRequestSuccess: .init {
|
||||
.saved(
|
||||
status: try $0.articleSavingRequest(
|
||||
selection: .init {
|
||||
SaveArticleStatus.make(
|
||||
jobId: try $0.id(),
|
||||
savingStatus: try $0.status()
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
createArticleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .badData) }
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveArticleError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
return .network
|
||||
case .badpayload, .badURL, .badstatus, .cancelled:
|
||||
return .unknown(description: httpError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func uploadPDFPublisher(
|
||||
pageScrapePayload: PageScrapePayload,
|
||||
requestId: String
|
||||
) -> AnyPublisher<Void, SaveArticleError> {
|
||||
uploadFileRequestPublisher(pageScrapePayload: pageScrapePayload)
|
||||
.flatMap { self.uploadFilePublisher(fileUploadConfig: $0, fileURLString: pageScrapePayload.url) }
|
||||
.flatMap { self.saveFilePublisher(pageScrapePayload: pageScrapePayload, uploadFileId: $0, requestId: requestId) }
|
||||
.catch { _ in self.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private struct UploadFileRequestPayload {
|
||||
let uploadID: String?
|
||||
let uploadFileID: String?
|
||||
let urlString: String?
|
||||
}
|
||||
|
||||
private extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func uploadFileRequestPublisher(pageScrapePayload: PageScrapePayload) -> AnyPublisher<UploadFileRequestPayload, SaveArticleError> {
|
||||
enum MutationResult {
|
||||
case success(payload: UploadFileRequestPayload)
|
||||
case error(errorCode: Enums.UploadFileRequestErrorCode?)
|
||||
}
|
||||
|
||||
let input = InputObjects.UploadFileRequestInput(
|
||||
url: pageScrapePayload.url,
|
||||
contentType: "application/pdf"
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.UploadFileRequestResult> {
|
||||
try $0.on(
|
||||
uploadFileRequestSuccess: .init {
|
||||
.success(
|
||||
payload: UploadFileRequestPayload(
|
||||
uploadID: try $0.id(),
|
||||
uploadFileID: try $0.uploadFileId(),
|
||||
urlString: try $0.uploadSignedUrl()
|
||||
)
|
||||
)
|
||||
},
|
||||
uploadFileRequestError: .init { .error(errorCode: try? $0.errorCodes().first) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.uploadFileRequest(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 let .success(payload):
|
||||
promise(.success(payload))
|
||||
case let .error(errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
default:
|
||||
promise(.failure(.unknown(description: errorCode.debugDescription)))
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.badData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
func uploadFilePublisher(fileUploadConfig: UploadFileRequestPayload, fileURLString: String) -> AnyPublisher<String, SaveArticleError> {
|
||||
let pdfData = URL(string: fileURLString).flatMap { try? Data(contentsOf: $0) }
|
||||
|
||||
let url = fileUploadConfig.urlString.flatMap { URL(string: $0) }
|
||||
guard let url = url, let pdfData = pdfData else { return Future { $0(.failure(.badData)) }.eraseToAnyPublisher() }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
|
||||
request.httpBody = pdfData
|
||||
|
||||
return networker.urlSession.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response -> String in
|
||||
let serverResponse = ServerResponse(data: data, response: response)
|
||||
if serverResponse.httpUrlResponse?.statusCode == 200, let fileUploadID = fileUploadConfig.uploadID {
|
||||
return fileUploadID
|
||||
}
|
||||
|
||||
throw ServerError(serverResponse: serverResponse)
|
||||
}
|
||||
.mapError { error -> SaveArticleError in
|
||||
let serverResponse = ServerResponse(error: error)
|
||||
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
|
||||
let serverError = ServerError(serverResponse: serverResponse)
|
||||
switch serverError {
|
||||
case .noConnection, .timeout:
|
||||
return .network
|
||||
case .unauthenticated:
|
||||
return .unauthorized
|
||||
case .unknown:
|
||||
return .unknown(description: "upload to file server failed")
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
func saveFilePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
|
||||
enum MutationResult {
|
||||
case saved(requestId: String, url: String)
|
||||
case error(errorCode: Enums.SaveErrorCode)
|
||||
}
|
||||
|
||||
let input = InputObjects.SaveFileInput(
|
||||
url: pageScrapePayload.url,
|
||||
source: "ios-file",
|
||||
clientRequestId: requestId,
|
||||
uploadFileId: uploadFileId
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SaveResult> {
|
||||
try $0.on(
|
||||
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
|
||||
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.saveFile(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(SaveError.make(from: error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
return .network
|
||||
case .badpayload, .badURL, .badstatus, .cancelled:
|
||||
return .unknown(description: httpError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func savePagePublisher(pageScrapePayload: PageScrapePayload, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
|
||||
enum MutationResult {
|
||||
case saved(requestId: String, url: String)
|
||||
case error(errorCode: Enums.SaveErrorCode)
|
||||
}
|
||||
|
||||
let input = InputObjects.SavePageInput(
|
||||
url: pageScrapePayload.url,
|
||||
source: "ios-page",
|
||||
clientRequestId: requestId,
|
||||
title: OptionalArgument(pageScrapePayload.title),
|
||||
originalContent: pageScrapePayload.html ?? ""
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SaveResult> {
|
||||
try $0.on(
|
||||
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
|
||||
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.savePage(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(SaveError.make(from: error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
return .network
|
||||
case .badpayload, .badURL, .badstatus, .cancelled:
|
||||
return .unknown(description: httpError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func saveUrlPublisher(pageScrapePayload: PageScrapePayload, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
|
||||
enum MutationResult {
|
||||
case saved(requestId: String, url: String)
|
||||
case error(errorCode: Enums.SaveErrorCode)
|
||||
}
|
||||
|
||||
let input = InputObjects.SaveUrlInput(
|
||||
url: pageScrapePayload.url,
|
||||
source: "ios-url",
|
||||
clientRequestId: requestId
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SaveResult> {
|
||||
try $0.on(
|
||||
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
|
||||
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.saveUrl(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(SaveError.make(from: error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
return .network
|
||||
case .badpayload, .badURL, .badstatus, .cancelled:
|
||||
return .unknown(description: httpError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateArticleReadingProgressPublisher(
|
||||
itemID: String,
|
||||
readingProgress: Double,
|
||||
anchorIndex: Int
|
||||
) -> AnyPublisher<FeedItem, SaveArticleError> {
|
||||
enum MutationResult {
|
||||
case saved(feedItem: FeedItem)
|
||||
case error(errorCode: Enums.SaveArticleReadingProgressErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SaveArticleReadingProgressResult> {
|
||||
try $0.on(
|
||||
saveArticleReadingProgressSuccess: .init {
|
||||
.saved(
|
||||
feedItem: try $0.updatedArticle(selection: homeFeedItemSelection)
|
||||
)
|
||||
},
|
||||
saveArticleReadingProgressError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.saveArticleReadingProgress(
|
||||
input: InputObjects.SaveArticleReadingProgressInput(
|
||||
id: itemID,
|
||||
readingProgressPercent: readingProgress,
|
||||
readingProgressAnchorIndex: anchorIndex
|
||||
),
|
||||
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(feedItem):
|
||||
promise(.success(feedItem))
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
case .badData, .notFound:
|
||||
promise(.failure(.badData))
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.badData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateHighlightAttributesPublisher(
|
||||
highlightID: String,
|
||||
annotation: String?,
|
||||
sharedAt: Date?
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.UpdateHighlightErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.UpdateHighlightResult> {
|
||||
try $0.on(
|
||||
updateHighlightSuccess: .init {
|
||||
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
|
||||
},
|
||||
updateHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.updateHighlight(
|
||||
input: InputObjects.UpdateHighlightInput(
|
||||
highlightId: highlightID,
|
||||
annotation: OptionalArgument(annotation),
|
||||
sharedAt: OptionalArgument(sharedAt.flatMap { DateTime(from: $0) })
|
||||
),
|
||||
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(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
|
||||
private let standardJSONDecoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return decoder
|
||||
}()
|
||||
|
||||
extension Decodable {
|
||||
static func decodeJSON(fromData data: Data) -> Self? {
|
||||
do {
|
||||
return try standardJSONDecoder.decode(Self.self, from: data)
|
||||
} catch {
|
||||
let jsonReadingOptions = JSONSerialization.ReadingOptions(rawValue: 0)
|
||||
let json = (try? JSONSerialization.jsonObject(with: data, options: jsonReadingOptions))
|
||||
print("Decoding Error: \(error)\nRaw JSON: \(String(describing: json))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func decode<ResponseModel: Decodable>(serverResponse: ServerResponse) -> ResponseModel? {
|
||||
guard let httpResponse = serverResponse.httpUrlResponse else { return nil }
|
||||
guard 200 ... 299 ~= httpResponse.statusCode else { return nil }
|
||||
guard let data = serverResponse.data else { return nil }
|
||||
return decodeJSON(fromData: data) as? ResponseModel
|
||||
}
|
||||
}
|
||||
|
||||
extension UnkeyedDecodingContainer {
|
||||
func decodeAll<T: Decodable>() throws -> [T] {
|
||||
var container = self
|
||||
var decoded = [T]()
|
||||
|
||||
while !container.isAtEnd {
|
||||
let decodedElement = try container.decode(T.self)
|
||||
decoded.append(decodedElement)
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import Foundation
|
||||
|
||||
struct NetworkRequestLogger {
|
||||
static func log(request: URLRequest, serverResponse: ServerResponse) {
|
||||
log(request: request)
|
||||
log(serverResponse: serverResponse)
|
||||
}
|
||||
|
||||
private static func log(request: URLRequest) {
|
||||
let urlString = request.url?.absoluteString ?? ""
|
||||
let components = URLComponents(string: urlString)
|
||||
|
||||
let method = request.httpMethod != nil ? "\(request.httpMethod!)" : ""
|
||||
let path = "\(components?.path ?? "")"
|
||||
let query = "\(components?.query ?? "")"
|
||||
let host = "\(components?.host ?? "")"
|
||||
|
||||
var requestLog = "\n\n\n------ OUTGOING NETWORK REQUEST ---------->\n\n"
|
||||
|
||||
requestLog += "\(urlString)"
|
||||
requestLog += "\n\n"
|
||||
requestLog += "\(method) \(path)?\(query) HTTP/1.1\n"
|
||||
requestLog += "Host: \(host)\n"
|
||||
|
||||
for (key, value) in request.allHTTPHeaderFields ?? [:] {
|
||||
requestLog += "\(key): \(value)\n"
|
||||
}
|
||||
|
||||
if let body = request.httpBody {
|
||||
requestLog += "\n\(body.prettyPrintedJSONString ?? "")\n"
|
||||
}
|
||||
|
||||
requestLog += "\n------------------------->\n"
|
||||
print(requestLog)
|
||||
}
|
||||
|
||||
private static func log(serverResponse: ServerResponse, includeJSON: Bool = true) {
|
||||
let urlString = serverResponse.httpUrlResponse?.url?.absoluteString
|
||||
let components = URLComponents(string: urlString ?? "")
|
||||
|
||||
let path = "\(components?.path ?? "")"
|
||||
let query = "?\(components?.query ?? "")"
|
||||
|
||||
var responseLog = "\n\n\n<---------- INCOMING NETWORK RESPONSE ----------\n\n"
|
||||
|
||||
if let urlString = urlString {
|
||||
responseLog += "\(urlString)"
|
||||
responseLog += "\n\n"
|
||||
}
|
||||
|
||||
if let statusCode = serverResponse.httpUrlResponse?.statusCode {
|
||||
responseLog += "HTTP \(statusCode) \(path)\(query)\n"
|
||||
}
|
||||
|
||||
if let host = components?.host {
|
||||
responseLog += "Host: \(host)\n"
|
||||
}
|
||||
|
||||
for (key, value) in serverResponse.httpUrlResponse?.allHeaderFields ?? [:] {
|
||||
responseLog += "\(key): \(value)\n"
|
||||
}
|
||||
|
||||
if let body = serverResponse.data, includeJSON {
|
||||
responseLog += "\n\(body.prettyPrintedJSONString ?? "")\n"
|
||||
}
|
||||
|
||||
if let error = serverResponse.urlError {
|
||||
responseLog += "\nError: \(error.localizedDescription)\n"
|
||||
}
|
||||
|
||||
responseLog += "<---------------------------------\n"
|
||||
print(responseLog)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
var prettyPrintedJSONString: String? {
|
||||
guard
|
||||
let object = try? JSONSerialization.jsonObject(with: self, options: []),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
|
||||
let prettyPrintedString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue))
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return prettyPrintedString
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
public final class Networker {
|
||||
let urlSession: URLSession
|
||||
let appEnvironment: AppEnvironment
|
||||
|
||||
var defaultHeaders: [String: String] {
|
||||
var headers = URLRequest.defaultHeaders
|
||||
|
||||
if let authToken = ValetKey.authToken.value() {
|
||||
headers["authorization"] = authToken
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
public init(appEnvironment: AppEnvironment, urlSession: URLSession = .shared) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Utils
|
||||
|
||||
struct ServerResource<ResponseModel> {
|
||||
let urlRequest: URLRequest
|
||||
let decode: (ServerResponse) -> ResponseModel?
|
||||
}
|
||||
|
||||
struct ServerResponse {
|
||||
let data: Data?
|
||||
let httpUrlResponse: HTTPURLResponse?
|
||||
let urlError: URLError?
|
||||
|
||||
init(data: Data?, response: URLResponse?) {
|
||||
self.data = data
|
||||
self.httpUrlResponse = response as? HTTPURLResponse
|
||||
self.urlError = nil
|
||||
}
|
||||
|
||||
init(error: Error) {
|
||||
self.data = nil
|
||||
self.httpUrlResponse = nil
|
||||
self.urlError = error as? URLError
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerResponse {
|
||||
// extract custom error message here
|
||||
var errorMessage: String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
extension URLSession {
|
||||
// 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 {
|
||||
static var defaultHeaders: [String: String] {
|
||||
var headers = [
|
||||
"content-type": "application/json",
|
||||
"user-agent": userAgent,
|
||||
"app-language": Locale.preferredLanguages[0]
|
||||
]
|
||||
|
||||
if let deviceLanguage = NSLocale.current.languageCode {
|
||||
headers["device-language"] = deviceLanguage
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
public static func webRequest(
|
||||
baseURL: URL,
|
||||
urlPath: String,
|
||||
queryParams: [String: String]?
|
||||
) -> URLRequest {
|
||||
create(
|
||||
baseURL: baseURL,
|
||||
urlPath: urlPath,
|
||||
requestMethod: .get,
|
||||
includeAuthToken: true,
|
||||
additionalHeaders: [:],
|
||||
queryParams: queryParams
|
||||
)
|
||||
}
|
||||
|
||||
static func create(
|
||||
baseURL: URL,
|
||||
urlPath: String,
|
||||
requestMethod: HTTPMethod,
|
||||
includeAuthToken: Bool = false,
|
||||
additionalHeaders: [String: String] = [:],
|
||||
queryParams: [String: String]? = nil
|
||||
) -> URLRequest {
|
||||
let url: URL = {
|
||||
var urlComponents = URLComponents()
|
||||
urlComponents.path = urlPath
|
||||
urlComponents.queryItems = queryParams?.map {
|
||||
URLQueryItem(
|
||||
name: $0.key,
|
||||
value: $0.value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
|
||||
)
|
||||
}
|
||||
return urlComponents.url(relativeTo: baseURL)!
|
||||
}()
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = requestMethod.stringValue
|
||||
|
||||
for (headerField, value) in defaultHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: headerField)
|
||||
}
|
||||
|
||||
if includeAuthToken, let authToken = ValetKey.authToken.value() {
|
||||
request.addValue(authToken, forHTTPHeaderField: "authorization")
|
||||
}
|
||||
|
||||
for (headerField, value) in additionalHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: headerField)
|
||||
}
|
||||
|
||||
switch requestMethod {
|
||||
case let .put(params), let .patch(params), let .post(params):
|
||||
request.httpBody = params
|
||||
case .get, .delete:
|
||||
break
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPMethod {
|
||||
case get
|
||||
case put(params: Data)
|
||||
case patch(params: Data)
|
||||
case post(params: Data)
|
||||
case delete
|
||||
|
||||
var stringValue: String {
|
||||
switch self {
|
||||
case .get:
|
||||
return "GET"
|
||||
case .put:
|
||||
return "PUT"
|
||||
case .patch:
|
||||
return "PATCH"
|
||||
case .post:
|
||||
return "POST"
|
||||
case .delete:
|
||||
return "DELETE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
|
||||
extension Networker {
|
||||
func createAccount(params: Data) -> AnyPublisher<AuthPayload, ServerError> {
|
||||
let urlRequest = URLRequest.create(
|
||||
baseURL: appEnvironment.serverBaseURL,
|
||||
urlPath: "/api/mobile-auth/create-account",
|
||||
requestMethod: .post(params: params)
|
||||
)
|
||||
|
||||
let resource = ServerResource<AuthPayload>(
|
||||
urlRequest: urlRequest,
|
||||
decode: AuthPayload.decode
|
||||
)
|
||||
|
||||
return urlSession
|
||||
.performRequest(resource: resource)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
extension Networker {
|
||||
func submitGoogleToken(idToken: String) -> AnyPublisher<AuthPayload, LoginError> {
|
||||
let params = SignInParams(token: idToken, provider: .google)
|
||||
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)
|
||||
.mapError { LoginError.make(serverError: $0) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
extension Networker {
|
||||
func createPendingUser(params: Data) -> AnyPublisher<PendingUserAuthPayload, ServerError> {
|
||||
let urlRequest = URLRequest.create(
|
||||
baseURL: appEnvironment.serverBaseURL,
|
||||
urlPath: "/api/mobile-auth/sign-up",
|
||||
requestMethod: .post(params: params)
|
||||
)
|
||||
|
||||
let resource = ServerResource<PendingUserAuthPayload>(
|
||||
urlRequest: urlRequest,
|
||||
decode: PendingUserAuthPayload.decode
|
||||
)
|
||||
|
||||
return urlSession
|
||||
.performRequest(resource: resource)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue