mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge branch 'main' into OMN-700
This commit is contained in:
commit
a8531b7ced
139 changed files with 32673 additions and 16243 deletions
|
|
@ -0,0 +1,184 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/1/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
class ExtensionSaveService {
|
||||
let queue: OperationQueue
|
||||
|
||||
init() {
|
||||
self.queue = OperationQueue()
|
||||
}
|
||||
|
||||
private func queueSaveOperation(_ pageScrape: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
|
||||
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
|
||||
guard !expiring else {
|
||||
self.queue.cancelAllOperations()
|
||||
self.queue.waitUntilAllOperationsAreFinished()
|
||||
return
|
||||
}
|
||||
|
||||
let operation = SaveOperation(pageScrapePayload: pageScrape, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
|
||||
|
||||
self.queue.addOperation(operation)
|
||||
self.queue.waitUntilAllOperationsAreFinished()
|
||||
}
|
||||
}
|
||||
|
||||
public func save(_ extensionContext: NSExtensionContext, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
|
||||
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
DispatchQueue.main.async {
|
||||
shareExtensionViewModel.status = .saved
|
||||
|
||||
let url = URLComponents(string: payload.url)
|
||||
let hostname = URL(string: payload.url)?.host ?? ""
|
||||
|
||||
switch payload.contentType {
|
||||
case let .html(html: _, title: title, iconURL: iconURL):
|
||||
shareExtensionViewModel.title = title
|
||||
shareExtensionViewModel.iconURL = iconURL
|
||||
shareExtensionViewModel.url = hostname
|
||||
case .none:
|
||||
shareExtensionViewModel.url = hostname
|
||||
shareExtensionViewModel.title = payload.url
|
||||
if var url = url {
|
||||
url.path = "/favicon.ico"
|
||||
shareExtensionViewModel.iconURL = url.url?.absoluteString
|
||||
}
|
||||
case let .pdf(localUrl: localUrl):
|
||||
shareExtensionViewModel.url = hostname
|
||||
shareExtensionViewModel.title = PDFUtils.titleFromPdfFile(localUrl.absoluteString)
|
||||
Task {
|
||||
let localThumbnail = try await PDFUtils.createThumbnailFor(inputUrl: localUrl)
|
||||
DispatchQueue.main.async {
|
||||
shareExtensionViewModel.iconURL = localThumbnail?.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.queueSaveOperation(payload, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
|
||||
case let .failure:
|
||||
DispatchQueue.main.async {
|
||||
shareExtensionViewModel.status = .failed(error: .unknown(description: "Could not retrieve content"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SaveOperation: Operation, URLSessionDelegate {
|
||||
let requestId: String
|
||||
let services: Services
|
||||
let pageScrapePayload: PageScrapePayload
|
||||
let shareExtensionViewModel: ShareExtensionChildViewModel
|
||||
|
||||
var queue: OperationQueue?
|
||||
var uploadTask: URLSessionTask?
|
||||
|
||||
enum State: Int {
|
||||
case created
|
||||
case started
|
||||
case finished
|
||||
}
|
||||
|
||||
init(pageScrapePayload: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
|
||||
self.pageScrapePayload = pageScrapePayload
|
||||
self.requestId = requestId
|
||||
self.shareExtensionViewModel = shareExtensionViewModel
|
||||
|
||||
self.state = .created
|
||||
self.services = Services()
|
||||
}
|
||||
|
||||
open var state: State = .created {
|
||||
willSet {
|
||||
willChangeValue(forKey: "isReady")
|
||||
willChangeValue(forKey: "isExecuting")
|
||||
willChangeValue(forKey: "isFinished")
|
||||
willChangeValue(forKey: "isCancelled")
|
||||
}
|
||||
didSet {
|
||||
didChangeValue(forKey: "isCancelled")
|
||||
didChangeValue(forKey: "isFinished")
|
||||
didChangeValue(forKey: "isExecuting")
|
||||
didChangeValue(forKey: "isReady")
|
||||
}
|
||||
}
|
||||
|
||||
override var isAsynchronous: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var isReady: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var isExecuting: Bool {
|
||||
self.state == .started
|
||||
}
|
||||
|
||||
override var isFinished: Bool {
|
||||
self.state == .finished
|
||||
}
|
||||
|
||||
override func start() {
|
||||
guard !isCancelled else { return }
|
||||
state = .started
|
||||
queue = OperationQueue()
|
||||
|
||||
Task {
|
||||
await persist(services: self.services, pageScrapePayload: self.pageScrapePayload, requestId: self.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
override func cancel() {
|
||||
super.cancel()
|
||||
}
|
||||
|
||||
private func updateStatus(newStatus: ShareExtensionStatus) {
|
||||
DispatchQueue.main.async {
|
||||
self.shareExtensionViewModel.status = newStatus
|
||||
}
|
||||
}
|
||||
|
||||
private func persist(services: Services, pageScrapePayload: PageScrapePayload, requestId: String) async {
|
||||
do {
|
||||
try await services.dataService.persistPageScrapePayload(pageScrapePayload, requestId: requestId)
|
||||
} catch {
|
||||
updateStatus(newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
updateStatus(newStatus: .saved)
|
||||
|
||||
switch pageScrapePayload.contentType {
|
||||
case .none:
|
||||
try await services.dataService.syncUrl(id: requestId, url: pageScrapePayload.url)
|
||||
case let .pdf(localUrl):
|
||||
try await services.dataService.syncPdf(id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url)
|
||||
case let .html(html, title, _):
|
||||
try await services.dataService.syncPage(id: requestId, originalHtml: html, title: title, url: pageScrapePayload.url)
|
||||
}
|
||||
|
||||
} catch {
|
||||
updateStatus(newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")))
|
||||
return
|
||||
}
|
||||
|
||||
updateStatus(newStatus: .synced)
|
||||
state = .finished
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,83 +20,35 @@ public extension PlatformViewController {
|
|||
}
|
||||
}
|
||||
|
||||
final class ShareExtensionViewModel: ObservableObject {
|
||||
public class ShareExtensionViewModel: ObservableObject {
|
||||
@Published var title: String?
|
||||
@Published var status: ShareExtensionStatus = FeatureFlag.enableReadNow ? .processing : .success
|
||||
@Published var status: ShareExtensionStatus = .processing
|
||||
@Published var debugText: String?
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
let requestID = UUID().uuidString.lowercased()
|
||||
|
||||
init() {}
|
||||
let saveService = ExtensionSaveService()
|
||||
let requestId = UUID().uuidString.lowercased()
|
||||
|
||||
func handleReadNowAction(extensionContext: NSExtensionContext?) {
|
||||
#if os(iOS)
|
||||
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
|
||||
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestID)")
|
||||
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)")
|
||||
application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
|
||||
}
|
||||
#endif
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
|
||||
func savePage(extensionContext: NSExtensionContext?) {
|
||||
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
self?.persist(pageScrapePayload: payload, requestId: self?.requestID ?? "")
|
||||
case let .failure(error):
|
||||
self?.debugText = error.message
|
||||
}
|
||||
func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) {
|
||||
if let extensionContext = extensionContext {
|
||||
saveService.save(extensionContext, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
|
||||
} else {
|
||||
updateStatus(.failed(error: .unknown(description: "Internal Error")))
|
||||
}
|
||||
}
|
||||
|
||||
private func persist(pageScrapePayload: PageScrapePayload, requestId: String) {
|
||||
let services = Services()
|
||||
|
||||
guard services.authenticator.hasValidAuthToken else {
|
||||
status = .failed(error: .unauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: requestId)
|
||||
let saveLinkPublisher: AnyPublisher<Void, SaveArticleError> = {
|
||||
if case let .pdf(data) = pageScrapePayload.contentType {
|
||||
return services.dataService.uploadPDFPublisher(pageScrapePayload: pageScrapePayload,
|
||||
data: data,
|
||||
requestId: requestId)
|
||||
} else if case let .html(html, title) = pageScrapePayload.contentType {
|
||||
return services.dataService.savePagePublisher(pageScrapePayload: pageScrapePayload,
|
||||
html: html,
|
||||
title: title,
|
||||
requestId: requestId)
|
||||
} else {
|
||||
return services.dataService.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
|
||||
}
|
||||
}()
|
||||
|
||||
saveLinkPublisher
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.debugText = "saveArticleError: \(error)"
|
||||
self?.status = .failed(error: error)
|
||||
UIApplication.shared.endBackgroundTask(backgroundTask)
|
||||
} receiveValue: { [weak self] _ in
|
||||
self?.status = .success
|
||||
UIApplication.shared.endBackgroundTask(backgroundTask)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Check connection to get fast feedback for auth/network errors
|
||||
Task {
|
||||
let hasConnectionAndValidToken = await services.dataService.hasConnectionAndValidToken()
|
||||
|
||||
if !hasConnectionAndValidToken {
|
||||
DispatchQueue.main.async {
|
||||
self.debugText = "saveArticleError: No connection or invalid token."
|
||||
self.status = .failed(error: .unknown(description: ""))
|
||||
}
|
||||
}
|
||||
private func updateStatus(_ newStatus: ShareExtensionStatus) {
|
||||
DispatchQueue.main.async {
|
||||
self.status = newStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,13 +56,12 @@ final class ShareExtensionViewModel: ObservableObject {
|
|||
struct ShareExtensionView: View {
|
||||
let extensionContext: NSExtensionContext?
|
||||
@StateObject private var viewModel = ShareExtensionViewModel()
|
||||
@StateObject private var childViewModel = ShareExtensionChildViewModel()
|
||||
|
||||
var body: some View {
|
||||
ShareExtensionChildView(
|
||||
debugText: viewModel.debugText,
|
||||
title: viewModel.title,
|
||||
status: viewModel.status,
|
||||
onAppearAction: { viewModel.savePage(extensionContext: extensionContext) },
|
||||
viewModel: childViewModel,
|
||||
onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
|
||||
readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
|
||||
dismissButtonTappedAction: { _, _ in
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import Utils
|
|||
@State private var shareLink: ShareLink?
|
||||
|
||||
init(remoteURL: URL, viewModel: PDFViewerViewModel) {
|
||||
self.pdfURL = viewModel.dataURL(remoteURL: remoteURL)
|
||||
self.pdfURL = viewModel.pdfItem.localPdfURL ?? remoteURL
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,28 +17,6 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
self.pdfItem = pdfItem
|
||||
}
|
||||
|
||||
public func dataURL(remoteURL: URL) -> URL {
|
||||
if let storedURL = storedURL {
|
||||
return storedURL
|
||||
}
|
||||
|
||||
guard let data = pdfItem.documentData else { return remoteURL }
|
||||
|
||||
let subPath = pdfItem.title.isEmpty ? UUID().uuidString : pdfItem.title
|
||||
|
||||
let path = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(subPath)
|
||||
|
||||
do {
|
||||
try data.write(to: path)
|
||||
storedURL = path
|
||||
return path
|
||||
} catch {
|
||||
return remoteURL
|
||||
}
|
||||
}
|
||||
|
||||
public func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
|
||||
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ import Views
|
|||
.onChange(of: viewModel.appliedFilter) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.appliedSort) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(mode: .item(item), onSave: nil)
|
||||
}
|
||||
|
|
@ -156,11 +159,23 @@ import Views
|
|||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeFilterButton(
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter"
|
||||
)
|
||||
}
|
||||
)
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
|
||||
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort"
|
||||
)
|
||||
}
|
||||
)
|
||||
TextChipButton.makeAddLabelButton {
|
||||
showLabelsSheet = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ import Views
|
|||
@Published var selectedLinkItem: NSManagedObjectID?
|
||||
@Published var linkRequest: LinkRequest?
|
||||
@Published var showLoadingBar = false
|
||||
@Published var appliedFilter = LinkedItemFilter.inbox.rawValue
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue)
|
||||
var appliedFilter = LinkedItemFilter.inbox.rawValue
|
||||
|
||||
var cursor: String?
|
||||
|
||||
|
|
@ -111,7 +112,6 @@ import Views
|
|||
|
||||
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)]
|
||||
|
||||
var subPredicates = [NSPredicate]()
|
||||
|
||||
|
|
@ -142,6 +142,8 @@ import Views
|
|||
}
|
||||
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
|
||||
fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: appliedSort) ?? .newest).sortDescriptors
|
||||
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +199,8 @@ import Views
|
|||
|
||||
private var searchQuery: String {
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter) ?? .inbox
|
||||
var query = "\(filter.queryString)"
|
||||
let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest
|
||||
var query = "\(filter.queryString) \(sort.queryString)"
|
||||
|
||||
if !searchTerm.isEmpty {
|
||||
query.append(" \(searchTerm)")
|
||||
|
|
|
|||
|
|
@ -34,43 +34,12 @@ struct ApplyLabelsView: View {
|
|||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = LabelsViewModel()
|
||||
|
||||
func isSelected(_ label: LinkedItemLabel) -> Bool {
|
||||
viewModel.selectedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
List {
|
||||
Section(header: Text("Assigned Labels")) {
|
||||
if viewModel.selectedLabels.isEmpty {
|
||||
Text("No labels are currently assigned.")
|
||||
}
|
||||
ForEach(viewModel.selectedLabels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
withAnimation {
|
||||
viewModel.removeLabelFromItem(label)
|
||||
}
|
||||
},
|
||||
label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Available Labels")) {
|
||||
ForEach(viewModel.unselectedLabels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
withAnimation {
|
||||
viewModel.addLabelToItem(label)
|
||||
}
|
||||
},
|
||||
label: { Image(systemName: "plus").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button(
|
||||
action: { viewModel.showCreateEmailModal = true },
|
||||
|
|
@ -84,6 +53,28 @@ struct ApplyLabelsView: View {
|
|||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
Section {
|
||||
ForEach(viewModel.labels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
|
||||
Button(
|
||||
action: {
|
||||
if isSelected(label) {
|
||||
viewModel.selectedLabels.removeAll(where: { $0.id == label.id })
|
||||
} else {
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
if isSelected(label) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(mode.navTitle)
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -12,10 +12,6 @@ struct FilterByLabelsView: View {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
// init(initiallySelected: [LinkedItemLabel], initiallyNegated: [LinkedItemLabel], onSave:) {
|
||||
//
|
||||
// }
|
||||
|
||||
func isNegated(_ label: LinkedItemLabel) -> Bool {
|
||||
viewModel.negatedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ struct ProfileView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private extension BasicWebAppView {
|
||||
extension BasicWebAppView {
|
||||
static func privacyPolicyWebView(baseURL: URL) -> BasicWebAppView {
|
||||
omnivoreWebView(path: "/app/privacy", baseURL: baseURL)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,69 +84,10 @@ final class RegistrationViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
struct RegistrationView: View {
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@StateObject private var viewModel = RegistrationViewModel()
|
||||
|
||||
var authenticationView: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 28) {
|
||||
if horizontalSizeClass == .regular {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text(LocalText.registrationViewHeadline)
|
||||
.font(.appTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, horizontalSizeClass == .compact ? 0 : 50)
|
||||
.padding(.top, horizontalSizeClass == .compact ? 30 : 0)
|
||||
|
||||
AppleSignInButton {
|
||||
viewModel.handleAppleSignInCompletion(result: $0, authenticator: authenticator)
|
||||
}
|
||||
|
||||
if AppKeys.sharedInstance?.iosClientGoogleId != nil {
|
||||
GoogleAuthButton {
|
||||
viewModel.handleGoogleAuth(authenticator: authenticator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let loginError = viewModel.loginError {
|
||||
LoginErrorMessageView(loginError: loginError)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: 316)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let registrationState = viewModel.registrationState {
|
||||
if case let RegistrationViewModel.RegistrationState.createProfile(userProfile) = registrationState {
|
||||
CreateProfileView(userProfile: userProfile)
|
||||
} else if case let RegistrationViewModel.RegistrationState.newAppleSignUp(userProfile) = registrationState {
|
||||
NewAppleSignupView(
|
||||
userProfile: userProfile,
|
||||
showProfileEditView: { viewModel.registrationState = .createProfile(userProfile: userProfile) }
|
||||
)
|
||||
} else {
|
||||
authenticationView
|
||||
}
|
||||
} else {
|
||||
authenticationView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentingViewController() -> PlatformViewController? {
|
||||
#if os(iOS)
|
||||
return UIApplication.shared.windows
|
||||
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
|
||||
return scene?.windows
|
||||
.filter(\.isKeyWindow)
|
||||
.first?
|
||||
.rootViewController
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ import WebKit
|
|||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityRatioUpdater: (Double) -> Void
|
||||
|
||||
@Binding var increaseFontActionID: UUID?
|
||||
@Binding var decreaseFontActionID: UUID?
|
||||
@Binding var updateFontFamilyActionID: UUID?
|
||||
@Binding var updateFontActionID: UUID?
|
||||
@Binding var updateTextContrastActionID: UUID?
|
||||
@Binding var updateMarginActionID: UUID?
|
||||
@Binding var updateLineHeightActionID: UUID?
|
||||
@Binding var annotationSaveTransactionID: UUID?
|
||||
@Binding var showNavBarActionID: UUID?
|
||||
@Binding var shareActionID: UUID?
|
||||
|
|
@ -29,6 +32,16 @@ import WebKit
|
|||
return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize
|
||||
}
|
||||
|
||||
func lineHeight() -> Int {
|
||||
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebLineSpacing.rawValue)
|
||||
return storedSize <= 1 ? 150 : storedSize
|
||||
}
|
||||
|
||||
func margin() -> Int {
|
||||
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebMargin.rawValue)
|
||||
return storedSize <= 1 ? 360 : storedSize
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WebViewManager.shared()
|
||||
let contentController = WKUserContentController()
|
||||
|
|
@ -64,17 +77,32 @@ import WebKit
|
|||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
|
||||
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
|
||||
(webView as? WebView)?.saveAnnotation(annotation: annotation)
|
||||
(webView as? WebView)?.dispatchEvent(.saveAnnotation(annotation: annotation))
|
||||
}
|
||||
|
||||
if increaseFontActionID != context.coordinator.previousIncreaseFontActionID {
|
||||
context.coordinator.previousIncreaseFontActionID = increaseFontActionID
|
||||
(webView as? WebView)?.increaseFontSize()
|
||||
if updateFontFamilyActionID != context.coordinator.previousUpdateFontFamilyActionID {
|
||||
context.coordinator.previousUpdateFontFamilyActionID = updateFontFamilyActionID
|
||||
(webView as? WebView)?.updateFontFamily()
|
||||
}
|
||||
|
||||
if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID {
|
||||
context.coordinator.previousDecreaseFontActionID = decreaseFontActionID
|
||||
(webView as? WebView)?.decreaseFontSize()
|
||||
if updateFontActionID != context.coordinator.previousUpdateFontActionID {
|
||||
context.coordinator.previousUpdateFontActionID = updateFontActionID
|
||||
(webView as? WebView)?.updateFontSize()
|
||||
}
|
||||
|
||||
if updateTextContrastActionID != context.coordinator.previousUpdateTextContrastActionID {
|
||||
context.coordinator.previousUpdateTextContrastActionID = updateTextContrastActionID
|
||||
(webView as? WebView)?.updateTextContrast()
|
||||
}
|
||||
|
||||
if updateMarginActionID != context.coordinator.previousUpdateMarginActionID {
|
||||
context.coordinator.previousUpdateMarginActionID = updateMarginActionID
|
||||
(webView as? WebView)?.updateMargin()
|
||||
}
|
||||
|
||||
if updateLineHeightActionID != context.coordinator.previousUpdateLineHeightActionID {
|
||||
context.coordinator.previousUpdateLineHeightActionID = updateLineHeightActionID
|
||||
(webView as? WebView)?.updateLineHeight()
|
||||
}
|
||||
|
||||
if showNavBarActionID != context.coordinator.previousShowNavBarActionID {
|
||||
|
|
@ -110,13 +138,19 @@ import WebKit
|
|||
}
|
||||
|
||||
func loadContent(webView: WKWebView) {
|
||||
let fontFamilyValue = UserDefaults.standard.string(forKey: UserDefaultKey.preferredWebFont.rawValue)
|
||||
let fontFamily = fontFamilyValue.flatMap { WebFont(rawValue: $0) } ?? .inter
|
||||
|
||||
webView.loadHTMLString(
|
||||
WebReaderContent(
|
||||
htmlContent: htmlContent,
|
||||
highlightsJSONString: highlightsJSONString,
|
||||
item: item,
|
||||
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
|
||||
fontSize: fontSize()
|
||||
fontSize: fontSize(),
|
||||
lineHeight: lineHeight(),
|
||||
margin: margin(),
|
||||
fontFamily: fontFamily
|
||||
)
|
||||
.styledContent,
|
||||
baseURL: ViewsPackage.bundleURL
|
||||
|
|
|
|||
|
|
@ -8,15 +8,18 @@ import WebKit
|
|||
struct WebReaderContainerView: View {
|
||||
let item: LinkedItem
|
||||
|
||||
@State private var showFontSizePopover = false
|
||||
@State private var showPreferencesPopover = false
|
||||
@State private var showLabelsModal = false
|
||||
@State var showHighlightAnnotationModal = false
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var progressViewOpacity = 0.0
|
||||
@State var increaseFontActionID: UUID?
|
||||
@State var decreaseFontActionID: UUID?
|
||||
@State var updateFontFamilyActionID: UUID?
|
||||
@State var updateFontActionID: UUID?
|
||||
@State var updateTextContrastActionID: UUID?
|
||||
@State var updateMarginActionID: UUID?
|
||||
@State var updateLineHeightActionID: UUID?
|
||||
@State var annotationSaveTransactionID: UUID?
|
||||
@State var showNavBarActionID: UUID?
|
||||
@State var shareActionID: UUID?
|
||||
|
|
@ -26,13 +29,6 @@ import WebKit
|
|||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
@StateObject var viewModel = WebReaderViewModel()
|
||||
|
||||
var fontAdjustmentPopoverView: some View {
|
||||
FontSizeAdjustmentPopoverView(
|
||||
increaseFontAction: { increaseFontActionID = UUID() },
|
||||
decreaseFontAction: { decreaseFontActionID = UUID() }
|
||||
)
|
||||
}
|
||||
|
||||
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
|
||||
if let replyHandler = replyHandler {
|
||||
viewModel.webViewActionWithReplyHandler(
|
||||
|
|
@ -75,7 +71,7 @@ import WebKit
|
|||
.scaleEffect(navBarVisibilityRatio)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
action: { showPreferencesPopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
|
|
@ -122,9 +118,6 @@ import WebKit
|
|||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
Snackbar.show(message: "Link removed")
|
||||
|
|
@ -153,13 +146,13 @@ import WebKit
|
|||
},
|
||||
webViewActionHandler: webViewActionHandler,
|
||||
navBarVisibilityRatioUpdater: {
|
||||
if $0 < 1 {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
navBarVisibilityRatio = $0
|
||||
},
|
||||
increaseFontActionID: $increaseFontActionID,
|
||||
decreaseFontActionID: $decreaseFontActionID,
|
||||
updateFontFamilyActionID: $updateFontFamilyActionID,
|
||||
updateFontActionID: $updateFontActionID,
|
||||
updateTextContrastActionID: $updateTextContrastActionID,
|
||||
updateMarginActionID: $updateMarginActionID,
|
||||
updateLineHeightActionID: $updateLineHeightActionID,
|
||||
annotationSaveTransactionID: $annotationSaveTransactionID,
|
||||
showNavBarActionID: $showNavBarActionID,
|
||||
shareActionID: $shareActionID,
|
||||
|
|
@ -200,33 +193,22 @@ import WebKit
|
|||
await viewModel.loadContent(dataService: dataService, itemID: item.unwrappedID)
|
||||
}
|
||||
}
|
||||
if showFontSizePopover {
|
||||
VStack {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.frame(height: LinkItemDetailView.navBarHeight)
|
||||
HStack {
|
||||
Spacer()
|
||||
fontAdjustmentPopoverView
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
.padding(.trailing, 44)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.background(
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
}.onDisappear {
|
||||
}
|
||||
.formSheet(isPresented: $showPreferencesPopover, useSmallDetent: false) {
|
||||
WebPreferencesPopoverView(
|
||||
updateFontFamilyAction: { updateFontFamilyActionID = UUID() },
|
||||
updateFontAction: { updateFontActionID = UUID() },
|
||||
updateTextContrastAction: { updateTextContrastActionID = UUID() },
|
||||
updateMarginAction: { updateMarginActionID = UUID() },
|
||||
updateLineHeightAction: { updateLineHeightActionID = UUID() },
|
||||
dismissAction: { showPreferencesPopover = false }
|
||||
)
|
||||
}
|
||||
.onDisappear {
|
||||
// Clear the shared webview content when exiting
|
||||
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,36 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
struct WebReaderContent {
|
||||
let textFontSize: Int
|
||||
let lineHeight: Int
|
||||
let margin: Int
|
||||
let htmlContent: String
|
||||
let highlightsJSONString: String
|
||||
let item: LinkedItem
|
||||
let themeKey: String
|
||||
let fontFamily: WebFont
|
||||
|
||||
init(
|
||||
htmlContent: String,
|
||||
highlightsJSONString: String,
|
||||
item: LinkedItem,
|
||||
isDark: Bool,
|
||||
fontSize: Int
|
||||
fontSize: Int,
|
||||
lineHeight: Int,
|
||||
margin: Int,
|
||||
fontFamily: WebFont
|
||||
) {
|
||||
self.textFontSize = fontSize
|
||||
self.lineHeight = lineHeight
|
||||
self.margin = margin
|
||||
self.htmlContent = htmlContent
|
||||
self.highlightsJSONString = highlightsJSONString
|
||||
self.item = item
|
||||
self.themeKey = isDark ? "Gray" : "LightGray"
|
||||
self.fontFamily = fontFamily
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
|
|
@ -71,6 +81,9 @@ struct WebReaderContent {
|
|||
}
|
||||
|
||||
window.fontSize = \(textFontSize)
|
||||
window.fontFamily = "\(fontFamily.rawValue)"
|
||||
window.margin = \(margin)
|
||||
window.lineHeight = \(lineHeight)
|
||||
window.localStorage.setItem("theme", "\(themeKey)")
|
||||
</script>
|
||||
<script src="bundle.js"></script>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ final class WebReaderCoordinator: NSObject {
|
|||
var linkHandler: (URL) -> Void = { _ in }
|
||||
var needsReload = false
|
||||
var lastSavedAnnotationID: UUID?
|
||||
var previousIncreaseFontActionID: UUID?
|
||||
var previousDecreaseFontActionID: UUID?
|
||||
var previousUpdateFontFamilyActionID: UUID?
|
||||
var previousUpdateFontActionID: UUID?
|
||||
var previousUpdateTextContrastActionID: UUID?
|
||||
var previousUpdateMarginActionID: UUID?
|
||||
var previousUpdateLineHeightActionID: UUID?
|
||||
var previousShowNavBarActionID: UUID?
|
||||
var previousShareActionID: UUID?
|
||||
var updateNavBarVisibilityRatio: (Double) -> Void = { _ in }
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import Utils
|
|||
|
||||
guard let username = username else { return }
|
||||
|
||||
// If the page was locally created, make sure they are synced before we pull content
|
||||
await dataService.syncUnsyncedArticleContent(itemID: requestID)
|
||||
await fetchLinkedItem(dataService: dataService, requestID: requestID, username: username)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,16 +12,19 @@ struct SafariWebLink: Identifiable {
|
|||
@Published var articleContent: ArticleContent?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
func loadContent(dataService: DataService, itemID: String) async {
|
||||
func loadContent(dataService: DataService, itemID: String, retryCount: Int = 0) async {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
articleContent = try await dataService.fetchArticleContent(itemID: itemID)
|
||||
} catch {
|
||||
if retryCount == 0 {
|
||||
return await loadContent(dataService: dataService, itemID: itemID, retryCount: 1)
|
||||
}
|
||||
if let fetchError = error as? ContentFetchError {
|
||||
switch fetchError {
|
||||
case .network:
|
||||
errorMessage = "We were unable to retrieve your content. Please ccheck network connectivity and try again."
|
||||
errorMessage = "We were unable to retrieve your content. Please check network connectivity and try again."
|
||||
default:
|
||||
errorMessage = "We were unable to parse your content."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,19 @@ import Views
|
|||
|
||||
struct WelcomeView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
|
||||
@StateObject private var viewModel = RegistrationViewModel()
|
||||
|
||||
@State private var showRegistrationView = false
|
||||
@State private var isKeyboardOnScreen = false
|
||||
@State private var showDebugModal = false
|
||||
@State private var showTermsLinks = false
|
||||
@State private var showTermsModal = false
|
||||
@State private var showPrivacyModal = false
|
||||
@State private var showAboutPage = false
|
||||
@State private var selectedEnvironment = AppEnvironment.initialAppEnvironment
|
||||
@State private var containerSize: CGSize = .zero
|
||||
|
||||
func handleHiddenGestureAction() {
|
||||
if !Bundle.main.isAppStoreBuild {
|
||||
|
|
@ -19,68 +27,183 @@ struct WelcomeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func userInteractiveView(width: CGFloat) -> some View {
|
||||
var headlineText: some View {
|
||||
Group {
|
||||
if showRegistrationView {
|
||||
RegistrationView()
|
||||
if horizontalSizeClass == .compact {
|
||||
Text("Everything you read. Safe, organized, and easy to share.")
|
||||
} else {
|
||||
GetStartedView(showRegistrationView: $showRegistrationView)
|
||||
Text("Everything you read. Safe,\norganized, and easy to share.")
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.zIndex(2)
|
||||
.font(.appLargeTitle)
|
||||
}
|
||||
|
||||
@ViewBuilder func primaryContent() -> some View {
|
||||
if horizontalSizeClass == .compact {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Color.systemBackground
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
var headlineView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
headlineText
|
||||
|
||||
if geometry.size.width < geometry.size.height, !isKeyboardOnScreen {
|
||||
VStack {
|
||||
Color.appDeepBackground.frame(height: 100)
|
||||
Spacer()
|
||||
}
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
VStack {
|
||||
if geometry.size.width < geometry.size.height, !isKeyboardOnScreen {
|
||||
RegistrationHeroImageView(tapGestureHandler: handleHiddenGestureAction)
|
||||
}
|
||||
userInteractiveView(width: geometry.size.width)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showAboutPage = true },
|
||||
label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Learn more")
|
||||
Image(systemName: "arrow.right")
|
||||
}
|
||||
.font(.appTitleThree)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
}
|
||||
|
||||
var footerView: some View {
|
||||
Group {
|
||||
Text("By signing up, you agree to Omnivore’s\n")
|
||||
+ Text("Terms of Service").underline()
|
||||
+ Text(" and ")
|
||||
+ Text("Privacy Policy").underline()
|
||||
}
|
||||
.font(.appSubheadline)
|
||||
.confirmationDialog("", isPresented: $showTermsLinks, titleVisibility: .hidden) {
|
||||
Button("View Terms of Service") {
|
||||
showTermsModal = true
|
||||
}
|
||||
} else {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
SplitColorBackground(width: geometry.size.width)
|
||||
|
||||
VStack {
|
||||
TitleLogoView(handleHiddenGestureAction: handleHiddenGestureAction)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
Button("View Privacy Policy") {
|
||||
showPrivacyModal = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPrivacyModal) {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
showPrivacyModal = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTermsModal) {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
showTermsModal = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
BasicWebAppView.termsConditionsWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAboutPage) {
|
||||
if let url = URL(string: "https://omnivore.app/about") {
|
||||
SafariView(url: url)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
showTermsLinks = true
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
userInteractiveView(width: geometry.size.width * 0.5)
|
||||
ReadingIllustrationXXLView(width: geometry.size.width * 0.5)
|
||||
var logoView: some View {
|
||||
Image.omnivoreTitleLogo
|
||||
.gesture(
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
if !Bundle.main.isAppStoreBuild {
|
||||
showDebugModal = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var authProviderButtonStack: some View {
|
||||
let useHorizontalLayout = containerSize.width > 400
|
||||
|
||||
let buttonGroup = Group {
|
||||
AppleSignInButton {
|
||||
viewModel.handleAppleSignInCompletion(result: $0, authenticator: authenticator)
|
||||
}
|
||||
|
||||
if AppKeys.sharedInstance?.iosClientGoogleId != nil {
|
||||
GoogleAuthButton {
|
||||
viewModel.handleGoogleAuth(authenticator: authenticator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
if useHorizontalLayout {
|
||||
HStack { buttonGroup }
|
||||
} else {
|
||||
buttonGroup
|
||||
}
|
||||
|
||||
if let loginError = viewModel.loginError {
|
||||
HStack {
|
||||
LoginErrorMessageView(loginError: loginError)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
primaryContent()
|
||||
.sheet(isPresented: $showDebugModal) {
|
||||
DebugMenuView(selectedEnvironment: $selectedEnvironment)
|
||||
ZStack(alignment: .leading) {
|
||||
Color.appBackground
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.modifier(SizeModifier())
|
||||
.onPreferenceChange(SizePreferenceKey.self) {
|
||||
self.containerSize = $0
|
||||
}
|
||||
if let registrationState = viewModel.registrationState {
|
||||
if case let RegistrationViewModel.RegistrationState.createProfile(userProfile) = registrationState {
|
||||
CreateProfileView(userProfile: userProfile)
|
||||
} else if case let RegistrationViewModel.RegistrationState.newAppleSignUp(userProfile) = registrationState {
|
||||
NewAppleSignupView(
|
||||
userProfile: userProfile,
|
||||
showProfileEditView: { viewModel.registrationState = .createProfile(userProfile: userProfile) }
|
||||
)
|
||||
} else {
|
||||
EmptyView() // will never be called
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: containerSize.height < 500 ? 12 : 50) {
|
||||
logoView
|
||||
.padding(.bottom, 20)
|
||||
headlineView
|
||||
if containerSize.width > 400 {
|
||||
authProviderButtonStack
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
authProviderButtonStack
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
footerView
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showDebugModal) {
|
||||
DebugMenuView(selectedEnvironment: $selectedEnvironment)
|
||||
}
|
||||
}
|
||||
.onReceive(Publishers.keyboardHeight) { isKeyboardOnScreen = $0 > 1 }
|
||||
.onAppear { selectedEnvironment = dataService.appEnvironment }
|
||||
}
|
||||
.preferredColorScheme(.light)
|
||||
.task { selectedEnvironment = dataService.appEnvironment }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="annotation" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
|
@ -29,7 +29,9 @@
|
|||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="imageURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="isArchived" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="localPdfURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="onDeviceImageURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="originalHtml" optional="YES" attributeType="String"/>
|
||||
<attribute name="pageURLString" attributeType="String"/>
|
||||
<attribute name="pdfData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="publishDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
|
@ -41,6 +43,7 @@
|
|||
<attribute name="siteName" optional="YES" attributeType="String"/>
|
||||
<attribute name="slug" attributeType="String"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
|
||||
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
|
||||
<uniquenessConstraints>
|
||||
|
|
@ -86,7 +89,7 @@
|
|||
</entity>
|
||||
<elements>
|
||||
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
|
||||
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="359"/>
|
||||
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="404"/>
|
||||
<element name="LinkedItemLabel" positionX="-36" positionY="18" width="128" height="134"/>
|
||||
<element name="NewsletterEmail" positionX="0" positionY="180" width="128" height="74"/>
|
||||
<element name="Viewer" positionX="45" positionY="234" width="128" height="89"/>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public struct JSONArticle: Decodable {
|
|||
public let id: String
|
||||
public let title: String
|
||||
public let createdAt: Date
|
||||
public let updatedAt: Date
|
||||
public let savedAt: Date
|
||||
public let image: String
|
||||
public let readingProgressPercent: Double
|
||||
|
|
@ -116,6 +117,7 @@ public extension LinkedItem {
|
|||
}
|
||||
|
||||
guard context.hasChanges else { return }
|
||||
self.updatedAt = Date()
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ public struct PDFItem {
|
|||
public let objectID: NSManagedObjectID
|
||||
public let itemID: String
|
||||
public let pdfURL: URL?
|
||||
public let documentData: Data?
|
||||
public let localPdfURL: URL?
|
||||
public let title: String
|
||||
public let slug: String
|
||||
public let readingProgress: Double
|
||||
|
|
@ -22,7 +22,7 @@ public struct PDFItem {
|
|||
objectID: item.objectID,
|
||||
itemID: item.unwrappedID,
|
||||
pdfURL: URL(string: item.unwrappedPageURLString),
|
||||
documentData: item.pdfData,
|
||||
localPdfURL: item.localPdfURL.flatMap { URL(string: $0) },
|
||||
title: item.unwrappedID,
|
||||
slug: item.unwrappedSlug,
|
||||
readingProgress: item.readingProgress,
|
||||
|
|
|
|||
54
apple/OmnivoreKit/Sources/Models/LinkedItemSort.swift
Normal file
54
apple/OmnivoreKit/Sources/Models/LinkedItemSort.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import Foundation
|
||||
|
||||
public enum LinkedItemSort: String, CaseIterable {
|
||||
case newest
|
||||
case oldest
|
||||
// case recentlyRead
|
||||
case recentlyPublished
|
||||
// case relevance
|
||||
}
|
||||
|
||||
public extension LinkedItemSort {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .newest:
|
||||
return "Newest"
|
||||
case .oldest:
|
||||
return "Oldest"
|
||||
// case .recentlyRead:
|
||||
// return "Recently Read"
|
||||
case .recentlyPublished:
|
||||
return "Recently Published"
|
||||
// case .relevance:
|
||||
// return "Relevance"
|
||||
}
|
||||
}
|
||||
|
||||
var queryString: String {
|
||||
switch self {
|
||||
case .newest:
|
||||
return "sort:saved"
|
||||
case .oldest:
|
||||
return "sort:saved-ASC"
|
||||
// case .recentlyRead:
|
||||
// return "sort:updated"
|
||||
case .recentlyPublished:
|
||||
return "sort:published"
|
||||
// case .relevance:
|
||||
// return "relevance"
|
||||
}
|
||||
}
|
||||
|
||||
var sortDescriptors: [NSSortDescriptor] {
|
||||
switch self {
|
||||
case .newest /* , .relevance */:
|
||||
return [NSSortDescriptor(keyPath: \LinkedItem.createdAt, ascending: false)]
|
||||
case .oldest:
|
||||
return [NSSortDescriptor(keyPath: \LinkedItem.createdAt, ascending: true)]
|
||||
// case .recentlyRead:
|
||||
// return [NSSortDescriptor(keyPath: \LinkedItem.updatedAt, ascending: false)]
|
||||
case .recentlyPublished:
|
||||
return [NSSortDescriptor(keyPath: \LinkedItem.publishDate, ascending: false)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,16 +9,10 @@ import UniformTypeIdentifiers
|
|||
let URLREGEX = #"[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)"#
|
||||
|
||||
public struct PageScrapePayload {
|
||||
public struct HTMLPayload {
|
||||
let url: String
|
||||
let title: String?
|
||||
let html: String
|
||||
}
|
||||
|
||||
public enum ContentType {
|
||||
case none
|
||||
case html(html: String, title: String?)
|
||||
case pdf(data: Data)
|
||||
case html(html: String, title: String?, iconURL: String?)
|
||||
case pdf(localUrl: URL)
|
||||
}
|
||||
|
||||
public let url: String
|
||||
|
|
@ -29,14 +23,14 @@ public struct PageScrapePayload {
|
|||
self.contentType = .none
|
||||
}
|
||||
|
||||
init(url: String, pdfData: Data) {
|
||||
init(url: String, localUrl: URL) {
|
||||
self.url = url
|
||||
self.contentType = .pdf(data: pdfData)
|
||||
self.contentType = .pdf(localUrl: localUrl)
|
||||
}
|
||||
|
||||
init(url: String, title: String?, html: String) {
|
||||
init(url: String, title: String?, html: String, iconURL: String? = nil) {
|
||||
self.url = url
|
||||
self.contentType = .html(html: html, title: title)
|
||||
self.contentType = .html(html: html, title: title, iconURL: iconURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,12 +242,28 @@ private extension PageScrapePayload {
|
|||
return nil
|
||||
}
|
||||
|
||||
static func sharedContainerURL() -> URL {
|
||||
FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.app.omnivoreapp"
|
||||
)!
|
||||
}
|
||||
|
||||
static func makeFromURL(_ url: URL) -> PageScrapePayload? {
|
||||
if url.isFileURL {
|
||||
let type = try? url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier
|
||||
if type == UTType.pdf.identifier, let data = try? Data(contentsOf: url) {
|
||||
return PageScrapePayload(url: url.absoluteString, pdfData: data)
|
||||
if type == UTType.pdf.identifier {
|
||||
// Copy PDFs into a temporary file where they are staged for processing.
|
||||
var dest = sharedContainerURL()
|
||||
let localFile = UUID().uuidString.lowercased() + ".pdf"
|
||||
dest.appendPathComponent(localFile)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
return PageScrapePayload(url: url.absoluteString, localUrl: dest)
|
||||
} catch {
|
||||
print("error copying file locally", error)
|
||||
}
|
||||
}
|
||||
// TODO:
|
||||
// Don't try to handle file URLs that are not PDFs.
|
||||
// In the future we can add image and other file type support here
|
||||
return nil
|
||||
|
|
@ -264,8 +274,9 @@ private extension PageScrapePayload {
|
|||
static func makeFromDictionary(_ dictionary: NSDictionary) -> PageScrapePayload? {
|
||||
let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary
|
||||
guard let url = results?["url"] as? String else { return nil }
|
||||
let html = results?["documentHTML"] as? String
|
||||
let html = results?["originalHTML"] as? String
|
||||
let title = results?["title"] as? String
|
||||
let iconURL = results?["iconURL"] as? String
|
||||
let contentType = results?["contentType"] as? String
|
||||
|
||||
// If we were not able to capture any HTML, treat this as a URL and
|
||||
|
|
@ -281,7 +292,7 @@ private extension PageScrapePayload {
|
|||
}
|
||||
|
||||
if let html = html {
|
||||
return PageScrapePayload(url: url, title: title, html: html)
|
||||
return PageScrapePayload(url: url, title: title, html: html, iconURL: iconURL)
|
||||
}
|
||||
|
||||
return PageScrapePayload(url: url)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import CoreImage
|
||||
import Foundation
|
||||
import Models
|
||||
import OSLog
|
||||
import QuickLookThumbnailing
|
||||
import UIKit
|
||||
import Utils
|
||||
|
||||
let logger = Logger(subsystem: "app.omnivore", category: "data-service")
|
||||
|
|
@ -12,10 +15,10 @@ public final class DataService: ObservableObject {
|
|||
public static var showIntercomMessenger: (() -> Void)?
|
||||
|
||||
public let appEnvironment: AppEnvironment
|
||||
let networker: Networker
|
||||
public let networker: Networker
|
||||
|
||||
var persistentContainer: PersistentContainer
|
||||
var backgroundContext: NSManagedObjectContext
|
||||
public var backgroundContext: NSManagedObjectContext
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
public var viewContext: NSManagedObjectContext {
|
||||
|
|
@ -29,7 +32,7 @@ public final class DataService: ObservableObject {
|
|||
self.backgroundContext = persistentContainer.newBackgroundContext()
|
||||
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
|
||||
if isFirstTimeRunningNewAppVersion() {
|
||||
if isFirstTimeRunningNewAppBuild() {
|
||||
resetCoreData()
|
||||
} else {
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
|
|
@ -88,13 +91,85 @@ public final class DataService: ObservableObject {
|
|||
backgroundContext = persistentContainer.newBackgroundContext()
|
||||
}
|
||||
|
||||
private func isFirstTimeRunningNewAppVersion() -> Bool {
|
||||
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")
|
||||
guard let appVersion = appVersion as? String else { return false }
|
||||
private func isFirstTimeRunningNewAppBuild() -> Bool {
|
||||
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
|
||||
guard let appVersion = appVersion, let buildNumber = buildNumber else { return false }
|
||||
|
||||
let lastUsedAppVersion = UserDefaults.standard.string(forKey: UserDefaultKey.lastUsedAppVersion.rawValue)
|
||||
let isFirstRun = (lastUsedAppVersion ?? "unknown") != appVersion
|
||||
UserDefaults.standard.set(appVersion, forKey: UserDefaultKey.lastUsedAppVersion.rawValue)
|
||||
return isFirstRun
|
||||
|
||||
let lastUsedAppBuildNumber = UserDefaults.standard.string(forKey: UserDefaultKey.lastUsedAppBuildNumber.rawValue)
|
||||
UserDefaults.standard.set(buildNumber, forKey: UserDefaultKey.lastUsedAppBuildNumber.rawValue)
|
||||
|
||||
let isFirstRunOfVersion = (lastUsedAppVersion ?? "unknown") != appVersion
|
||||
let isFirstRunWithBuildNumber = (lastUsedAppBuildNumber ?? "unknown") != buildNumber
|
||||
|
||||
return isFirstRunOfVersion || isFirstRunWithBuildNumber
|
||||
}
|
||||
|
||||
public func persistPageScrapePayload(_ pageScrape: PageScrapePayload, requestId: String) async throws {
|
||||
let normalizedURL = normalizeURL(pageScrape.url)
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "pageURLString = %@", normalizedURL)
|
||||
|
||||
let currentTime = Date()
|
||||
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
|
||||
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
|
||||
|
||||
linkedItem.id = existingItem?.unwrappedID ?? requestId
|
||||
linkedItem.title = normalizedURL
|
||||
linkedItem.pageURLString = normalizedURL
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsCreation.rawValue)
|
||||
linkedItem.savedAt = currentTime
|
||||
linkedItem.createdAt = currentTime
|
||||
linkedItem.isArchived = false
|
||||
|
||||
linkedItem.imageURLString = nil
|
||||
linkedItem.onDeviceImageURLString = nil
|
||||
linkedItem.descriptionText = nil
|
||||
linkedItem.publisherURLString = nil
|
||||
linkedItem.author = nil
|
||||
linkedItem.publishDate = nil
|
||||
|
||||
if let currentViewer = self.currentViewer {
|
||||
linkedItem.slug = "\(currentViewer)/\(requestId)"
|
||||
} else {
|
||||
// Technically this is invalid, but I don't think slug is used at all locally anymore
|
||||
linkedItem.slug = requestId
|
||||
}
|
||||
|
||||
switch pageScrape.contentType {
|
||||
case let .pdf(localUrl):
|
||||
linkedItem.contentReader = "PDF"
|
||||
linkedItem.localPdfURL = localUrl.absoluteString
|
||||
linkedItem.title = PDFUtils.titleFromPdfFile(pageScrape.url)
|
||||
// let thumbnailUrl = PDFUtils.thumbnailUrl(localUrl: localUrl)
|
||||
// linkedItem.imageURLString = await PDFUtils.createThumbnailFor(inputUrl: localUrl, at: thumbnailUrl)
|
||||
|
||||
case let .html(html: html, title: title, iconURL: iconURL):
|
||||
linkedItem.contentReader = "WEB"
|
||||
linkedItem.originalHtml = html
|
||||
linkedItem.imageURLString = iconURL
|
||||
linkedItem.title = title ?? PDFUtils.titleFromPdfFile(pageScrape.url)
|
||||
case .none:
|
||||
print("SAVING URL", linkedItem.unwrappedPageURLString)
|
||||
linkedItem.contentReader = "WEB"
|
||||
}
|
||||
|
||||
do {
|
||||
try self.backgroundContext.save()
|
||||
logger.debug("ArticleContent saved succesfully")
|
||||
} catch {
|
||||
self.backgroundContext.rollback()
|
||||
|
||||
print("Failed to save ArticleContent", error.localizedDescription, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,16 +24,16 @@ extension DataService {
|
|||
|
||||
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) }
|
||||
archiveLinkError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
archiveLinkSuccess: .init { .success(linkId: try $0.linkId()) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLinkArchived(
|
||||
input: InputObjects.ArchiveLinkInput(
|
||||
linkId: itemID,
|
||||
archived: archived
|
||||
archived: archived,
|
||||
linkId: itemID
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,22 +40,22 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.CreateHighlightResult> {
|
||||
try $0.on(
|
||||
createHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) },
|
||||
createHighlightSuccess: .init {
|
||||
.saved(highlight: try $0.highlight(selection: highlightSelection))
|
||||
},
|
||||
createHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.createHighlight(
|
||||
input: InputObjects.CreateHighlightInput(
|
||||
id: highlight.id,
|
||||
shortId: highlight.shortId,
|
||||
annotation: OptionalArgument(highlight.annotation),
|
||||
articleId: articleId,
|
||||
id: highlight.id,
|
||||
patch: highlight.patch,
|
||||
quote: highlight.quote,
|
||||
annotation: OptionalArgument(highlight.annotation)
|
||||
shortId: highlight.shortId
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,17 +34,17 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.CreateLabelResult> {
|
||||
try $0.on(
|
||||
createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) },
|
||||
createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.createLabel(
|
||||
input: InputObjects.CreateLabelInput(
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: OptionalArgument(label.labelDescription)
|
||||
description: OptionalArgument(label.labelDescription),
|
||||
name: label.name
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.CreateNewsletterEmailResult> {
|
||||
try $0.on(
|
||||
createNewsletterEmailError: .init {
|
||||
.error(errorCode: try $0.errorCodes().first ?? .badRequest)
|
||||
},
|
||||
createNewsletterEmailSuccess: .init {
|
||||
.saved(newsletterEmail: try $0.newsletterEmail(selection: Selection.NewsletterEmail {
|
||||
InternalNewsletterEmail(
|
||||
|
|
@ -20,9 +23,6 @@ public extension DataService {
|
|||
confirmationCode: try $0.confirmationCode()
|
||||
)
|
||||
}))
|
||||
},
|
||||
createNewsletterEmailError: .init {
|
||||
.error(errorCode: try $0.errorCodes().first ?? .badRequest)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,21 +37,21 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.CreateReminderResult> {
|
||||
try $0.on(
|
||||
createReminderError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
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)
|
||||
clientRequestId: OptionalArgument(reminderItemId.clientRequestId),
|
||||
linkId: OptionalArgument(reminderItemId.linkId),
|
||||
remindAt: DateTime(from: remindAt),
|
||||
sendNotification: true
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.DeleteHighlightResult> {
|
||||
try $0.on(
|
||||
deleteHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .unauthorized) },
|
||||
deleteHighlightSuccess: .init {
|
||||
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
|
||||
},
|
||||
deleteHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .unauthorized) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.UnsubscribeResult> {
|
||||
try $0.on(
|
||||
unsubscribeSuccess: .init { .success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() })) },
|
||||
unsubscribeError: .init { .error(errorMessage: (try $0.errorCodes().first ?? .unauthorized).rawValue) }
|
||||
unsubscribeError: .init { .error(errorMessage: (try $0.errorCodes().first ?? .unauthorized).rawValue) },
|
||||
unsubscribeSuccess: .init { .success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() })) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.SetDeviceTokenResult> {
|
||||
try $0.on(
|
||||
setDeviceTokenError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
setDeviceTokenSuccess: .init {
|
||||
.saved(id: try $0.deviceToken(selection: Selection.DeviceToken { try $0.id() }))
|
||||
},
|
||||
setDeviceTokenError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,25 +46,25 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.MergeHighlightResult> {
|
||||
try $0.on(
|
||||
mergeHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) },
|
||||
mergeHighlightSuccess: .init {
|
||||
.saved(highlight: try $0.highlight(selection: highlightSelection))
|
||||
},
|
||||
mergeHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.mergeHighlight(
|
||||
input: InputObjects.MergeHighlightInput(
|
||||
id: highlight.id,
|
||||
shortId: highlight.shortId,
|
||||
articleId: articleId,
|
||||
patch: highlight.patch,
|
||||
quote: highlight.quote,
|
||||
prefix: .absent(),
|
||||
suffix: .absent(),
|
||||
annotation: .absent(),
|
||||
overlapHighlightIdList: overlapHighlightIdList
|
||||
articleId: articleId,
|
||||
id: highlight.id,
|
||||
overlapHighlightIdList: overlapHighlightIdList,
|
||||
patch: highlight.patch,
|
||||
prefix: .absent(),
|
||||
quote: highlight.quote,
|
||||
shortId: highlight.shortId,
|
||||
suffix: .absent()
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.DeleteLabelResult> {
|
||||
try $0.on(
|
||||
deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
deleteLabelSuccess: .init {
|
||||
.success(labelID: try $0.label(selection: Selection.Label { try $0.id() }))
|
||||
},
|
||||
deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.SetBookmarkArticleResult> {
|
||||
try $0.on(
|
||||
setBookmarkArticleError: .init { .error(errorCode: try $0.errorCodes().first ?? .notFound) },
|
||||
setBookmarkArticleSuccess: .init {
|
||||
.success(
|
||||
linkId: try $0.bookmarkedArticle(selection: Selection.Article {
|
||||
try $0.id()
|
||||
})
|
||||
)
|
||||
},
|
||||
setBookmarkArticleError: .init { .error(errorCode: try $0.errorCodes().first ?? .notFound) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public extension Networker {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.ArticleSavingRequestResult> {
|
||||
try $0.on(
|
||||
articleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .notFound) },
|
||||
articleSavingRequestSuccess: .init {
|
||||
.saved(
|
||||
status: try $0.articleSavingRequest(
|
||||
|
|
@ -40,8 +41,7 @@ public extension Networker {
|
|||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
articleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .notFound) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ public extension DataService {
|
|||
}
|
||||
|
||||
let preparedDocument: InputObjects.PreparedDocumentInput? = {
|
||||
if case let .html(html, title) = pageScrapePayload.contentType {
|
||||
if case let .html(html, title, _) = pageScrapePayload.contentType {
|
||||
return InputObjects.PreparedDocumentInput(
|
||||
document: html,
|
||||
pageInfo: InputObjects.PageInfoInput(title: OptionalArgument(title))
|
||||
|
|
@ -102,15 +102,15 @@ public extension DataService {
|
|||
}()
|
||||
|
||||
let input = InputObjects.CreateArticleInput(
|
||||
url: pageScrapePayload.url,
|
||||
preparedDocument: OptionalArgument(preparedDocument),
|
||||
uploadFileId: uploadFileId != nil ? .present(uploadFileId!) : .null()
|
||||
uploadFileId: uploadFileId != nil ? .present(uploadFileId!) : .null(),
|
||||
url: pageScrapePayload.url
|
||||
)
|
||||
|
||||
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) }
|
||||
createArticleError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unableToParse) },
|
||||
createArticleSuccess: .init { .saved(created: try $0.created()) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +163,7 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.CreateArticleSavingRequestResult> {
|
||||
try $0.on(
|
||||
createArticleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .badData) },
|
||||
createArticleSavingRequestSuccess: .init {
|
||||
.saved(
|
||||
status: try $0.articleSavingRequest(
|
||||
|
|
@ -174,8 +175,7 @@ public extension DataService {
|
|||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
createArticleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .badData) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -219,14 +219,3 @@ public extension DataService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveArticleError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
return .network
|
||||
case .badpayload, .badURL, .badstatus, .cancelled:
|
||||
return .unknown(description: httpError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,44 +3,29 @@ import Foundation
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public struct UploadFileRequestPayload {
|
||||
public let uploadID: String?
|
||||
public let uploadFileID: String?
|
||||
public let urlString: String?
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func uploadPDFPublisher(
|
||||
pageScrapePayload: PageScrapePayload,
|
||||
data: Data,
|
||||
requestId: String
|
||||
) -> AnyPublisher<Void, SaveArticleError> {
|
||||
uploadFileRequestPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
|
||||
.flatMap { self.uploadFilePublisher(fileUploadConfig: $0, data: data) }
|
||||
.flatMap { self.saveFilePublisher(pageScrapePayload: pageScrapePayload, uploadFileId: $0, requestId: requestId) }
|
||||
.catch { _ in self.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private struct UploadFileRequestPayload {
|
||||
let uploadID: String?
|
||||
let uploadFileID: String?
|
||||
let urlString: String?
|
||||
}
|
||||
|
||||
private extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func uploadFileRequestPublisher(pageScrapePayload: PageScrapePayload, requestId: String?) -> AnyPublisher<UploadFileRequestPayload, SaveArticleError> {
|
||||
func uploadFileRequest(id: String, url: String) async throws -> UploadFileRequestPayload {
|
||||
enum MutationResult {
|
||||
case success(payload: UploadFileRequestPayload)
|
||||
case error(errorCode: Enums.UploadFileRequestErrorCode?)
|
||||
}
|
||||
|
||||
let input = InputObjects.UploadFileRequestInput(
|
||||
url: pageScrapePayload.url,
|
||||
clientRequestId: OptionalArgument(id),
|
||||
contentType: "application/pdf",
|
||||
createPageEntry: OptionalArgument(true),
|
||||
clientRequestId: OptionalArgument(requestId)
|
||||
createPageEntry: OptionalArgument(false),
|
||||
url: url
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.UploadFileRequestResult> {
|
||||
try $0.on(
|
||||
uploadFileRequestError: .init { .error(errorCode: try? $0.errorCodes().first) },
|
||||
uploadFileRequestSuccess: .init {
|
||||
.success(
|
||||
payload: UploadFileRequestPayload(
|
||||
|
|
@ -49,8 +34,7 @@ private extension DataService {
|
|||
urlString: try $0.uploadSignedUrl()
|
||||
)
|
||||
)
|
||||
},
|
||||
uploadFileRequestError: .init { .error(errorCode: try? $0.errorCodes().first) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -61,88 +45,89 @@ private extension DataService {
|
|||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(payload):
|
||||
promise(.success(payload))
|
||||
case let .error(errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
default:
|
||||
promise(.failure(.unknown(description: errorCode.debugDescription)))
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.badData))
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
continuation.resume(
|
||||
throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(payload):
|
||||
if let urlString = payload.urlString, let url = URL(string: urlString) {
|
||||
continuation.resume(returning: payload)
|
||||
} else {
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: "No upload URL"))
|
||||
}
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
continuation.resume(throwing: SaveArticleError.unauthorized)
|
||||
default:
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode?.rawValue ?? "unknown"))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: SaveArticleError.make(from: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
func uploadFilePublisher(fileUploadConfig: UploadFileRequestPayload, data: Data) -> AnyPublisher<String, SaveArticleError> {
|
||||
let url = fileUploadConfig.urlString.flatMap { URL(string: $0) }
|
||||
guard let url = url else { return Future { $0(.failure(.badData)) }.eraseToAnyPublisher() }
|
||||
|
||||
func uploadFile(id _: String, localPdfURL: URL, url: URL) async throws {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
|
||||
request.httpBody = data
|
||||
|
||||
return networker.urlSession.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response -> String in
|
||||
let serverResponse = ServerResponse(data: data, response: response)
|
||||
if serverResponse.httpUrlResponse?.statusCode == 200, let fileUploadID = fileUploadConfig.uploadID {
|
||||
return fileUploadID
|
||||
}
|
||||
|
||||
throw ServerError(serverResponse: serverResponse)
|
||||
}
|
||||
.mapError { error -> SaveArticleError in
|
||||
let serverResponse = ServerResponse(error: error)
|
||||
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
|
||||
let serverError = ServerError(serverResponse: serverResponse)
|
||||
switch serverError {
|
||||
case .noConnection, .timeout:
|
||||
return .network
|
||||
case .unauthenticated:
|
||||
return .unauthorized
|
||||
case .unknown:
|
||||
return .unknown(description: "upload to file server failed")
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let task = networker.urlSession.uploadTask(with: request, fromFile: localPdfURL) { _, response, _ in
|
||||
if let httpResponse = response as? HTTPURLResponse, 200 ... 299 ~= httpResponse.statusCode {
|
||||
continuation.resume()
|
||||
} else {
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: "Invalid response"))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func uploadFileInBackground(id: String, localPdfURL: String?, url: URL, usingSession session: URLSession) -> URLSessionTask? {
|
||||
if let localPdfURL = localPdfURL, let localUrl = URL(string: localPdfURL) {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue("application/pdf", forHTTPHeaderField: "content-type")
|
||||
request.setValue(id, forHTTPHeaderField: "clientRequestId")
|
||||
|
||||
let task = session.uploadTask(with: request, fromFile: localUrl)
|
||||
return task
|
||||
} else {
|
||||
// TODO: How should we handle this scenario?
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
func saveFilePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
|
||||
func saveFilePublisher(requestId: String, uploadFileId: String, url: String) async throws {
|
||||
enum MutationResult {
|
||||
case saved(requestId: String, url: String)
|
||||
case error(errorCode: Enums.SaveErrorCode)
|
||||
}
|
||||
|
||||
let input = InputObjects.SaveFileInput(
|
||||
url: pageScrapePayload.url,
|
||||
source: "ios-file",
|
||||
clientRequestId: requestId,
|
||||
uploadFileId: uploadFileId
|
||||
source: "ios-file",
|
||||
uploadFileId: uploadFileId,
|
||||
url: url
|
||||
)
|
||||
|
||||
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) }
|
||||
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
|
||||
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -153,34 +138,31 @@ private extension DataService {
|
|||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .saved:
|
||||
promise(.success(()))
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
default:
|
||||
promise(.failure(.unknown(description: errorCode.rawValue)))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
promise(.failure(SaveError.make(from: error)))
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .saved:
|
||||
continuation.resume()
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
continuation.resume(throwing: SaveArticleError.unauthorized)
|
||||
default:
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode.rawValue))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: SaveArticleError.make(from: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,26 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func savePagePublisher(pageScrapePayload: PageScrapePayload, html: String, title: String?, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
|
||||
func savePage(id: String, url: String, title: String, originalHtml: String) async throws {
|
||||
enum MutationResult {
|
||||
case saved(requestId: String, url: String)
|
||||
case error(errorCode: Enums.SaveErrorCode)
|
||||
}
|
||||
|
||||
let input = InputObjects.SavePageInput(
|
||||
url: requestId,
|
||||
source: html,
|
||||
clientRequestId: "ios-page",
|
||||
clientRequestId: id,
|
||||
originalContent: originalHtml,
|
||||
source: "ios-page",
|
||||
title: OptionalArgument(title),
|
||||
originalContent: pageScrapePayload.url
|
||||
url: url
|
||||
)
|
||||
|
||||
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) }
|
||||
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
|
||||
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -32,38 +31,36 @@ public extension DataService {
|
|||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .saved:
|
||||
promise(.success(()))
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
default:
|
||||
promise(.failure(.unknown(description: errorCode.rawValue)))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
promise(.failure(SaveError.make(from: error)))
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
continuation.resume(
|
||||
throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription)
|
||||
)
|
||||
return
|
||||
}
|
||||
switch payload.data {
|
||||
case .saved:
|
||||
continuation.resume()
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
continuation.resume(throwing: SaveArticleError.unauthorized)
|
||||
default:
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode.rawValue))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: SaveArticleError.make(from: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveError {
|
||||
extension SaveArticleError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
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> {
|
||||
func saveURL(id: String, url: String) async throws {
|
||||
enum MutationResult {
|
||||
case saved(requestId: String, url: String)
|
||||
case error(errorCode: Enums.SaveErrorCode)
|
||||
}
|
||||
|
||||
let input = InputObjects.SaveUrlInput(
|
||||
url: pageScrapePayload.url,
|
||||
clientRequestId: id,
|
||||
source: "ios-url",
|
||||
clientRequestId: requestId
|
||||
url: url
|
||||
)
|
||||
|
||||
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) }
|
||||
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
|
||||
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -31,44 +29,32 @@ public extension DataService {
|
|||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .saved:
|
||||
promise(.success(()))
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
default:
|
||||
promise(.failure(.unknown(description: errorCode.rawValue)))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
promise(.failure(SaveError.make(from: error)))
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
continuation.resume(
|
||||
throwing: SaveArticleError.unknown(description: graphqlError.first.debugDescription)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .saved:
|
||||
continuation.resume()
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
continuation.resume(throwing: SaveArticleError.unauthorized)
|
||||
default:
|
||||
continuation.resume(throwing: SaveArticleError.unknown(description: errorCode.rawValue))
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: SaveArticleError.make(from: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveError {
|
||||
static func make(from httpError: HttpError) -> SaveArticleError {
|
||||
switch httpError {
|
||||
case .network, .timeout:
|
||||
return .network
|
||||
case .badpayload, .badURL, .badstatus, .cancelled:
|
||||
return .unknown(description: httpError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,16 +32,16 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.SetLabelsResult> {
|
||||
try $0.on(
|
||||
setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) },
|
||||
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLabels(
|
||||
input: InputObjects.SetLabelsInput(
|
||||
pageId: itemID,
|
||||
labelIds: labelIDs
|
||||
labelIds: labelIDs,
|
||||
pageId: itemID
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.SaveArticleReadingProgressResult> {
|
||||
try $0.on(
|
||||
saveArticleReadingProgressError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) },
|
||||
saveArticleReadingProgressSuccess: .init {
|
||||
.saved(
|
||||
readingProgress: try $0.updatedArticle(selection: Selection.Article { try $0.readingProgressPercent() })
|
||||
)
|
||||
},
|
||||
saveArticleReadingProgressError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -46,8 +46,8 @@ extension DataService {
|
|||
try $0.saveArticleReadingProgress(
|
||||
input: InputObjects.SaveArticleReadingProgressInput(
|
||||
id: itemID,
|
||||
readingProgressPercent: readingProgress,
|
||||
readingProgressAnchorIndex: anchorIndex
|
||||
readingProgressAnchorIndex: anchorIndex,
|
||||
readingProgressPercent: readingProgress
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,18 +31,18 @@ extension DataService {
|
|||
|
||||
let selection = Selection<MutationResult, Unions.UpdateHighlightResult> {
|
||||
try $0.on(
|
||||
updateHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) },
|
||||
updateHighlightSuccess: .init {
|
||||
.saved(highlight: try $0.highlight(selection: highlightSelection))
|
||||
},
|
||||
updateHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.updateHighlight(
|
||||
input: InputObjects.UpdateHighlightInput(
|
||||
highlightId: highlightID,
|
||||
annotation: OptionalArgument(annotation),
|
||||
highlightId: highlightID,
|
||||
sharedAt: OptionalArgument(nil)
|
||||
),
|
||||
selection: selection
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
public final class Networker {
|
||||
public final class Networker: NSObject, URLSessionTaskDelegate {
|
||||
let urlSession: URLSession
|
||||
let appEnvironment: AppEnvironment
|
||||
var uploadQueue: [String: URLSessionUploadTask] = [:]
|
||||
|
||||
var defaultHeaders: [String: String] {
|
||||
var headers = URLRequest.defaultHeaders
|
||||
|
|
@ -15,9 +16,9 @@ public final class Networker {
|
|||
return headers
|
||||
}
|
||||
|
||||
public init(appEnvironment: AppEnvironment, urlSession: URLSession = .shared) {
|
||||
public init(appEnvironment: AppEnvironment) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.urlSession = urlSession
|
||||
self.urlSession = .shared
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import CoreData
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
extension DataService {
|
||||
func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
public extension DataService {
|
||||
internal func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
// TODO: send a simple request to see if we're online?
|
||||
var unsyncedLinkedItems = [LinkedItem]()
|
||||
var unsyncedHighlights = [Highlight]()
|
||||
|
|
@ -35,12 +35,110 @@ extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
private func updateLinkedItemStatus(id: String, status: ServerSyncStatus) async throws {
|
||||
try backgroundContext.performAndWait {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let linkedItem = (try? backgroundContext.fetch(fetchRequest))?.first else { return }
|
||||
linkedItem.serverSyncStatus = Int64(status.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func syncPdf(id: String, localPdfURL: URL, url: String) async throws {
|
||||
do {
|
||||
let uploadRequest = try await uploadFileRequest(id: id, url: url)
|
||||
if let urlString = uploadRequest.urlString, let uploadUrl = URL(string: urlString) {
|
||||
try await uploadFile(id: id, localPdfURL: localPdfURL, url: uploadUrl)
|
||||
// try await services.dataService.saveFilePublisher(requestId: requestId, uploadFileId: uploadFileID, url: url)
|
||||
} else {
|
||||
throw SaveArticleError.badData
|
||||
}
|
||||
|
||||
try await updateLinkedItemStatus(id: id, status: .isNSync)
|
||||
try backgroundContext.performAndWait {
|
||||
try backgroundContext.save()
|
||||
}
|
||||
} catch {
|
||||
backgroundContext.performAndWait {
|
||||
backgroundContext.rollback()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func syncPage(id: String, originalHtml: String, title: String?, url: String) async throws {
|
||||
do {
|
||||
try await savePage(id: id, url: url, title: title ?? url, originalHtml: originalHtml)
|
||||
try await updateLinkedItemStatus(id: id, status: .isNSync)
|
||||
try backgroundContext.performAndWait {
|
||||
try backgroundContext.save()
|
||||
}
|
||||
} catch {
|
||||
backgroundContext.performAndWait {
|
||||
backgroundContext.rollback()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func syncUrl(id: String, url: String) async throws {
|
||||
do {
|
||||
try await updateLinkedItemStatus(id: id, status: .isSyncing)
|
||||
try await saveURL(id: id, url: url)
|
||||
try backgroundContext.performAndWait {
|
||||
try backgroundContext.save()
|
||||
}
|
||||
} catch {
|
||||
backgroundContext.performAndWait {
|
||||
backgroundContext.rollback()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func syncLocalCreatedLinkedItem(item: LinkedItem) {
|
||||
switch item.contentReader {
|
||||
case "PDF":
|
||||
let id = item.unwrappedID
|
||||
let localPdfURL = item.localPdfURL
|
||||
let url = item.unwrappedPageURLString
|
||||
|
||||
if let pdfUrlStr = localPdfURL, let localPdfURL = URL(string: pdfUrlStr) {
|
||||
Task {
|
||||
try await syncPdf(id: id, localPdfURL: localPdfURL, url: url)
|
||||
}
|
||||
} else {
|
||||
// TODO: This is an invalid object, we should have a way of reflecting that with an error state
|
||||
// updateLinkedItemStatus(id: id, status: .)
|
||||
}
|
||||
case "WEB":
|
||||
let id = item.unwrappedID
|
||||
let url = item.unwrappedPageURLString
|
||||
let title = item.unwrappedTitle
|
||||
let originalHtml = item.originalHtml
|
||||
|
||||
Task {
|
||||
if let originalHtml = originalHtml {
|
||||
try await syncPage(id: id, originalHtml: originalHtml, title: title, url: url)
|
||||
} else {
|
||||
try await syncUrl(id: id, url: url)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) {
|
||||
for item in unsyncedLinkedItems {
|
||||
guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue }
|
||||
|
||||
switch syncStatus {
|
||||
case .isNSync, .isSyncing, .needsCreation:
|
||||
case .needsCreation:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLocalCreatedLinkedItem(item: item)
|
||||
case .isNSync, .isSyncing:
|
||||
break
|
||||
case .needsDeletion:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
|
|
@ -88,4 +186,23 @@ extension DataService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func locallyCreatedItemSynced(notification: NSNotification) {
|
||||
print("SYNCED LOCALLY CREATED ITEM", notification)
|
||||
if let objectId = notification.userInfo?["objectID"] as? String {
|
||||
do {
|
||||
try backgroundContext.performAndWait {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", objectId)
|
||||
if let existingItem = try? self.backgroundContext.fetch(fetchRequest).first {
|
||||
existingItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
try self.backgroundContext.save()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("ERROR", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ extension DataService {
|
|||
return cachedContent
|
||||
}
|
||||
|
||||
// If the page was locally created, make sure they are synced before we pull content
|
||||
await syncUnsyncedArticleContent(itemID: itemID)
|
||||
|
||||
enum QueryResult {
|
||||
case success(result: ArticleProps)
|
||||
case error(error: String)
|
||||
|
|
@ -97,6 +100,7 @@ extension DataService {
|
|||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
|
|
@ -111,6 +115,7 @@ extension DataService {
|
|||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
),
|
||||
htmlContent: try $0.content(),
|
||||
|
|
@ -121,17 +126,17 @@ extension DataService {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleContentSelection))
|
||||
},
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleContentSelection))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.article(username: username, slug: itemID, selection: selection)
|
||||
try $0.article(slug: itemID, username: username, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
|
|
@ -217,7 +222,7 @@ extension DataService {
|
|||
linkedItem.isArchived = item.isArchived
|
||||
linkedItem.contentReader = item.contentReader
|
||||
|
||||
if linkedItem.isPDF, linkedItem.pdfData == nil {
|
||||
if linkedItem.isPDF, linkedItem.localPdfURL == nil {
|
||||
do {
|
||||
try self.fetchPDFData(slug: linkedItem.unwrappedSlug, pageURLString: linkedItem.unwrappedPageURLString)
|
||||
} catch {
|
||||
|
|
@ -257,9 +262,16 @@ extension DataService {
|
|||
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
linkedItem.pdfData = data
|
||||
|
||||
let subPath = UUID().uuidString + ".pdf" // linkedItem.title.isEmpty ? UUID().uuidString : linkedItem.title
|
||||
|
||||
let path = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(subPath)
|
||||
|
||||
do {
|
||||
try data.write(to: path)
|
||||
linkedItem.localPdfURL = path.absoluteString
|
||||
try self?.backgroundContext.save()
|
||||
logger.debug("PDF data saved succesfully")
|
||||
} catch {
|
||||
|
|
@ -296,6 +308,49 @@ extension DataService {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func syncUnsyncedArticleContent(itemID: String) async {
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
linkedItemFetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", itemID
|
||||
)
|
||||
|
||||
let context = backgroundContext
|
||||
|
||||
var id: String?
|
||||
var url: String?
|
||||
var title: String?
|
||||
var originalHtml: String?
|
||||
var serverSyncStatus: Int64?
|
||||
|
||||
backgroundContext.performAndWait {
|
||||
guard let linkedItem = try? context.fetch(linkedItemFetchRequest).first else { return }
|
||||
id = linkedItem.unwrappedID
|
||||
url = linkedItem.unwrappedPageURLString
|
||||
title = linkedItem.unwrappedTitle
|
||||
originalHtml = linkedItem.originalHtml
|
||||
serverSyncStatus = linkedItem.serverSyncStatus
|
||||
}
|
||||
|
||||
if let id = id, let url = url, let title = title,
|
||||
let serverSyncStatus = serverSyncStatus,
|
||||
serverSyncStatus != ServerSyncStatus.isNSync.rawValue
|
||||
{
|
||||
do {
|
||||
if let originalHtml = originalHtml {
|
||||
try await savePage(id: id, url: url, title: title, originalHtml: originalHtml)
|
||||
} else {
|
||||
try await saveURL(id: id, url: url)
|
||||
}
|
||||
try backgroundContext.performAndWait {
|
||||
try backgroundContext.save()
|
||||
}
|
||||
} catch {
|
||||
// We don't propogate these errors, we just let it pass through so
|
||||
// the user can attempt to fetch content again.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ArticleContentStatus {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.LabelsResult> {
|
||||
try $0.on(
|
||||
labelsSuccess: .init {
|
||||
QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list))
|
||||
},
|
||||
labelsError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
labelsSuccess: .init {
|
||||
QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.ArticlesResult> {
|
||||
try $0.on(
|
||||
articlesError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
articlesSuccess: .init {
|
||||
QueryResult.success(
|
||||
result: InternalHomeFeedData(
|
||||
|
|
@ -33,25 +36,23 @@ public extension DataService {
|
|||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
articlesError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.articles(
|
||||
after: OptionalArgument(cursor),
|
||||
first: OptionalArgument(limit),
|
||||
includePending: OptionalArgument(true),
|
||||
query: OptionalArgument(searchQuery),
|
||||
sharedOnly: .present(false),
|
||||
sort: OptionalArgument(
|
||||
InputObjects.SortParams(
|
||||
order: .present(.descending), by: .updatedTime
|
||||
by: .updatedTime,
|
||||
order: .present(.descending)
|
||||
)
|
||||
),
|
||||
after: OptionalArgument(cursor),
|
||||
first: OptionalArgument(limit),
|
||||
query: OptionalArgument(searchQuery),
|
||||
includePending: OptionalArgument(true),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
|
@ -96,6 +97,7 @@ public extension DataService {
|
|||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
|
|
@ -110,24 +112,25 @@ public extension DataService {
|
|||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleSelection))
|
||||
},
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleSelection))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
// backend has a hack that allows us to pass in itemID in place of slug
|
||||
try $0.article(username: username, slug: itemID, selection: selection)
|
||||
try $0.article(slug: itemID, username: username, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
|
|
@ -160,6 +163,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
updatedAt: try $0.updatedAt().value ?? Date(),
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
imageURLString: try $0.image(),
|
||||
|
|
@ -174,6 +178,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ extension DataService {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.SearchResult> {
|
||||
try $0.on(
|
||||
searchError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
searchSuccess: .init {
|
||||
QueryResult.success(
|
||||
result: LinkedItemIDFetchResult(
|
||||
|
|
@ -29,9 +32,6 @@ extension DataService {
|
|||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
searchError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.NewsletterEmailsResult> {
|
||||
try $0.on(
|
||||
newsletterEmailsSuccess: .init {
|
||||
QueryResult.success(result: try $0.newsletterEmails(selection: newsletterEmailSelection.list))
|
||||
},
|
||||
newsletterEmailsError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
newsletterEmailsSuccess: .init {
|
||||
QueryResult.success(result: try $0.newsletterEmails(selection: newsletterEmailSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@ public extension DataService {
|
|||
|
||||
let selection = Selection<QueryResult, Unions.SubscriptionsResult> {
|
||||
try $0.on(
|
||||
subscriptionsSuccess: .init {
|
||||
QueryResult.success(result: try $0.subscriptions(selection: subsciptionSelection.list))
|
||||
},
|
||||
subscriptionsError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
subscriptionsSuccess: .init {
|
||||
QueryResult.success(result: try $0.subscriptions(selection: subsciptionSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -7,6 +7,7 @@ struct InternalLinkedItem {
|
|||
let title: String
|
||||
let createdAt: Date
|
||||
let savedAt: Date
|
||||
let updatedAt: Date
|
||||
var readingProgress: Double
|
||||
var readingProgressAnchor: Int
|
||||
let imageURLString: String?
|
||||
|
|
@ -21,6 +22,7 @@ struct InternalLinkedItem {
|
|||
let slug: String
|
||||
let isArchived: Bool
|
||||
let contentReader: String?
|
||||
let originalHtml: String?
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
var isPDF: Bool {
|
||||
|
|
@ -38,6 +40,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.title = title
|
||||
linkedItem.createdAt = createdAt
|
||||
linkedItem.savedAt = savedAt
|
||||
linkedItem.updatedAt = updatedAt
|
||||
linkedItem.readingProgress = readingProgress
|
||||
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
|
||||
linkedItem.imageURLString = imageURLString
|
||||
|
|
@ -51,6 +54,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.slug = slug
|
||||
linkedItem.isArchived = isArchived
|
||||
linkedItem.contentReader = contentReader
|
||||
linkedItem.originalHtml = originalHtml
|
||||
|
||||
for label in labels {
|
||||
linkedItem.addToLabels(label.asManagedObject(inContext: context))
|
||||
|
|
@ -93,6 +97,7 @@ extension JSONArticle {
|
|||
title: title,
|
||||
createdAt: createdAt,
|
||||
savedAt: savedAt,
|
||||
updatedAt: updatedAt,
|
||||
readingProgress: readingProgressPercent,
|
||||
readingProgressAnchor: readingProgressAnchorIndex,
|
||||
imageURLString: image,
|
||||
|
|
@ -107,6 +112,7 @@ extension JSONArticle {
|
|||
slug: slug,
|
||||
isArchived: isArchived,
|
||||
contentReader: contentReader,
|
||||
originalHtml: nil,
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
|
|
|||
52
apple/OmnivoreKit/Sources/Utils/NormalizeURL.swift
Normal file
52
apple/OmnivoreKit/Sources/Utils/NormalizeURL.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// NormalizeURL.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/2/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// based losesly on the normalize-url npm package which we use on the backend
|
||||
public func normalizeURL(_ dirtyURL: String) -> String {
|
||||
var urlString = dirtyURL
|
||||
|
||||
urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if var urlObject = URLComponents(string: urlString) {
|
||||
// Remove auth
|
||||
if /* options.stripAuthentication */ true {
|
||||
urlObject.user = nil
|
||||
urlObject.password = nil
|
||||
}
|
||||
|
||||
// Remove hash
|
||||
if /* options.stripHash */ true {
|
||||
urlObject.fragment = nil
|
||||
}
|
||||
|
||||
urlObject.queryItems = urlObject.queryItems?.filter { item in
|
||||
item.name.starts(with: "utm_")
|
||||
}
|
||||
|
||||
if /* options.removeTrailingSlash */ true {
|
||||
urlObject.path = urlObject.path.replacingRegex(pattern: "/$", replaceWith: "")
|
||||
}
|
||||
|
||||
if let finalUrl = urlObject.url {
|
||||
return finalUrl.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
return dirtyURL
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func replacingRegex(pattern: String, replaceWith: String = "") -> String {
|
||||
do {
|
||||
let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .anchorsMatchLines])
|
||||
let range = NSRange(location: 0, length: utf16.count)
|
||||
return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith)
|
||||
} catch { return self }
|
||||
}
|
||||
}
|
||||
62
apple/OmnivoreKit/Sources/Utils/PDFUtils.swift
Normal file
62
apple/OmnivoreKit/Sources/Utils/PDFUtils.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// PDFUtils.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/3/22.
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
import Foundation
|
||||
import QuickLookThumbnailing
|
||||
import UIKit
|
||||
|
||||
public enum PDFUtils {
|
||||
public static func titleFromPdfFile(_ urlStr: String) -> String {
|
||||
let url = URL(string: urlStr)
|
||||
if let url = url {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
return urlStr
|
||||
}
|
||||
|
||||
public static func titleFromUrl(_ urlStr: String) -> String {
|
||||
let url = URL(string: urlStr)
|
||||
if let url = url {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
return urlStr
|
||||
}
|
||||
|
||||
public static func thumbnailUrl(localUrl: URL) -> URL {
|
||||
var thumbnailUrl = localUrl
|
||||
thumbnailUrl.appendPathExtension(".jpg")
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
public static func createThumbnailFor(inputUrl: URL) async throws -> URL? {
|
||||
let size = CGSize(width: 80, height: 80)
|
||||
let scale = await UIScreen.main.scale
|
||||
let outputUrl = thumbnailUrl(localUrl: inputUrl)
|
||||
|
||||
// Create the thumbnail request.
|
||||
let request =
|
||||
QLThumbnailGenerator.Request(
|
||||
fileAt: inputUrl,
|
||||
size: size,
|
||||
scale: scale,
|
||||
representationTypes: .all
|
||||
)
|
||||
|
||||
// Retrieve the singleton instance of the thumbnail generator and generate the thumbnails.
|
||||
let generator = QLThumbnailGenerator.shared
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
generator.saveBestRepresentation(for: request, to: outputUrl, contentType: UTType.jpeg.identifier) { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: outputUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
public enum UserDefaultKey: String {
|
||||
case preferredWebFont
|
||||
case preferredWebFontSize
|
||||
case preferredWebLineSpacing
|
||||
case preferredWebMargin
|
||||
case prefersHighContrastWebFont
|
||||
case userHasDeniedPushPrimer
|
||||
case firebasePushToken
|
||||
case homeFeedlayoutPreference
|
||||
case lastSelectedLinkedItemFilter
|
||||
case lastUsedAppVersion
|
||||
case lastUsedAppBuildNumber
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,17 +95,17 @@ public enum WebViewManager {
|
|||
|
||||
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
|
||||
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
|
||||
(webView as? WebView)?.saveAnnotation(annotation: annotation)
|
||||
(webView as? WebView)?.dispatchEvent(.saveAnnotation(annotation: annotation))
|
||||
}
|
||||
|
||||
if sendIncreaseFontSignal {
|
||||
sendIncreaseFontSignal = false
|
||||
(webView as? WebView)?.increaseFontSize()
|
||||
(webView as? WebView)?.updateFontSize()
|
||||
}
|
||||
|
||||
if sendDecreaseFontSignal {
|
||||
sendDecreaseFontSignal = false
|
||||
(webView as? WebView)?.decreaseFontSize()
|
||||
(webView as? WebView)?.updateFontSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import Utils
|
||||
import WebKit
|
||||
|
||||
/// Describes actions that can be sent from the WebView back to native views.
|
||||
|
|
@ -26,21 +27,46 @@ public final class WebView: WKWebView {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public func increaseFontSize() {
|
||||
dispatchEvent("increaseFontSize")
|
||||
public func updateFontFamily() {
|
||||
if let fontFamily = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebFont.rawValue) as? String {
|
||||
dispatchEvent(.updateFontFamily(family: fontFamily))
|
||||
}
|
||||
}
|
||||
|
||||
public func decreaseFontSize() {
|
||||
dispatchEvent("decreaseFontSize")
|
||||
public func updateFontSize() {
|
||||
if let fontSize = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebFontSize.rawValue) as? Int {
|
||||
dispatchEvent(.updateFontSize(size: fontSize))
|
||||
}
|
||||
}
|
||||
|
||||
public func updateMargin() {
|
||||
if let margin = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebMargin.rawValue) as? Int {
|
||||
dispatchEvent(.updateMargin(width: margin))
|
||||
}
|
||||
}
|
||||
|
||||
public func updateLineHeight() {
|
||||
if let height = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebLineSpacing.rawValue) as? Int {
|
||||
dispatchEvent(.updateLineHeight(height: height))
|
||||
}
|
||||
}
|
||||
|
||||
public func updateTextContrast() {
|
||||
let isHighContrast = UserDefaults.standard.value(
|
||||
forKey: UserDefaultKey.prefersHighContrastWebFont.rawValue
|
||||
) as? Bool
|
||||
|
||||
if let isHighContrast = isHighContrast {
|
||||
dispatchEvent(.handleFontContrastChange(isHighContrast: isHighContrast))
|
||||
}
|
||||
}
|
||||
|
||||
public func shareOriginalItem() {
|
||||
dispatchEvent("share")
|
||||
dispatchEvent(.share)
|
||||
}
|
||||
|
||||
func dispatchEvent(_ name: String) {
|
||||
let dispatch = "document.dispatchEvent(new Event('\(name)'));"
|
||||
evaluateJavaScript(dispatch) { obj, err in
|
||||
public func dispatchEvent(_ event: WebViewDispatchEvent) {
|
||||
evaluateJavaScript(event.script) { obj, err in
|
||||
if let err = err { print(err) }
|
||||
if let obj = obj { print(obj) }
|
||||
}
|
||||
|
|
@ -50,12 +76,7 @@ public final class WebView: WKWebView {
|
|||
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
|
||||
|
||||
if traitCollection.userInterfaceStyle == .dark {
|
||||
dispatchEvent("switchToDarkMode")
|
||||
} else {
|
||||
dispatchEvent("switchToLightMode")
|
||||
}
|
||||
dispatchEvent(.updateColorMode(isDark: traitCollection.userInterfaceStyle == .dark))
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
|
|
@ -149,28 +170,28 @@ public final class WebView: WKWebView {
|
|||
}
|
||||
|
||||
@objc private func annotateSelection() {
|
||||
dispatchEvent("annotate")
|
||||
dispatchEvent(.annotate)
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
@objc private func highlightSelection() {
|
||||
dispatchEvent("highlight")
|
||||
dispatchEvent(.highlight)
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
@objc private func shareSelection() {
|
||||
dispatchEvent("share")
|
||||
dispatchEvent(.share)
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
@objc private func removeSelection() {
|
||||
dispatchEvent("remove")
|
||||
dispatchEvent(.remove)
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
@objc override public func copy(_ sender: Any?) {
|
||||
super.copy(sender)
|
||||
dispatchEvent("copyHighlight")
|
||||
dispatchEvent(.copyHighlight)
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +210,7 @@ public final class WebView: WKWebView {
|
|||
|
||||
private func hideMenuAndDismissHighlight() {
|
||||
hideMenu()
|
||||
dispatchEvent("dismissHighlight")
|
||||
dispatchEvent(.dismissHighlight)
|
||||
}
|
||||
|
||||
private func showHighlightMenu(_ rect: CGRect) {
|
||||
|
|
@ -213,14 +234,77 @@ public final class WebView: WKWebView {
|
|||
|
||||
UIMenuController.shared.showMenu(from: self, rect: rect)
|
||||
}
|
||||
|
||||
public func saveAnnotation(annotation: String) {
|
||||
// swiftlint:disable:next line_length
|
||||
let dispatch = "var event = new Event('saveAnnotation');event.annotation = '\(annotation)';document.dispatchEvent(event);"
|
||||
evaluateJavaScript(dispatch) { obj, err in
|
||||
if let err = err { print(err) }
|
||||
if let obj = obj { print(obj) }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public enum WebViewDispatchEvent {
|
||||
case handleFontContrastChange(isHighContrast: Bool)
|
||||
case updateLineHeight(height: Int)
|
||||
case updateMargin(width: Int)
|
||||
case updateFontSize(size: Int)
|
||||
case updateColorMode(isDark: Bool)
|
||||
case updateFontFamily(family: String)
|
||||
case saveAnnotation(annotation: String)
|
||||
case annotate
|
||||
case highlight
|
||||
case share
|
||||
case remove
|
||||
case copyHighlight
|
||||
case dismissHighlight
|
||||
|
||||
var script: String {
|
||||
"var event = new Event('\(eventName)');\(scriptPropertyLine)document.dispatchEvent(event);"
|
||||
}
|
||||
|
||||
private var eventName: String {
|
||||
switch self {
|
||||
case .handleFontContrastChange:
|
||||
return "handleFontContrastChange"
|
||||
case .updateLineHeight:
|
||||
return "updateLineHeight"
|
||||
case .updateMargin:
|
||||
return "updateMargin"
|
||||
case .updateFontSize:
|
||||
return "updateFontSize"
|
||||
case .updateColorMode:
|
||||
return "updateColorMode"
|
||||
case .updateFontFamily:
|
||||
return "updateFontFamily"
|
||||
case .saveAnnotation:
|
||||
return "saveAnnotation"
|
||||
case .annotate:
|
||||
return "annotate"
|
||||
case .highlight:
|
||||
return "highlight"
|
||||
case .share:
|
||||
return "share"
|
||||
case .remove:
|
||||
return "remove"
|
||||
case .copyHighlight:
|
||||
return "copyHighlight"
|
||||
case .dismissHighlight:
|
||||
return "dismissHighlight"
|
||||
}
|
||||
}
|
||||
|
||||
private var scriptPropertyLine: String {
|
||||
switch self {
|
||||
case let .handleFontContrastChange(isHighContrast: isHighContrast):
|
||||
return "event.fontContrast = '\(isHighContrast ? "high" : "normal")';"
|
||||
case let .updateLineHeight(height: height):
|
||||
return "event.lineHeight = '\(height)';"
|
||||
case let .updateMargin(width: width):
|
||||
return "event.margin = '\(width)';"
|
||||
case let .updateFontSize(size: size):
|
||||
return "event.fontSize = '\(size)';"
|
||||
case let .updateColorMode(isDark: isDark):
|
||||
return "event.isDarkMode = '\(isDark)';"
|
||||
case let .updateFontFamily(family: family):
|
||||
return "event.fontFamily = '\(family)';"
|
||||
case let .saveAnnotation(annotation: annotation):
|
||||
return "event.annotation = '\(annotation)';"
|
||||
case .annotate, .highlight, .share, .remove, .copyHighlight, .dismissHighlight:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ public struct AppleSignInButton: View {
|
|||
},
|
||||
onCompletion: onCompletion
|
||||
)
|
||||
.frame(height: 44)
|
||||
.frame(height: 54)
|
||||
.frame(maxWidth: 300)
|
||||
.cornerRadius(8)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.signInWithAppleButtonStyle(.white)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ public struct GoogleAuthButton: View {
|
|||
HStack(spacing: 8) {
|
||||
Image.googleIcon
|
||||
.resizable()
|
||||
.frame(width: 12, height: 12)
|
||||
.frame(width: 16, height: 16)
|
||||
Text(LocalText.googleAuthButton)
|
||||
.font(isMacApp ? .appCaption : .appBody)
|
||||
.font(isMacApp ? .appCaption : .appTitleThree)
|
||||
.foregroundColor(.black)
|
||||
.fontWeight(Font.Weight.medium)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: isMacApp ? 30 : 44)
|
||||
.frame(maxWidth: 300)
|
||||
.frame(height: isMacApp ? 30 : 54)
|
||||
}
|
||||
.buttonStyle(GoogleButtonStyle())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x9F",
|
||||
"green" : "0xEA",
|
||||
"red" : "0xFF"
|
||||
"blue" : "0xA8",
|
||||
"green" : "0xEB",
|
||||
"red" : "0xFB"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x9F",
|
||||
"green" : "0xEA",
|
||||
"red" : "0xFF"
|
||||
"blue" : "0xA8",
|
||||
"green" : "0xEB",
|
||||
"red" : "0xFB"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,197 @@
|
|||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public enum WebFont: String, CaseIterable {
|
||||
case inter = "Inter"
|
||||
case merriweather = "Merriweather"
|
||||
case lyon = "Lyon"
|
||||
case tisa = "Tisa"
|
||||
case system = "unset"
|
||||
|
||||
var displayValue: String {
|
||||
switch self {
|
||||
case .inter, .merriweather, .lyon, .tisa:
|
||||
return rawValue
|
||||
case .system:
|
||||
return "System Default"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebPreferencesPopoverView: View {
|
||||
let updateFontFamilyAction: () -> Void
|
||||
let updateFontAction: () -> Void
|
||||
let updateTextContrastAction: () -> Void
|
||||
let updateMarginAction: () -> Void
|
||||
let updateLineHeightAction: () -> Void
|
||||
let dismissAction: () -> Void
|
||||
|
||||
static let preferredWebFontSizeKey = UserDefaultKey.preferredWebFontSize.rawValue
|
||||
#if os(macOS)
|
||||
@AppStorage(preferredWebFontSizeKey) var storedFontSize = Int(NSFont.userFont(ofSize: 16)?.pointSize ?? 16)
|
||||
#else
|
||||
@AppStorage(preferredWebFontSizeKey) var storedFontSize: Int = UITraitCollection.current.preferredWebFontSize
|
||||
#endif
|
||||
|
||||
@AppStorage(UserDefaultKey.preferredWebLineSpacing.rawValue) var storedLineSpacing = 150
|
||||
@AppStorage(UserDefaultKey.preferredWebMargin.rawValue) var storedMargin = 360
|
||||
@AppStorage(UserDefaultKey.preferredWebFont.rawValue) var preferredFont = WebFont.inter.rawValue
|
||||
@AppStorage(UserDefaultKey.prefersHighContrastWebFont.rawValue) var prefersHighContrastText = false
|
||||
|
||||
public init(
|
||||
updateFontFamilyAction: @escaping () -> Void,
|
||||
updateFontAction: @escaping () -> Void,
|
||||
updateTextContrastAction: @escaping () -> Void,
|
||||
updateMarginAction: @escaping () -> Void,
|
||||
updateLineHeightAction: @escaping () -> Void,
|
||||
dismissAction: @escaping () -> Void
|
||||
) {
|
||||
self.updateFontFamilyAction = updateFontFamilyAction
|
||||
self.updateFontAction = updateFontAction
|
||||
self.updateTextContrastAction = updateTextContrastAction
|
||||
self.updateMarginAction = updateMarginAction
|
||||
self.updateLineHeightAction = updateLineHeightAction
|
||||
self.dismissAction = dismissAction
|
||||
}
|
||||
|
||||
var fontList: some View {
|
||||
List {
|
||||
ForEach(WebFont.allCases, id: \.self) { font in
|
||||
Button(
|
||||
action: {
|
||||
preferredFont = font.rawValue
|
||||
updateFontFamilyAction()
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
Text(font.displayValue).foregroundColor(.appGrayTextContrast)
|
||||
Spacer()
|
||||
if font.rawValue == preferredFont {
|
||||
Image(systemName: "checkmark").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Reader Font")
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .center) {
|
||||
VStack {
|
||||
LabelledStepper(
|
||||
labelText: "Font Size:",
|
||||
onIncrement: {
|
||||
storedFontSize = min(storedFontSize + 2, 28)
|
||||
updateFontAction()
|
||||
},
|
||||
onDecrement: {
|
||||
storedFontSize = max(storedFontSize - 2, 10)
|
||||
updateFontAction()
|
||||
}
|
||||
)
|
||||
|
||||
if UIDevice.isIPad {
|
||||
LabelledStepper(
|
||||
labelText: "Margin:",
|
||||
onIncrement: {
|
||||
storedMargin = min(storedMargin + 45, 560)
|
||||
updateMarginAction()
|
||||
},
|
||||
onDecrement: {
|
||||
storedMargin = max(storedMargin - 45, 200)
|
||||
updateMarginAction()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LabelledStepper(
|
||||
labelText: "Line Spacing:",
|
||||
onIncrement: {
|
||||
storedLineSpacing = min(storedLineSpacing + 25, 300)
|
||||
updateLineHeightAction()
|
||||
},
|
||||
onDecrement: {
|
||||
storedLineSpacing = max(storedLineSpacing - 25, 100)
|
||||
updateLineHeightAction()
|
||||
}
|
||||
)
|
||||
|
||||
Toggle("High Contrast Text:", isOn: $prefersHighContrastText)
|
||||
.frame(height: 40)
|
||||
.padding(.trailing, 6)
|
||||
.onChange(of: prefersHighContrastText) { _ in
|
||||
updateTextContrastAction()
|
||||
}
|
||||
|
||||
HStack {
|
||||
NavigationLink(destination: fontList) {
|
||||
Text("Change Reader Font")
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 40)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Reader Preferences")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.accentColor(.appGrayTextContrast)
|
||||
}
|
||||
}
|
||||
|
||||
struct LabelledStepper: View {
|
||||
let labelText: String
|
||||
let onIncrement: () -> Void
|
||||
let onDecrement: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Text(labelText)
|
||||
Spacer()
|
||||
HStack(spacing: 0) {
|
||||
Button(
|
||||
action: onDecrement,
|
||||
label: {
|
||||
Image(systemName: "minus")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.systemLabel)
|
||||
.padding()
|
||||
#endif
|
||||
}
|
||||
)
|
||||
.frame(width: 55, height: 40, alignment: .center)
|
||||
Divider()
|
||||
.frame(height: 30)
|
||||
.background(Color.systemLabel)
|
||||
Button(
|
||||
action: onIncrement,
|
||||
label: {
|
||||
Image(systemName: "plus")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.systemLabel)
|
||||
.padding()
|
||||
#endif
|
||||
}
|
||||
)
|
||||
.frame(width: 55, height: 40, alignment: .center)
|
||||
}
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct FontSizeAdjustmentPopoverView: View {
|
||||
let increaseFontAction: () -> Void
|
||||
let decreaseFontAction: () -> Void
|
||||
|
|
|
|||
|
|
@ -13,25 +13,32 @@ import SwiftUI
|
|||
var content: () -> Content
|
||||
var onDismiss: (() -> Void)?
|
||||
var modalSize: CGSize
|
||||
let useSmallDetent: Bool
|
||||
|
||||
private var hostVC: UIHostingController<Content>?
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) { fatalError("") }
|
||||
|
||||
init(content: @escaping () -> Content, modalSize: CGSize) {
|
||||
init(content: @escaping () -> Content, modalSize: CGSize, useSmallDetent: Bool) {
|
||||
self.content = content
|
||||
self.modalSize = modalSize
|
||||
self.useSmallDetent = useSmallDetent
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard hostVC == nil else { return }
|
||||
let controller = UIHostingController(rootView: content())
|
||||
let controller: UIHostingController<Content> = {
|
||||
if UIDevice.isIPhone, useSmallDetent {
|
||||
return FormSheetHostingController(rootView: content(), height: 100)
|
||||
} else {
|
||||
return UIHostingController(rootView: content())
|
||||
}
|
||||
}()
|
||||
|
||||
if controller.traitCollection.userInterfaceIdiom == .phone {
|
||||
if UIDevice.isIPhone {
|
||||
if let sheet = controller.sheetPresentationController {
|
||||
sheet.preferredCornerRadius = 16
|
||||
sheet.prefersGrabberVisible = false
|
||||
sheet.detents = [.medium()]
|
||||
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
|
||||
|
|
@ -66,12 +73,17 @@ import SwiftUI
|
|||
@Binding var show: Bool
|
||||
|
||||
let modalSize: CGSize
|
||||
let useSmallDetent: Bool
|
||||
let content: () -> Content
|
||||
|
||||
func makeUIViewController(
|
||||
context _: UIViewControllerRepresentableContext<FormSheet<Content>>
|
||||
) -> FormSheetWrapper<Content> {
|
||||
let controller = FormSheetWrapper(content: content, modalSize: modalSize)
|
||||
let controller = FormSheetWrapper(
|
||||
content: content,
|
||||
modalSize: modalSize,
|
||||
useSmallDetent: useSmallDetent
|
||||
)
|
||||
controller.onDismiss = { self.show = false }
|
||||
return controller
|
||||
}
|
||||
|
|
@ -92,11 +104,55 @@ import SwiftUI
|
|||
func formSheet<Content: View>(
|
||||
isPresented: Binding<Bool>,
|
||||
modalSize: CGSize = CGSize(width: 320, height: 320),
|
||||
useSmallDetent: Bool = false,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) -> some View {
|
||||
background(FormSheet(show: isPresented,
|
||||
modalSize: modalSize,
|
||||
content: content))
|
||||
background(
|
||||
FormSheet(
|
||||
show: isPresented,
|
||||
modalSize: modalSize,
|
||||
useSmallDetent: useSmallDetent,
|
||||
content: content
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class FormSheetHostingController<Content: View>: UIHostingController<Content> {
|
||||
let height: CGFloat
|
||||
|
||||
init(rootView: Content, height: CGFloat) {
|
||||
self.height = height
|
||||
super.init(rootView: rootView)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@MainActor dynamic required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func updateViewConstraints() {
|
||||
view.frame.size.height = UIScreen.main.bounds.height - height
|
||||
view.frame.origin.y = height
|
||||
view.roundCorners(corners: [.topLeft, .topRight], radius: 16.0)
|
||||
super.updateViewConstraints()
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
func roundCorners(corners: UIRectCorner, radius: CGFloat) {
|
||||
let path = UIBezierPath(
|
||||
roundedRect: bounds,
|
||||
byRoundingCorners: corners,
|
||||
cornerRadii:
|
||||
CGSize(
|
||||
width: radius,
|
||||
height: radius
|
||||
)
|
||||
)
|
||||
let mask = CAShapeLayer()
|
||||
mask.path = path.cgPath
|
||||
layer.mask = mask
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import SwiftUI
|
|||
public extension Image {
|
||||
static var smallOmnivoreLogo: Image { Image("_smallOmnivoreLogo", bundle: .module) }
|
||||
static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) }
|
||||
static var readingIllustration: Image { Image("_readingIllustration", bundle: .module) }
|
||||
static var readingIllustrationXXL: Image { Image("_readingIllustrationXXL", bundle: .module) }
|
||||
static var googleIcon: Image { Image("_googleIcon", bundle: .module) }
|
||||
static var feedItemPlaceholder: Image { Image("_feedItemPlaceholder", bundle: .module) }
|
||||
static var sunHorizon: Image { Image("_sun-horizon", bundle: .module) }
|
||||
static var mountains: Image { Image("_mountains", bundle: .module) }
|
||||
static var moon: Image { Image("_moon", bundle: .module) }
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "imagePlaceholder.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Mask Group.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Mask Group@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Mask Group@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB |
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "XXLIllustration.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
|
|
@ -1,25 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct RegistrationHeroImageView: View {
|
||||
let tapGestureHandler: () -> Void
|
||||
|
||||
public init(tapGestureHandler: @escaping () -> Void) {
|
||||
self.tapGestureHandler = tapGestureHandler
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Image.readingIllustration
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
Image.omnivoreTitleLogo
|
||||
.padding()
|
||||
.gesture(
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
tapGestureHandler()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleAuthFlowButton: View {
|
||||
let authFlow: AuthFlow
|
||||
let action: () -> Void
|
||||
|
||||
var buttonTitle: String {
|
||||
switch authFlow {
|
||||
case .signIn:
|
||||
return "Don’t have an account? "
|
||||
case .signUp:
|
||||
return "Already have an account? "
|
||||
}
|
||||
}
|
||||
|
||||
var buttonTitleSuffix: String {
|
||||
switch authFlow {
|
||||
case .signIn:
|
||||
return "Sign Up"
|
||||
case .signUp:
|
||||
return "Log In"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(
|
||||
action: action,
|
||||
label: {
|
||||
Text(buttonTitle)
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
+ Text(buttonTitleSuffix)
|
||||
.underline()
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -2,23 +2,64 @@ import Models
|
|||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public class ShareExtensionChildViewModel: ObservableObject {
|
||||
@Published public var status: ShareExtensionStatus = .processing
|
||||
@Published public var title: String?
|
||||
@Published public var url: String?
|
||||
@Published public var iconURL: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public enum ShareExtensionStatus {
|
||||
case processing
|
||||
case success
|
||||
case saved
|
||||
case synced
|
||||
case failed(error: SaveArticleError)
|
||||
case syncFailed(error: SaveArticleError)
|
||||
|
||||
var displayMessage: String {
|
||||
switch self {
|
||||
case .success:
|
||||
return LocalText.saveArticleSavedState
|
||||
case let .failed(error: error):
|
||||
return error.displayMessage
|
||||
case .processing:
|
||||
return LocalText.saveArticleProcessingState
|
||||
case .saved:
|
||||
return LocalText.saveArticleSavedState
|
||||
case .synced:
|
||||
return "Synced"
|
||||
case let .failed(error: error):
|
||||
return "Save failed \(error.displayMessage)"
|
||||
case let .syncFailed(error: error):
|
||||
return "Sync failed \(error.displayMessage)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CornerRadiusStyle: ViewModifier {
|
||||
var radius: CGFloat
|
||||
var corners: UIRectCorner
|
||||
|
||||
struct CornerRadiusShape: Shape {
|
||||
var radius = CGFloat.infinity
|
||||
var corners = UIRectCorner.allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.clipShape(CornerRadiusShape(radius: radius, corners: corners))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
ModifiedContent(content: self, modifier: CornerRadiusStyle(radius: radius, corners: corners))
|
||||
}
|
||||
}
|
||||
|
||||
private extension SaveArticleError {
|
||||
var displayMessage: String {
|
||||
switch self {
|
||||
|
|
@ -83,32 +124,26 @@ struct CheckmarkButtonView: View {
|
|||
}
|
||||
|
||||
public struct ShareExtensionChildView: View {
|
||||
let debugText: String?
|
||||
let title: String?
|
||||
let status: ShareExtensionStatus
|
||||
let viewModel: ShareExtensionChildViewModel
|
||||
let onAppearAction: () -> Void
|
||||
let readNowButtonAction: () -> Void
|
||||
let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void
|
||||
|
||||
@State var reminderTime: ReminderTime?
|
||||
@State var hideUntilReminded = false
|
||||
|
||||
public init(
|
||||
debugText: String?,
|
||||
title: String?,
|
||||
status: ShareExtensionStatus,
|
||||
viewModel: ShareExtensionChildViewModel,
|
||||
onAppearAction: @escaping () -> Void,
|
||||
readNowButtonAction: @escaping () -> Void,
|
||||
dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void
|
||||
) {
|
||||
self.debugText = debugText
|
||||
self.title = title
|
||||
self.status = status
|
||||
self.viewModel = viewModel
|
||||
self.onAppearAction = onAppearAction
|
||||
self.readNowButtonAction = readNowButtonAction
|
||||
self.dismissButtonTappedAction = dismissButtonTappedAction
|
||||
}
|
||||
|
||||
@State var reminderTime: ReminderTime?
|
||||
@State var hideUntilReminded = false
|
||||
|
||||
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
|
||||
if selectedTime == reminderTime {
|
||||
reminderTime = nil
|
||||
|
|
@ -119,101 +154,142 @@ public struct ShareExtensionChildView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
switch viewModel.status {
|
||||
case .saved, .synced, .syncFailed(error: _):
|
||||
return "Saved to Omnivore"
|
||||
case .processing:
|
||||
return "Saving to Omnivore"
|
||||
case .failed(error: _):
|
||||
return "Error saving to Omnivore"
|
||||
}
|
||||
}
|
||||
|
||||
private var cloudIconName: String {
|
||||
switch viewModel.status {
|
||||
case .synced:
|
||||
return "checkmark.icloud"
|
||||
case .saved, .processing:
|
||||
return "icloud"
|
||||
case .failed(error: _), .syncFailed(error: _):
|
||||
return "exclamationmark.icloud"
|
||||
}
|
||||
}
|
||||
|
||||
private var cloudIconColor: Color {
|
||||
switch viewModel.status {
|
||||
case .saved, .processing:
|
||||
return .appGrayText
|
||||
case .failed(error: _), .syncFailed(error: _):
|
||||
return .red
|
||||
case .synced:
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
|
||||
private func localImage(from: URL) -> Image? {
|
||||
do {
|
||||
if let data = try? Data(contentsOf: from), let img = UIImage(data: data) {
|
||||
return Image(uiImage: img)
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var previewCard: some View {
|
||||
HStack {
|
||||
if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) {
|
||||
if !iconURL.isFileURL {
|
||||
AsyncLoadingImage(url: iconURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
.clipped()
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let localImage = localImage(from: iconURL) {
|
||||
localImage
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
.clipped()
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(viewModel.title ?? "")
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.appGrayText)
|
||||
.font(Font.system(size: 15, weight: .semibold))
|
||||
Text(viewModel.url ?? "")
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.appGrayText)
|
||||
.font(Font.system(size: 12, weight: .regular))
|
||||
}
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
Image(systemName: cloudIconName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 12, height: 12, alignment: .trailing)
|
||||
.foregroundColor(cloudIconColor)
|
||||
// .padding(.trailing, 6)
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8))
|
||||
}
|
||||
}
|
||||
.background(Color.appButtonBackground)
|
||||
.frame(maxWidth: .infinity, maxHeight: 61)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if DEBUG
|
||||
if let debugText = debugText {
|
||||
Text(debugText)
|
||||
}
|
||||
#endif
|
||||
Text(titleText)
|
||||
.foregroundColor(.appGrayText)
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 23)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
if let title = title {
|
||||
Text(title)
|
||||
.font(.appHeadline)
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 50)
|
||||
Divider()
|
||||
}
|
||||
Rectangle()
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(maxWidth: .infinity, maxHeight: 1)
|
||||
.opacity(0.06)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
previewCard
|
||||
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
|
||||
Spacer()
|
||||
|
||||
if case ShareExtensionStatus.success = status {
|
||||
HStack(spacing: 4) {
|
||||
Text("Saved to Omnivore")
|
||||
.font(.appTitleThree)
|
||||
.foregroundColor(.appGrayText)
|
||||
.padding(.trailing, 16)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(nil)
|
||||
}
|
||||
.padding()
|
||||
} else if case let ShareExtensionStatus.failed(error) = status {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(error.displayMessage)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Saving...")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
if FeatureFlag.enableRemindersFromShareExtension {
|
||||
VStack(spacing: 0) {
|
||||
CheckmarkButtonView(
|
||||
titleText: "Remind me tonight",
|
||||
isSelected: reminderTime == .tonight,
|
||||
action: { handleReminderTimeSelection(.tonight) }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
CheckmarkButtonView(
|
||||
titleText: "Remind me tomorrow",
|
||||
isSelected: reminderTime == .tomorrow,
|
||||
action: { handleReminderTimeSelection(.tomorrow) }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
CheckmarkButtonView(
|
||||
titleText: "Remind me this weekend",
|
||||
isSelected: reminderTime == .thisWeekend,
|
||||
action: { handleReminderTimeSelection(.thisWeekend) }
|
||||
)
|
||||
}
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
if FeatureFlag.enableSnoozeFromShareExtension {
|
||||
CheckmarkButtonView(
|
||||
titleText: "Hide it until then",
|
||||
isSelected: hideUntilReminded,
|
||||
action: { hideUntilReminded.toggle() }
|
||||
)
|
||||
.cornerRadius(8)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack {
|
||||
if case ShareExtensionStatus.success = status, FeatureFlag.enableReadNow {
|
||||
if FeatureFlag.enableReadNow {
|
||||
Button(
|
||||
action: { readNowButtonAction() },
|
||||
label: { Text("Read Now").frame(maxWidth: .infinity) }
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
if case ShareExtensionStatus.processing = status, FeatureFlag.enableReadNow {
|
||||
Button(action: {}, label: { ProgressView().frame(maxWidth: .infinity) })
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
Button(
|
||||
action: {
|
||||
dismissButtonTappedAction(reminderTime, hideUntilReminded)
|
||||
|
|
|
|||
23
apple/OmnivoreKit/Sources/Views/SizeModifier.swift
Normal file
23
apple/OmnivoreKit/Sources/Views/SizeModifier.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct SizePreferenceKey: PreferenceKey {
|
||||
public static var defaultValue: CGSize = .zero
|
||||
|
||||
public static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
public struct SizeModifier: ViewModifier {
|
||||
public init() {}
|
||||
|
||||
private var sizeView: some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
|
||||
}
|
||||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content.background(sizeView)
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ public struct TextChipButton: View {
|
|||
TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, negated: false, onTap: onTap)
|
||||
}
|
||||
|
||||
public static func makeFilterButton(title: String) -> TextChipButton {
|
||||
public static func makeMenuButton(title: String) -> TextChipButton {
|
||||
TextChipButton(title: title, color: .systemGray6, actionType: .show, negated: false, onTap: {})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct ReadingIllustrationXXLView: View {
|
||||
let width: CGFloat
|
||||
|
||||
public init(width: CGFloat) {
|
||||
self.width = width
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image.readingIllustrationXXL
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
.edgesIgnoringSafeArea([.vertical, .trailing])
|
||||
}
|
||||
}
|
||||
|
||||
public struct TitleLogoView: View {
|
||||
let handleHiddenGestureAction: () -> Void
|
||||
|
||||
public init(handleHiddenGestureAction: @escaping () -> Void) {
|
||||
self.handleHiddenGestureAction = handleHiddenGestureAction
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image.omnivoreTitleLogo
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(height: 40)
|
||||
.gesture(
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
handleHiddenGestureAction()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct GetStartedView: View {
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@Binding var showRegistrationView: Bool
|
||||
|
||||
public init(showRegistrationView: Binding<Bool>) {
|
||||
self._showRegistrationView = showRegistrationView
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 32) {
|
||||
Text("A better social\nreading experience\nstarts with Omnivore.")
|
||||
.font(.appTitle)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
BorderedButton(color: .appGrayTextContrast, text: "Get Started") {
|
||||
showRegistrationView = true
|
||||
}
|
||||
.frame(width: 220)
|
||||
}
|
||||
.padding(.leading, horizontalSizeClass == .compact ? 16 : 80)
|
||||
.padding(.top, horizontalSizeClass == .compact ? 16 : 0)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SplitColorBackground: View {
|
||||
let width: CGFloat
|
||||
|
||||
public init(width: CGFloat) {
|
||||
self.width = width
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Color.systemBackground.frame(width: width * 0.5)
|
||||
Color.appBackground.frame(width: width * 0.5)
|
||||
}
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
var ShareExtension = function() {};
|
||||
|
||||
function iconURL() {
|
||||
try {
|
||||
const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']").content
|
||||
if (previewImage) { return previewImage }
|
||||
|
||||
return document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']").href
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
|
||||
ShareExtension.prototype = {
|
||||
run: function(arguments) {
|
||||
arguments.completionFunction({
|
||||
'url': window.location.href,
|
||||
'title': document.title.toString(),
|
||||
'iconURL': iconURL(),
|
||||
'contentType': document.contentType,
|
||||
'documentHTML': new XMLSerializer().serializeToString(document),
|
||||
'originalHTML': new XMLSerializer().serializeToString(document)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import Utils
|
|||
|
||||
embed(
|
||||
childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext),
|
||||
heightRatio: 0.3
|
||||
heightRatio: 0.55
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
"@types/analytics-node": "^3.1.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/chai": "^4.2.18",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/chai-string": "^1.4.2",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
|
|
@ -109,6 +110,7 @@
|
|||
"@types/uuid": "^8.3.0",
|
||||
"@types/voca": "^1.4.0",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chai-string": "^1.5.0",
|
||||
"circular-dependency-plugin": "^5.2.0",
|
||||
"mocha": "^9.0.1",
|
||||
|
|
|
|||
|
|
@ -164,34 +164,37 @@ export const searchHighlights = async (
|
|||
|
||||
const searchBody = {
|
||||
query: {
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.userId': userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [
|
||||
{
|
||||
multi_match: {
|
||||
query: query || '',
|
||||
fields: ['highlights.quote', 'highlights.annotation'],
|
||||
operator: 'and',
|
||||
type: 'cross_fields',
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: query ? 1 : 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
inner_hits: {},
|
||||
],
|
||||
should: [
|
||||
{
|
||||
multi_match: {
|
||||
query: query || '',
|
||||
fields: [
|
||||
'highlights.quote^5',
|
||||
'title^3',
|
||||
'description^2',
|
||||
'content',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: query ? 1 : 0,
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
[sortField]: {
|
||||
order: sortOrder,
|
||||
|
|
@ -203,7 +206,7 @@ export const searchHighlights = async (
|
|||
],
|
||||
from,
|
||||
size,
|
||||
_source: ['title', 'slug', 'url', 'createdAt'],
|
||||
_source: ['title', 'slug', 'url', 'createdAt', 'highlights'],
|
||||
}
|
||||
|
||||
console.log('searching highlights in elastic', JSON.stringify(searchBody))
|
||||
|
|
@ -220,15 +223,13 @@ export const searchHighlights = async (
|
|||
const results: SearchItem[] = []
|
||||
response.body.hits.hits.forEach((hit) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
|
||||
hit.inner_hits.highlights.hits.hits.forEach(
|
||||
(innerHit: { _source: Highlight }) => {
|
||||
results.push({
|
||||
...hit._source,
|
||||
...innerHit._source,
|
||||
pageId: hit._id,
|
||||
})
|
||||
}
|
||||
)
|
||||
hit._source.highlights?.forEach((highlight) => {
|
||||
results.push({
|
||||
...highlight,
|
||||
...hit._source,
|
||||
pageId: hit._id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return [results, response.body.hits.total.value]
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ export interface Page {
|
|||
state: ArticleSavingRequestStatus
|
||||
taskName?: string
|
||||
language?: string
|
||||
readAt?: Date
|
||||
}
|
||||
|
||||
export interface SearchItem {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export type Article = {
|
|||
title: Scalars['String'];
|
||||
unsubHttpUrl?: Maybe<Scalars['String']>;
|
||||
unsubMailTo?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['Date'];
|
||||
uploadFileId?: Maybe<Scalars['ID']>;
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
|
@ -764,6 +765,7 @@ export type Link = {
|
|||
shareInfo: LinkShareInfo;
|
||||
shareStats: ShareStats;
|
||||
slug: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
|
|
@ -1181,6 +1183,7 @@ export type Page = {
|
|||
readableHtml: Scalars['String'];
|
||||
title: Scalars['String'];
|
||||
type: PageType;
|
||||
updatedAt: Scalars['Date'];
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
|
|
@ -1243,6 +1246,7 @@ export type Query = {
|
|||
newsletterEmails: NewsletterEmailsResult;
|
||||
reminder: ReminderResult;
|
||||
search: SearchResult;
|
||||
sendInstallInstructions: SendInstallInstructionsResult;
|
||||
sharedArticle: SharedArticleResult;
|
||||
subscriptions: SubscriptionsResult;
|
||||
user: UserResult;
|
||||
|
|
@ -1512,6 +1516,7 @@ export type SearchItem = {
|
|||
pageType: PageType;
|
||||
publishedAt?: Maybe<Scalars['Date']>;
|
||||
quote?: Maybe<Scalars['String']>;
|
||||
readAt?: Maybe<Scalars['Date']>;
|
||||
readingProgressAnchorIndex?: Maybe<Scalars['Int']>;
|
||||
readingProgressPercent?: Maybe<Scalars['Float']>;
|
||||
shortId?: Maybe<Scalars['String']>;
|
||||
|
|
@ -1522,6 +1527,7 @@ export type SearchItem = {
|
|||
title: Scalars['String'];
|
||||
unsubHttpUrl?: Maybe<Scalars['String']>;
|
||||
unsubMailTo?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['Date'];
|
||||
uploadFileId?: Maybe<Scalars['ID']>;
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
|
@ -1540,6 +1546,25 @@ export type SearchSuccess = {
|
|||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type SendInstallInstructionsError = {
|
||||
__typename?: 'SendInstallInstructionsError';
|
||||
errorCodes: Array<SendInstallInstructionsErrorCode>;
|
||||
};
|
||||
|
||||
export enum SendInstallInstructionsErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
Forbidden = 'FORBIDDEN',
|
||||
NotFound = 'NOT_FOUND',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type SendInstallInstructionsResult = SendInstallInstructionsError | SendInstallInstructionsSuccess;
|
||||
|
||||
export type SendInstallInstructionsSuccess = {
|
||||
__typename?: 'SendInstallInstructionsSuccess';
|
||||
sent: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type SetBookmarkArticleError = {
|
||||
__typename?: 'SetBookmarkArticleError';
|
||||
errorCodes: Array<SetBookmarkArticleErrorCode>;
|
||||
|
|
@ -2527,6 +2552,10 @@ export type ResolversTypes = {
|
|||
SearchItemEdge: ResolverTypeWrapper<SearchItemEdge>;
|
||||
SearchResult: ResolversTypes['SearchError'] | ResolversTypes['SearchSuccess'];
|
||||
SearchSuccess: ResolverTypeWrapper<SearchSuccess>;
|
||||
SendInstallInstructionsError: ResolverTypeWrapper<SendInstallInstructionsError>;
|
||||
SendInstallInstructionsErrorCode: SendInstallInstructionsErrorCode;
|
||||
SendInstallInstructionsResult: ResolversTypes['SendInstallInstructionsError'] | ResolversTypes['SendInstallInstructionsSuccess'];
|
||||
SendInstallInstructionsSuccess: ResolverTypeWrapper<SendInstallInstructionsSuccess>;
|
||||
SetBookmarkArticleError: ResolverTypeWrapper<SetBookmarkArticleError>;
|
||||
SetBookmarkArticleErrorCode: SetBookmarkArticleErrorCode;
|
||||
SetBookmarkArticleInput: SetBookmarkArticleInput;
|
||||
|
|
@ -2832,6 +2861,9 @@ export type ResolversParentTypes = {
|
|||
SearchItemEdge: SearchItemEdge;
|
||||
SearchResult: ResolversParentTypes['SearchError'] | ResolversParentTypes['SearchSuccess'];
|
||||
SearchSuccess: SearchSuccess;
|
||||
SendInstallInstructionsError: SendInstallInstructionsError;
|
||||
SendInstallInstructionsResult: ResolversParentTypes['SendInstallInstructionsError'] | ResolversParentTypes['SendInstallInstructionsSuccess'];
|
||||
SendInstallInstructionsSuccess: SendInstallInstructionsSuccess;
|
||||
SetBookmarkArticleError: SetBookmarkArticleError;
|
||||
SetBookmarkArticleInput: SetBookmarkArticleInput;
|
||||
SetBookmarkArticleResult: ResolversParentTypes['SetBookmarkArticleError'] | ResolversParentTypes['SetBookmarkArticleSuccess'];
|
||||
|
|
@ -3038,6 +3070,7 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
|
|||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
unsubHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
unsubMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
|
@ -3501,6 +3534,7 @@ export type LinkResolvers<ContextType = ResolverContext, ParentType extends Reso
|
|||
shareInfo?: Resolver<ResolversTypes['LinkShareInfo'], ParentType, ContextType>;
|
||||
shareStats?: Resolver<ResolversTypes['ShareStats'], ParentType, ContextType>;
|
||||
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -3642,6 +3676,7 @@ export type PageResolvers<ContextType = ResolverContext, ParentType extends Reso
|
|||
readableHtml?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['PageType'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -3679,6 +3714,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
|
|||
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
|
||||
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
|
||||
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
|
||||
sendInstallInstructions?: Resolver<ResolversTypes['SendInstallInstructionsResult'], ParentType, ContextType>;
|
||||
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
|
||||
subscriptions?: Resolver<ResolversTypes['SubscriptionsResult'], ParentType, ContextType, Partial<QuerySubscriptionsArgs>>;
|
||||
user?: Resolver<ResolversTypes['UserResult'], ParentType, ContextType, Partial<QueryUserArgs>>;
|
||||
|
|
@ -3798,6 +3834,7 @@ export type SearchItemResolvers<ContextType = ResolverContext, ParentType extend
|
|||
pageType?: Resolver<ResolversTypes['PageType'], ParentType, ContextType>;
|
||||
publishedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
quote?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
readAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
readingProgressAnchorIndex?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
readingProgressPercent?: Resolver<Maybe<ResolversTypes['Float']>, ParentType, ContextType>;
|
||||
shortId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
|
|
@ -3808,6 +3845,7 @@ export type SearchItemResolvers<ContextType = ResolverContext, ParentType extend
|
|||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
unsubHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
unsubMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
|
@ -3829,6 +3867,20 @@ export type SearchSuccessResolvers<ContextType = ResolverContext, ParentType ext
|
|||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SendInstallInstructionsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SendInstallInstructionsError'] = ResolversParentTypes['SendInstallInstructionsError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['SendInstallInstructionsErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SendInstallInstructionsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SendInstallInstructionsResult'] = ResolversParentTypes['SendInstallInstructionsResult']> = {
|
||||
__resolveType: TypeResolveFn<'SendInstallInstructionsError' | 'SendInstallInstructionsSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SendInstallInstructionsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SendInstallInstructionsSuccess'] = ResolversParentTypes['SendInstallInstructionsSuccess']> = {
|
||||
sent?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SetBookmarkArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetBookmarkArticleError'] = ResolversParentTypes['SetBookmarkArticleError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['SetBookmarkArticleErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
|
@ -4410,6 +4462,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
|||
SearchItemEdge?: SearchItemEdgeResolvers<ContextType>;
|
||||
SearchResult?: SearchResultResolvers<ContextType>;
|
||||
SearchSuccess?: SearchSuccessResolvers<ContextType>;
|
||||
SendInstallInstructionsError?: SendInstallInstructionsErrorResolvers<ContextType>;
|
||||
SendInstallInstructionsResult?: SendInstallInstructionsResultResolvers<ContextType>;
|
||||
SendInstallInstructionsSuccess?: SendInstallInstructionsSuccessResolvers<ContextType>;
|
||||
SetBookmarkArticleError?: SetBookmarkArticleErrorResolvers<ContextType>;
|
||||
SetBookmarkArticleResult?: SetBookmarkArticleResultResolvers<ContextType>;
|
||||
SetBookmarkArticleSuccess?: SetBookmarkArticleSuccessResolvers<ContextType>;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ type Article {
|
|||
title: String!
|
||||
unsubHttpUrl: String
|
||||
unsubMailTo: String
|
||||
updatedAt: Date!
|
||||
uploadFileId: ID
|
||||
url: String!
|
||||
}
|
||||
|
|
@ -675,6 +676,7 @@ type Link {
|
|||
shareInfo: LinkShareInfo!
|
||||
shareStats: ShareStats!
|
||||
slug: String!
|
||||
updatedAt: Date!
|
||||
url: String!
|
||||
}
|
||||
|
||||
|
|
@ -840,6 +842,7 @@ type Page {
|
|||
readableHtml: String!
|
||||
title: String!
|
||||
type: PageType!
|
||||
updatedAt: Date!
|
||||
url: String!
|
||||
}
|
||||
|
||||
|
|
@ -899,6 +902,7 @@ type Query {
|
|||
newsletterEmails: NewsletterEmailsResult!
|
||||
reminder(linkId: ID!): ReminderResult!
|
||||
search(after: String, first: Int, query: String): SearchResult!
|
||||
sendInstallInstructions: SendInstallInstructionsResult!
|
||||
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
|
||||
subscriptions(sort: SortParams): SubscriptionsResult!
|
||||
user(userId: ID, username: String): UserResult!
|
||||
|
|
@ -1075,6 +1079,7 @@ type SearchItem {
|
|||
pageType: PageType!
|
||||
publishedAt: Date
|
||||
quote: String
|
||||
readAt: Date
|
||||
readingProgressAnchorIndex: Int
|
||||
readingProgressPercent: Float
|
||||
shortId: String
|
||||
|
|
@ -1085,6 +1090,7 @@ type SearchItem {
|
|||
title: String!
|
||||
unsubHttpUrl: String
|
||||
unsubMailTo: String
|
||||
updatedAt: Date!
|
||||
uploadFileId: ID
|
||||
url: String!
|
||||
}
|
||||
|
|
@ -1101,6 +1107,23 @@ type SearchSuccess {
|
|||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type SendInstallInstructionsError {
|
||||
errorCodes: [SendInstallInstructionsErrorCode!]!
|
||||
}
|
||||
|
||||
enum SendInstallInstructionsErrorCode {
|
||||
BAD_REQUEST
|
||||
FORBIDDEN
|
||||
NOT_FOUND
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
union SendInstallInstructionsResult = SendInstallInstructionsError | SendInstallInstructionsSuccess
|
||||
|
||||
type SendInstallInstructionsSuccess {
|
||||
sent: Boolean!
|
||||
}
|
||||
|
||||
type SetBookmarkArticleError {
|
||||
errorCodes: [SetBookmarkArticleErrorCode!]!
|
||||
}
|
||||
|
|
|
|||
2
packages/api/src/readability.d.ts
vendored
2
packages/api/src/readability.d.ts
vendored
|
|
@ -72,7 +72,7 @@ declare module '@omnivore/readability' {
|
|||
*
|
||||
* The response will be null if the processing failed (https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L2038)
|
||||
*/
|
||||
parse(): Readability.ParseResult | null
|
||||
async parse(): Promise<Readability.ParseResult | null>
|
||||
}
|
||||
|
||||
namespace Readability {
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ import {
|
|||
isParsingTimeout,
|
||||
pageError,
|
||||
stringToHash,
|
||||
titleForFilePath,
|
||||
userDataToUser,
|
||||
validatedDate,
|
||||
titleForFilePath,
|
||||
} from '../../utils/helpers'
|
||||
import {
|
||||
ParsedContentPuppeteer,
|
||||
|
|
@ -729,9 +729,10 @@ export const saveArticleReadingProgressResolver = authorized<
|
|||
readingProgressAnchorIndex: shouldUpdate
|
||||
? readingProgressAnchorIndex
|
||||
: page.readingProgressAnchorIndex,
|
||||
readAt: new Date(),
|
||||
}
|
||||
|
||||
shouldUpdate && (await updatePage(id, updatedPart, { pubsub, uid }))
|
||||
await updatePage(id, updatedPart, { pubsub, uid })
|
||||
|
||||
return {
|
||||
updatedArticle: {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import {
|
|||
savePageResolver,
|
||||
saveUrlResolver,
|
||||
searchResolver,
|
||||
sendInstallInstructionsResolver,
|
||||
setBookmarkArticleResolver,
|
||||
setDeviceTokenResolver,
|
||||
setFollowResolver,
|
||||
|
|
@ -179,6 +180,7 @@ export const functionResolvers = {
|
|||
labels: labelsResolver,
|
||||
search: searchResolver,
|
||||
subscriptions: subscriptionsResolver,
|
||||
sendInstallInstructions: sendInstallInstructionsResolver,
|
||||
webhooks: webhooksResolver,
|
||||
webhook: webhookResolver,
|
||||
apiKeys: apiKeysResolver,
|
||||
|
|
@ -572,6 +574,7 @@ export const functionResolvers = {
|
|||
...resultResolveTypeResolver('Subscriptions'),
|
||||
...resultResolveTypeResolver('Unsubscribe'),
|
||||
...resultResolveTypeResolver('UpdateLabel'),
|
||||
...resultResolveTypeResolver('SendInstallInstructions'),
|
||||
...resultResolveTypeResolver('UpdatePage'),
|
||||
...resultResolveTypeResolver('Subscribe'),
|
||||
...resultResolveTypeResolver('AddPopularRead'),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export * from './report'
|
|||
export * from './links'
|
||||
export * from './newsletters'
|
||||
export * from './save'
|
||||
export * from './send_install_instructions'
|
||||
export * from './reminders'
|
||||
export * from './user_device_tokens'
|
||||
export * from './labels'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
SendInstallInstructionsError,
|
||||
SendInstallInstructionsErrorCode,
|
||||
SendInstallInstructionsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { sendEmail } from '../../utils/sendEmail'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { User } from '../../entity/user'
|
||||
|
||||
const INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID =
|
||||
'd-c576bdc3b9a849dab250655ba14c7794'
|
||||
|
||||
export const sendInstallInstructionsResolver = authorized<
|
||||
SendInstallInstructionsSuccess,
|
||||
SendInstallInstructionsError
|
||||
>(async (_parent, _args, { claims }) => {
|
||||
try {
|
||||
const user = await AppDataSource.getRepository(User).findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return { errorCodes: [SendInstallInstructionsErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
const sendInstallInstructions = await sendEmail({
|
||||
from: 'msgs@omnivore.app',
|
||||
templateId: INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID,
|
||||
to: user?.email,
|
||||
})
|
||||
|
||||
return {
|
||||
sent: sendInstallInstructions,
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
return {
|
||||
errorCodes: [SendInstallInstructionsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -112,7 +112,7 @@ export const uploadFileRequestResolver: ResolverFn<
|
|||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
state: ArticleSavingRequestStatus.Processing,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
},
|
||||
ctx
|
||||
)
|
||||
|
|
|
|||
153
packages/api/src/routers/page_router.ts
Normal file
153
packages/api/src/routers/page_router.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import express from 'express'
|
||||
import {
|
||||
ArticleSavingRequestStatus,
|
||||
CreateArticleErrorCode,
|
||||
PageType,
|
||||
UploadFileStatus,
|
||||
} from '../generated/graphql'
|
||||
import { isSiteBlockedForParse } from '../utils/blocked'
|
||||
import cors from 'cors'
|
||||
import { env } from '../env'
|
||||
import { buildLogger } from '../utils/logger'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { corsConfig } from '../utils/corsConfig'
|
||||
import { createPageSaveRequest } from '../services/create_page_save_request'
|
||||
import { initModels } from '../server'
|
||||
import { kx } from '../datalayer/knex_config'
|
||||
import {
|
||||
fileNameForFilePath,
|
||||
generateSlug,
|
||||
isString,
|
||||
titleForFilePath,
|
||||
validateUuid,
|
||||
} from '../utils/helpers'
|
||||
import {
|
||||
generateUploadFilePathName,
|
||||
generateUploadSignedUrl,
|
||||
} from '../utils/uploads'
|
||||
import { Claims } from '../resolvers/types'
|
||||
import { createPage, getPageByParam, updatePage } from '../elastic/pages'
|
||||
import { createPubSubClient } from '../datalayer/pubsub'
|
||||
|
||||
const logger = buildLogger('app.dispatch')
|
||||
|
||||
export function pageRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
// Create a page from an uploaded PDF document
|
||||
router.options('/pdf', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
|
||||
router.put('/pdf', cors<express.Request>(corsConfig), async (req, res) => {
|
||||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
const claims = jwt.decode(token) as Claims
|
||||
|
||||
// Get the content type from the query params
|
||||
const { url, clientRequestId } = req.query
|
||||
const contentType = req.headers['content-type']
|
||||
console.log(
|
||||
'contentType',
|
||||
contentType,
|
||||
'url',
|
||||
url,
|
||||
'clientRequestId',
|
||||
clientRequestId
|
||||
)
|
||||
|
||||
if (
|
||||
!isString(url) ||
|
||||
!isString(contentType) ||
|
||||
!isString(clientRequestId)
|
||||
) {
|
||||
console.log(
|
||||
'creating page from pdf failed',
|
||||
url,
|
||||
contentType,
|
||||
clientRequestId
|
||||
)
|
||||
return res.status(400).send({ errorCode: 'BAD_DATA' })
|
||||
}
|
||||
|
||||
if (!validateUuid(clientRequestId)) {
|
||||
console.log('creating page from pdf failed invalid uuid')
|
||||
return res.status(400).send({ errorCode: 'BAD_DATA' })
|
||||
}
|
||||
|
||||
const models = initModels(kx, false)
|
||||
const ctx = {
|
||||
uid: claims.uid,
|
||||
pubsub: createPubSubClient(),
|
||||
}
|
||||
|
||||
const title = titleForFilePath(url)
|
||||
const fileName = fileNameForFilePath(url)
|
||||
const uploadFileData = await models.uploadFile.create({
|
||||
url: url,
|
||||
userId: claims.uid,
|
||||
fileName: fileName,
|
||||
status: UploadFileStatus.Initialized,
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
|
||||
const uploadFilePathName = generateUploadFilePathName(
|
||||
uploadFileData.id,
|
||||
fileName
|
||||
)
|
||||
|
||||
const signedUrl = await generateUploadSignedUrl(
|
||||
uploadFilePathName,
|
||||
'application/pdf'
|
||||
)
|
||||
|
||||
const page = await getPageByParam({
|
||||
userId: claims.uid,
|
||||
url: url,
|
||||
})
|
||||
|
||||
if (page) {
|
||||
console.log('updating page')
|
||||
await updatePage(
|
||||
page.id,
|
||||
{
|
||||
savedAt: new Date(),
|
||||
archivedAt: null,
|
||||
},
|
||||
ctx
|
||||
)
|
||||
} else {
|
||||
console.log('creating page')
|
||||
const pageId = await createPage(
|
||||
{
|
||||
url: signedUrl,
|
||||
id: clientRequestId,
|
||||
userId: claims.uid,
|
||||
title: title,
|
||||
hash: uploadFilePathName,
|
||||
content: '',
|
||||
pageType: PageType.File,
|
||||
uploadFileId: uploadFileData.id,
|
||||
slug: generateSlug(uploadFilePathName),
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
state: ArticleSavingRequestStatus.Processing,
|
||||
},
|
||||
ctx
|
||||
)
|
||||
if (!pageId) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('redirecting to signed URL', signedUrl)
|
||||
return res.redirect(signedUrl)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ export function emailsServiceRouter() {
|
|||
return
|
||||
}
|
||||
|
||||
if (isProbablyNewsletter(data.html)) {
|
||||
if (await isProbablyNewsletter(data.html)) {
|
||||
console.log('handling as newsletter', data)
|
||||
await saveNewsletterEmail({
|
||||
email: data.to,
|
||||
|
|
|
|||
|
|
@ -270,6 +270,7 @@ const schema = gql`
|
|||
slug: String!
|
||||
savedBy: User!
|
||||
savedAt: Date!
|
||||
updatedAt: Date!
|
||||
savedByViewer: Boolean!
|
||||
postedByViewer: Boolean!
|
||||
|
||||
|
|
@ -308,6 +309,7 @@ const schema = gql`
|
|||
originalHtml: String!
|
||||
readableHtml: String!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
}
|
||||
|
||||
type Article {
|
||||
|
|
@ -327,6 +329,7 @@ const schema = gql`
|
|||
originalHtml: String
|
||||
createdAt: Date!
|
||||
savedAt: Date!
|
||||
updatedAt: Date!
|
||||
publishedAt: Date
|
||||
readingProgressPercent: Float!
|
||||
readingProgressAnchorIndex: Int!
|
||||
|
|
@ -1255,6 +1258,25 @@ const schema = gql`
|
|||
errorCodes: [DeleteReminderErrorCode!]!
|
||||
}
|
||||
|
||||
type SendInstallInstructionsSuccess {
|
||||
sent: Boolean!
|
||||
}
|
||||
|
||||
enum SendInstallInstructionsErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
FORBIDDEN
|
||||
}
|
||||
|
||||
type SendInstallInstructionsError {
|
||||
errorCodes: [SendInstallInstructionsErrorCode!]!
|
||||
}
|
||||
|
||||
union SendInstallInstructionsResult =
|
||||
SendInstallInstructionsSuccess
|
||||
| SendInstallInstructionsError
|
||||
|
||||
input SetDeviceTokenInput {
|
||||
id: ID
|
||||
token: String
|
||||
|
|
@ -1449,6 +1471,7 @@ const schema = gql`
|
|||
pageType: PageType!
|
||||
contentReader: ContentReader!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
isArchived: Boolean!
|
||||
readingProgressPercent: Float
|
||||
readingProgressAnchorIndex: Int
|
||||
|
|
@ -1472,6 +1495,7 @@ const schema = gql`
|
|||
state: ArticleSavingRequestStatus
|
||||
siteName: String
|
||||
language: String
|
||||
readAt: Date
|
||||
}
|
||||
|
||||
type SearchItemEdge {
|
||||
|
|
@ -1821,6 +1845,7 @@ const schema = gql`
|
|||
labels: LabelsResult!
|
||||
search(after: String, first: Int, query: String): SearchResult!
|
||||
subscriptions(sort: SortParams): SubscriptionsResult!
|
||||
sendInstallInstructions: SendInstallInstructionsResult!
|
||||
webhooks: WebhooksResult!
|
||||
webhook(id: ID!): WebhookResult!
|
||||
apiKeys: ApiKeysResult!
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { config, loggers } from 'winston'
|
|||
import { sentryConfig } from './sentry'
|
||||
import { makeApolloServer } from './apollo'
|
||||
import { authRouter } from './routers/auth/auth_router'
|
||||
import { pageRouter } from './routers/page_router'
|
||||
import { articleRouter } from './routers/article_router'
|
||||
import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router'
|
||||
import { contentServiceRouter } from './routers/svc/content'
|
||||
|
|
@ -105,6 +106,7 @@ export const createApp = (): {
|
|||
app.get('/_ah/health', (req, res) => res.sendStatus(200))
|
||||
|
||||
app.use('/api/auth', authRouter())
|
||||
app.use('/api/page', pageRouter())
|
||||
app.use('/api/article', articleRouter())
|
||||
app.use('/api/mobile-auth', mobileAuthRouter())
|
||||
app.use('/svc/pubsub/content', contentServiceRouter())
|
||||
|
|
|
|||
|
|
@ -233,6 +233,18 @@ export const validatedDate = (
|
|||
}
|
||||
}
|
||||
|
||||
export const fileNameForFilePath = (urlStr: string): string => {
|
||||
const url = normalizeUrl(new URL(urlStr).href, {
|
||||
stripHash: true,
|
||||
stripWWW: false,
|
||||
})
|
||||
const fileName = decodeURI(path.basename(new URL(url).pathname)).replace(
|
||||
/[^a-zA-Z0-9-_.]/g,
|
||||
''
|
||||
)
|
||||
return fileName
|
||||
}
|
||||
|
||||
export const titleForFilePath = (url: string): string => {
|
||||
try {
|
||||
const title = decodeURI(path.basename(new URL(url).pathname, '.pdf'))
|
||||
|
|
@ -242,3 +254,13 @@ export const titleForFilePath = (url: string): string => {
|
|||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export const validateUuid = (str: string): boolean => {
|
||||
const regexExp =
|
||||
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi
|
||||
return regexExp.test(str)
|
||||
}
|
||||
|
||||
export const isString = (check: any): check is string => {
|
||||
return typeof check === 'string' || check instanceof String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,12 +133,12 @@ const getPurifiedContent = (html: string): Document => {
|
|||
return parseHTML(clean).document
|
||||
}
|
||||
|
||||
const getReadabilityResult = (
|
||||
const getReadabilityResult = async (
|
||||
url: string,
|
||||
html: string,
|
||||
document: Document,
|
||||
isNewsletter?: boolean
|
||||
): Readability.ParseResult | null => {
|
||||
): Promise<Readability.ParseResult | null> => {
|
||||
// First attempt to read the article as is.
|
||||
// if that fails attempt to purify then read
|
||||
const sources = [
|
||||
|
|
@ -157,7 +157,7 @@ const getReadabilityResult = (
|
|||
}
|
||||
|
||||
try {
|
||||
const article = new Readability(document, {
|
||||
const article = await new Readability(document, {
|
||||
debug: DEBUG_MODE,
|
||||
createImageProxyUrl,
|
||||
keepTables: isNewsletter,
|
||||
|
|
@ -236,7 +236,7 @@ export const parsePreparedContent = async (
|
|||
await applyHandlers(url, dom)
|
||||
|
||||
try {
|
||||
article = getReadabilityResult(url, document, dom, isNewsletter)
|
||||
article = await getReadabilityResult(url, document, dom, isNewsletter)
|
||||
if (!article?.textContent && allowRetry) {
|
||||
const newDocument = {
|
||||
...preparedDocument,
|
||||
|
|
@ -406,10 +406,10 @@ export const parseUrlMetadata = async (
|
|||
// based on it's contents.
|
||||
// TODO: when we consolidate the handlers we could include this
|
||||
// as a utility method on each one.
|
||||
export const isProbablyNewsletter = (html: string): boolean => {
|
||||
export const isProbablyNewsletter = async (html: string): Promise<boolean> => {
|
||||
const dom = parseHTML(html).document
|
||||
const domCopy = parseHTML(dom.documentElement.outerHTML).document
|
||||
const article = new Readability(domCopy, {
|
||||
const article = await new Readability(domCopy, {
|
||||
debug: false,
|
||||
keepTables: true,
|
||||
}).parse()
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export enum SortBy {
|
|||
UPDATED = 'updatedAt',
|
||||
SCORE = '_score',
|
||||
PUBLISHED = 'publishedAt',
|
||||
READ = 'readAt',
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
|
|
@ -178,6 +179,11 @@ const parseSortParams = (str?: string): SortParams | undefined => {
|
|||
by: SortBy.PUBLISHED,
|
||||
order: sortOrder,
|
||||
}
|
||||
case 'READ':
|
||||
return {
|
||||
by: SortBy.READ,
|
||||
order: sortOrder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +329,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
|||
break
|
||||
}
|
||||
case 'saved':
|
||||
case 'read':
|
||||
case 'published': {
|
||||
const dateFilter = parseDateFilter(keyword.keyword, keyword.value)
|
||||
dateFilter && result.dateFilters.push(dateFilter)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ export const generateUploadSignedUrl = async (
|
|||
contentType: string,
|
||||
selectedBucket?: string
|
||||
): Promise<string> => {
|
||||
if (env.dev.isLocal) {
|
||||
return 'http://localhost:3000/uploads/' + filePathName
|
||||
}
|
||||
// if (env.dev.isLocal) {
|
||||
// return 'http://localhost:3000/uploads/' + filePathName
|
||||
// }
|
||||
|
||||
// These options will allow temporary uploading of file with requested content type
|
||||
const options: GetSignedUrlConfig = {
|
||||
|
|
@ -59,9 +59,9 @@ export const makeStorageFilePublic = async (
|
|||
id: string,
|
||||
fileName: string
|
||||
): Promise<string> => {
|
||||
if (env.dev.isLocal) {
|
||||
return 'http://localhost:3000/public/' + id + '/' + fileName
|
||||
}
|
||||
// if (env.dev.isLocal) {
|
||||
// return 'http://localhost:3000/public/' + id + '/' + fileName
|
||||
// }
|
||||
|
||||
// Makes the file public
|
||||
const filePathName = generateUploadFilePathName(id, fileName)
|
||||
|
|
@ -75,12 +75,12 @@ export const getStorageFileDetails = async (
|
|||
id: string,
|
||||
fileName: string
|
||||
): Promise<{ md5Hash: string; fileUrl: string }> => {
|
||||
if (env.dev.isLocal) {
|
||||
return {
|
||||
md5Hash: 'some_md5_hash',
|
||||
fileUrl: 'http://localhost:3000/public/' + id + '/' + fileName,
|
||||
}
|
||||
}
|
||||
// if (env.dev.isLocal) {
|
||||
// return {
|
||||
// md5Hash: 'some_md5_hash',
|
||||
// fileUrl: 'http://localhost:3000/public/' + id + '/' + fileName,
|
||||
// }
|
||||
// }
|
||||
|
||||
const filePathName = generateUploadFilePathName(id, fileName)
|
||||
const file = storage.bucket(bucketName).file(filePathName)
|
||||
|
|
@ -103,9 +103,9 @@ export const uploadToSignedUrl = async (
|
|||
data: Buffer,
|
||||
contentType: string
|
||||
): Promise<void> => {
|
||||
if (env.dev.isLocal) {
|
||||
return
|
||||
}
|
||||
// if (env.dev.isLocal) {
|
||||
// return
|
||||
// }
|
||||
|
||||
await axios.put(uploadUrl, data, {
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ const articlesQuery = (after = '') => {
|
|||
id
|
||||
url
|
||||
linkId
|
||||
createdAt
|
||||
updatedAt
|
||||
originalArticleUrl
|
||||
labels {
|
||||
id
|
||||
|
|
@ -150,6 +152,7 @@ const getArticleQuery = (slug: string) => {
|
|||
annotation
|
||||
sharedAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,6 +177,8 @@ const searchQuery = (keyword = '') => {
|
|||
node {
|
||||
id
|
||||
url
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue