Merge pull request #803 from omnivore-app/main

Omnivore 1.9 web
This commit is contained in:
Jackson Harper 2022-06-14 21:05:00 -07:00 committed by GitHub
commit c2007f4337
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
251 changed files with 9710 additions and 2020 deletions

View file

@ -7,4 +7,4 @@ graphql_gen:
extension_gen:
pushd ../pkg/extension/ && yarn build-prod && popd
cp -r ../pkg/extension/dist/* Sources/SafariExtension/Resources/.
cat ../pkg/extension/dist/manifest.json | jq '.background.persistent = false' > Sources/SafariExtension/Resources/manifest.json
cat ../pkg/extension/dist/manifest.json | jq '.background.persistent = false' | jq '.permissions = ["activeTab", "storage", "nativeMessaging", "https://*/**", "http://*/**"]' > Sources/SafariExtension/Resources/manifest.json

View file

@ -1229,7 +1229,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1239,7 +1239,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
@ -1260,7 +1260,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1270,7 +1270,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1341,7 +1341,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1352,7 +1352,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1375,7 +1375,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1386,7 +1386,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1441,7 +1441,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1473,7 +1473,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1512,7 +1512,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1538,7 +1538,7 @@
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1551,7 +1551,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1576,7 +1576,7 @@
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 52;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1589,7 +1589,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1674,7 +1674,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
SDKROOT = iphoneos;
@ -1728,7 +1728,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1756,7 +1756,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.8.0;
MARKETING_VERSION = 1.9.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
SDKROOT = iphoneos;

View file

@ -18,7 +18,7 @@ class ExtensionSaveService {
self.queue = OperationQueue()
}
private func queueSaveOperation(_ pageScrape: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
private func queueSaveOperation(_ pageScrape: PageScrapePayload, shareExtensionViewModel: ShareExtensionChildViewModel) {
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
guard !expiring else {
self.queue.cancelAllOperations()
@ -26,14 +26,14 @@ class ExtensionSaveService {
return
}
let operation = SaveOperation(pageScrapePayload: pageScrape, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
let operation = SaveOperation(pageScrapePayload: pageScrape, shareExtensionViewModel: shareExtensionViewModel)
self.queue.addOperation(operation)
self.queue.waitUntilAllOperationsAreFinished()
}
}
public func save(_ extensionContext: NSExtensionContext, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
public func save(_ extensionContext: NSExtensionContext, shareExtensionViewModel: ShareExtensionChildViewModel) {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
guard let self = self else { return }
@ -68,8 +68,8 @@ class ExtensionSaveService {
}
}
}
self.queueSaveOperation(payload, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
case let .failure:
self.queueSaveOperation(payload, shareExtensionViewModel: shareExtensionViewModel)
case .failure:
DispatchQueue.main.async {
shareExtensionViewModel.status = .failed(error: .unknown(description: "Could not retrieve content"))
}
@ -78,7 +78,6 @@ class ExtensionSaveService {
}
class SaveOperation: Operation, URLSessionDelegate {
let requestId: String
let services: Services
let pageScrapePayload: PageScrapePayload
let shareExtensionViewModel: ShareExtensionChildViewModel
@ -92,9 +91,8 @@ class ExtensionSaveService {
case finished
}
init(pageScrapePayload: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
init(pageScrapePayload: PageScrapePayload, shareExtensionViewModel: ShareExtensionChildViewModel) {
self.pageScrapePayload = pageScrapePayload
self.requestId = requestId
self.shareExtensionViewModel = shareExtensionViewModel
self.state = .created
@ -138,7 +136,7 @@ class ExtensionSaveService {
queue = OperationQueue()
Task {
await persist(services: self.services, pageScrapePayload: self.pageScrapePayload, requestId: self.requestId)
await persist(services: self.services, pageScrapePayload: self.pageScrapePayload)
}
}
@ -146,38 +144,48 @@ class ExtensionSaveService {
super.cancel()
}
private func updateStatus(newStatus: ShareExtensionStatus) {
private func updateStatus(_ requestId: String?, newStatus: ShareExtensionStatus) {
DispatchQueue.main.async {
self.shareExtensionViewModel.status = newStatus
if let requestId = requestId {
self.shareExtensionViewModel.requestId = requestId
}
}
}
private func persist(services: Services, pageScrapePayload: PageScrapePayload, requestId: String) async {
private func persist(services: Services, pageScrapePayload: PageScrapePayload) async {
var requestId = shareExtensionViewModel.requestId
do {
try await services.dataService.persistPageScrapePayload(pageScrapePayload, requestId: requestId)
} catch {
updateStatus(newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")))
updateStatus(nil, newStatus: .failed(error: SaveArticleError.unknown(description: "Unable to access content")))
return
}
do {
updateStatus(newStatus: .saved)
updateStatus(requestId, newStatus: .saved)
switch pageScrapePayload.contentType {
case .none:
try await services.dataService.syncUrl(id: requestId, url: pageScrapePayload.url)
requestId = try await services.dataService.createPageFromUrl(id: requestId, url: pageScrapePayload.url)
case let .pdf(localUrl):
try await services.dataService.syncPdf(id: requestId, localPdfURL: localUrl, url: pageScrapePayload.url)
try await services.dataService.createPageFromPdf(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)
requestId = try await services.dataService.createPage(
id: requestId,
originalHtml: html,
title: title,
url: pageScrapePayload.url
)
}
} catch {
updateStatus(newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")))
updateStatus(nil, newStatus: .syncFailed(error: SaveArticleError.unknown(description: "Unknown Error")))
return
}
updateStatus(newStatus: .synced)
updateStatus(requestId, newStatus: .synced)
state = .finished
}
}

View file

@ -22,13 +22,11 @@ public extension PlatformViewController {
public class ShareExtensionViewModel: ObservableObject {
@Published var title: String?
@Published var status: ShareExtensionStatus = .processing
@Published var debugText: String?
let saveService = ExtensionSaveService()
let requestId = UUID().uuidString.lowercased()
func handleReadNowAction(extensionContext: NSExtensionContext?) {
func handleReadNowAction(requestId: String, extensionContext: NSExtensionContext?) {
#if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)")
@ -40,15 +38,11 @@ public class ShareExtensionViewModel: ObservableObject {
func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) {
if let extensionContext = extensionContext {
saveService.save(extensionContext, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
saveService.save(extensionContext, shareExtensionViewModel: shareExtensionViewModel)
} else {
updateStatus(.failed(error: .unknown(description: "Internal Error")))
}
}
private func updateStatus(_ newStatus: ShareExtensionStatus) {
DispatchQueue.main.async {
self.status = newStatus
DispatchQueue.main.async {
shareExtensionViewModel.status = .failed(error: .unknown(description: "Internal Error"))
}
}
}
}
@ -62,7 +56,7 @@ struct ShareExtensionView: View {
ShareExtensionChildView(
viewModel: childViewModel,
onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
readNowButtonAction: { viewModel.handleReadNowAction(requestId: $0, extensionContext: extensionContext) },
dismissButtonTappedAction: { _, _ in
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}

View file

@ -21,15 +21,14 @@ import Utils
let url: URL
}
let pdfURL: URL
let viewModel: PDFViewerViewModel
@StateObject var pdfStateObject = PDFStateObject()
@State var readerView: Bool = false
@State private var shareLink: ShareLink?
@State private var errorMessage: String?
init(remoteURL: URL, viewModel: PDFViewerViewModel) {
self.pdfURL = viewModel.pdfItem.localPdfURL ?? remoteURL
init(viewModel: PDFViewerViewModel) {
self.viewModel = viewModel
}
@ -135,12 +134,21 @@ import Utils
.sheet(item: $shareLink) {
ShareSheet(activityItems: [$0.url])
}
} else if let errorMessage = errorMessage {
Text(errorMessage)
} else {
ProgressView()
.task {
let document = HighlightedDocument(url: pdfURL, viewModel: viewModel)
pdfStateObject.document = document
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
// NOTE: the issue here is the PDF is downloaded, but saved to a URL we don't know about
// because it is changed.
let pdfURL = await viewModel.downloadPDF(dataService: dataService)
if let pdfURL = pdfURL {
let document = HighlightedDocument(url: pdfURL, viewModel: viewModel)
pdfStateObject.document = document
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
} else {
errorMessage = "Unable to download PDF: \(pdfURL)"
}
}
}
}

View file

@ -3,13 +3,13 @@ import CoreData
import Foundation
import Models
import Services
import Utils
public final class PDFViewerViewModel: ObservableObject {
@Published public var errorMessage: String?
@Published public var readerView: Bool = false
public let pdfItem: PDFItem
private var storedURL: URL?
var subscriptions = Set<AnyCancellable>()
@ -81,4 +81,30 @@ public final class PDFViewerViewModel: ObservableObject {
return components?.url
}
public var itemDownloaded: Bool {
if let localPdfURL = pdfItem.localPdfURL, FileManager.default.fileExists(atPath: localPdfURL.path) {
return true
}
return false
}
public func downloadPDF(dataService: DataService) async -> URL? {
do {
if itemDownloaded {
return pdfItem.localPdfURL
}
if let tempURL = pdfItem.tempPDFURL {
if let localURL = try? PDFUtils.copyToLocal(url: tempURL) {
return tempURL
}
}
if let localURL = try await dataService.fetchPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL) {
return localURL
}
} catch {
print("error downloading PDF", error)
}
return nil
}
}

View file

@ -55,6 +55,9 @@ import Views
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item), onSave: nil)
}
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
LinkedItemTitleEditView(item: item)
}
.toolbar {
ToolbarItem(placement: .barTrailing) {
Button("", action: {})
@ -230,6 +233,10 @@ import Views
viewModel: viewModel
)
.contextMenu {
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }
@ -344,6 +351,8 @@ import Views
confirmationShown = true
case .editLabels:
viewModel.itemUnderLabelEdit = item
case .editTitle:
viewModel.itemUnderTitleEdit = item
}
}

View file

@ -26,6 +26,10 @@ import Views
viewModel: viewModel
)
.contextMenu {
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }

View file

@ -14,6 +14,7 @@ import Views
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: LinkedItem?
@Published var itemUnderTitleEdit: LinkedItem?
@Published var searchTerm = ""
@Published var selectedLabels = [LinkedItemLabel]()
@Published var negatedLabels = [LinkedItemLabel]()

View file

@ -119,6 +119,7 @@ struct LinkItemDetailView: View {
static let navBarHeight = 50.0
@ObservedObject private var viewModel: LinkItemDetailViewModel
@State private var showFontSizePopover = false
@State private var showTitleEdit = false
@State private var navBarVisibilityRatio = 1.0
@State private var showDeleteConfirmation = false
@ -206,6 +207,10 @@ struct LinkItemDetailView: View {
Menu(
content: {
Group {
Button(
action: { showTitleEdit = true },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { viewModel.handleArchiveAction(dataService: dataService) },
label: {
@ -240,6 +245,11 @@ struct LinkItemDetailView: View {
}
Button("Cancel", role: .cancel, action: {})
}
.sheet(isPresented: $showTitleEdit) {
if let item = viewModel.item {
LinkedItemTitleEditView(item: item)
}
}
}
#if os(iOS)
@ -300,7 +310,7 @@ struct LinkItemDetailView: View {
@ViewBuilder private var fixedNavBarReader: some View {
if let pdfItem = viewModel.pdfItem, let pdfURL = pdfItem.pdfURL {
#if os(iOS)
PDFViewer(remoteURL: pdfURL, viewModel: PDFViewerViewModel(pdfItem: pdfItem))
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
PDFWrapperView(pdfURL: pdfURL)

View file

@ -0,0 +1,90 @@
import Models
import Services
import SwiftUI
import Views
@MainActor final class LinkedItemTitleEditViewModel: ObservableObject {
@Published var title = ""
@Published var description = ""
func load(item: LinkedItem) {
title = item.unwrappedTitle
description = item.descriptionText ?? ""
}
func submit(dataService: DataService, item: LinkedItem) {
dataService.updateLinkedItemTitleAndDescription(
itemID: item.unwrappedID,
title: title,
description: description
)
}
}
struct LinkedItemTitleEditView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = LinkedItemTitleEditViewModel()
let item: LinkedItem
var editForm: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Title")
.font(.appFootnote)
.foregroundColor(.appGrayTextContrast)
TextField("", text: $viewModel.title)
.textFieldStyle(StandardTextFieldStyle(textColor: .appGrayTextContrast))
}
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.appFootnote)
.foregroundColor(.appGrayTextContrast)
TextEditor(text: $viewModel.description)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayTextContrast)
.font(.appBody)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
)
.frame(height: 200)
}
}
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
var body: some View {
NavigationView {
editForm
.navigationTitle("Edit Title and Description")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .barTrailing) {
Button(
action: {
viewModel.submit(dataService: dataService, item: item)
presentationMode.wrappedValue.dismiss()
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
)
}
ToolbarItem(placement: .barLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
}
}
}
.task { viewModel.load(item: item) }
}
}

View file

@ -6,9 +6,8 @@ import WebKit
#if os(iOS)
struct WebReader: UIViewRepresentable {
let htmlContent: String
let highlightsJSONString: String
let item: LinkedItem
let articleContent: ArticleContent
let openLinkAction: (URL) -> Void
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
let navBarVisibilityRatioUpdater: (Double) -> Void
@ -16,7 +15,7 @@ import WebKit
@Binding var updateFontFamilyActionID: UUID?
@Binding var updateFontActionID: UUID?
@Binding var updateTextContrastActionID: UUID?
@Binding var updateMarginActionID: UUID?
@Binding var updateMaxWidthActionID: UUID?
@Binding var updateLineHeightActionID: UUID?
@Binding var annotationSaveTransactionID: UUID?
@Binding var showNavBarActionID: UUID?
@ -37,9 +36,9 @@ import WebKit
return storedSize <= 1 ? 150 : storedSize
}
func margin() -> Int {
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebMargin.rawValue)
return storedSize <= 1 ? 360 : storedSize
func maxWidthPercentage() -> Int {
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue)
return storedSize <= 1 ? 100 : storedSize
}
func makeUIView(context: Context) -> WKWebView {
@ -95,9 +94,9 @@ import WebKit
(webView as? WebView)?.updateTextContrast()
}
if updateMarginActionID != context.coordinator.previousUpdateMarginActionID {
context.coordinator.previousUpdateMarginActionID = updateMarginActionID
(webView as? WebView)?.updateMargin()
if updateMaxWidthActionID != context.coordinator.previousUpdateMaxWidthActionID {
context.coordinator.previousUpdateMaxWidthActionID = updateMaxWidthActionID
(webView as? WebView)?.updateMaxWidthPercentage()
}
if updateLineHeightActionID != context.coordinator.previousUpdateLineHeightActionID {
@ -143,13 +142,12 @@ import WebKit
webView.loadHTMLString(
WebReaderContent(
htmlContent: htmlContent,
highlightsJSONString: highlightsJSONString,
item: item,
articleContent: articleContent,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
fontSize: fontSize(),
lineHeight: lineHeight(),
margin: margin(),
maxWidthPercentage: maxWidthPercentage(),
fontFamily: fontFamily
)
.styledContent,

View file

@ -10,6 +10,7 @@ import WebKit
@State private var showPreferencesPopover = false
@State private var showLabelsModal = false
@State private var showTitleEdit = false
@State var showHighlightAnnotationModal = false
@State var safariWebLink: SafariWebLink?
@State private var navBarVisibilityRatio = 1.0
@ -18,7 +19,7 @@ import WebKit
@State var updateFontFamilyActionID: UUID?
@State var updateFontActionID: UUID?
@State var updateTextContrastActionID: UUID?
@State var updateMarginActionID: UUID?
@State var updateMaxWidthActionID: UUID?
@State var updateLineHeightActionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State var showNavBarActionID: UUID?
@ -82,6 +83,10 @@ import WebKit
Menu(
content: {
Group {
Button(
action: { showTitleEdit = true },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { showLabelsModal = true },
label: { Label("Edit Labels", systemImage: "tag") }
@ -89,6 +94,7 @@ import WebKit
Button(
action: {
dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived)
presentationMode.wrappedValue.dismiss()
Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox")
},
label: {
@ -122,21 +128,24 @@ import WebKit
Button("Remove Link", role: .destructive) {
Snackbar.show(message: "Link removed")
dataService.removeLink(objectID: item.objectID)
presentationMode.wrappedValue.dismiss()
}
Button("Cancel", role: .cancel, action: {})
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { _ in showLabelsModal = false })
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemTitleEditView(item: item)
}
}
var body: some View {
ZStack {
if let articleContent = viewModel.articleContent {
WebReader(
htmlContent: articleContent.htmlContent,
highlightsJSONString: articleContent.highlightsJSONString,
item: item,
articleContent: articleContent,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
@ -151,7 +160,7 @@ import WebKit
updateFontFamilyActionID: $updateFontFamilyActionID,
updateFontActionID: $updateFontActionID,
updateTextContrastActionID: $updateTextContrastActionID,
updateMarginActionID: $updateMarginActionID,
updateMaxWidthActionID: $updateMaxWidthActionID,
updateLineHeightActionID: $updateLineHeightActionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
showNavBarActionID: $showNavBarActionID,
@ -164,7 +173,7 @@ import WebKit
showNavBarActionID = UUID()
}
}
.sheet(item: $safariWebLink) {
.fullScreenCover(item: $safariWebLink) {
SafariView(url: $0.url)
}
.sheet(isPresented: $showHighlightAnnotationModal) {
@ -203,7 +212,7 @@ import WebKit
updateFontFamilyAction: { updateFontFamilyActionID = UUID() },
updateFontAction: { updateFontActionID = UUID() },
updateTextContrastAction: { updateTextContrastActionID = UUID() },
updateMarginAction: { updateMarginActionID = UUID() },
updateMaxWidthAction: { updateMaxWidthActionID = UUID() },
updateLineHeightAction: { updateLineHeightActionID = UUID() },
dismissAction: { showPreferencesPopover = false }
)

View file

@ -6,31 +6,28 @@ import Views
struct WebReaderContent {
let textFontSize: Int
let lineHeight: Int
let margin: Int
let htmlContent: String
let highlightsJSONString: String
let maxWidthPercentage: Int
let item: LinkedItem
let themeKey: String
let fontFamily: WebFont
let articleContent: ArticleContent
init(
htmlContent: String,
highlightsJSONString: String,
item: LinkedItem,
articleContent: ArticleContent,
isDark: Bool,
fontSize: Int,
lineHeight: Int,
margin: Int,
maxWidthPercentage: Int,
fontFamily: WebFont
) {
self.textFontSize = fontSize
self.lineHeight = lineHeight
self.margin = margin
self.htmlContent = htmlContent
self.highlightsJSONString = highlightsJSONString
self.maxWidthPercentage = maxWidthPercentage
self.item = item
self.themeKey = isDark ? "Gray" : "LightGray"
self.fontFamily = fontFamily
self.articleContent = articleContent
}
// swiftlint:disable line_length
@ -52,7 +49,7 @@ struct WebReaderContent {
<body>
<div id="root" />
<div id='_omnivore-htmlContent' style="display: none;">
\(htmlContent)
\(articleContent.htmlContent)
</div>
<script type="text/javascript">
window.omnivoreEnv = {
@ -70,19 +67,19 @@ struct WebReaderContent {
savedAt: \(savedAt),
publishedAt: \(publishedAt),
url: `\(item.unwrappedPageURLString)`,
title: `\(item.unwrappedTitle.replacingOccurrences(of: "`", with: "\\`"))`,
title: `\(articleContent.title.replacingOccurrences(of: "`", with: "\\`"))`,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "\(item.unwrappedPageURLString)",
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
labels: \(item.labelsJSONString),
highlights: \(highlightsJSONString),
highlights: \(articleContent.highlightsJSONString),
}
window.fontSize = \(textFontSize)
window.fontFamily = "\(fontFamily.rawValue)"
window.margin = \(margin)
window.maxWidthPercentage = \(maxWidthPercentage)
window.lineHeight = \(lineHeight)
window.localStorage.setItem("theme", "\(themeKey)")
</script>

View file

@ -18,7 +18,7 @@ final class WebReaderCoordinator: NSObject {
var previousUpdateFontFamilyActionID: UUID?
var previousUpdateFontActionID: UUID?
var previousUpdateTextContrastActionID: UUID?
var previousUpdateMarginActionID: UUID?
var previousUpdateMaxWidthActionID: UUID?
var previousUpdateLineHeightActionID: UUID?
var previousShowNavBarActionID: UUID?
var previousShareActionID: UUID?

View file

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

View file

@ -24,12 +24,13 @@
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="contentReader" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdId" optional="YES" attributeType="String"/>
<attribute name="descriptionText" optional="YES" attributeType="String"/>
<attribute name="htmlContent" optional="YES" attributeType="String"/>
<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="localPDF" optional="YES" attributeType="String"/>
<attribute name="onDeviceImageURLString" optional="YES" attributeType="String"/>
<attribute name="originalHtml" optional="YES" attributeType="String"/>
<attribute name="pageURLString" attributeType="String"/>
@ -42,6 +43,8 @@
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="siteName" optional="YES" attributeType="String"/>
<attribute name="slug" attributeType="String"/>
<attribute name="state" optional="YES" attributeType="String"/>
<attribute name="tempPDFURL" optional="YES" attributeType="URI"/>
<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"/>
@ -89,7 +92,7 @@
</entity>
<elements>
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="404"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="449"/>
<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"/>

View file

@ -8,15 +8,18 @@ public enum ArticleContentStatus {
}
public struct ArticleContent {
public let title: String
public let htmlContent: String
public let highlightsJSONString: String
public let contentStatus: ArticleContentStatus
public init(
title: String,
htmlContent: String,
highlightsJSONString: String,
contentStatus: ArticleContentStatus
) {
self.title = title
self.htmlContent = htmlContent
self.highlightsJSONString = highlightsJSONString
self.contentStatus = contentStatus

View file

@ -1,5 +1,6 @@
import CoreData
import Foundation
import Utils
public struct HomeFeedData { // TODO: rename this
public let items: [NSManagedObjectID]
@ -43,6 +44,15 @@ public extension LinkedItem {
readingProgress >= 0.98
}
var isReadyToRead: Bool {
if isPDF {
// If its a PDF we verify the local file is available
return PDFUtils.exists(filename: localPDF) || PDFUtils.tempExists(tempPDFURL: tempPDFURL)
}
// Check the state and whether we have HTML
return state == "SUCCEEDED"
}
var isPDF: Bool {
if let contentReader = contentReader {
return contentReader == "PDF"
@ -101,7 +111,9 @@ public extension LinkedItem {
inContext context: NSManagedObjectContext,
newReadingProgress: Double? = nil,
newAnchorIndex: Int? = nil,
newIsArchivedValue: Bool? = nil
newIsArchivedValue: Bool? = nil,
newTitle: String? = nil,
newDescription: String? = nil
) {
context.perform {
if let newReadingProgress = newReadingProgress {
@ -116,6 +128,14 @@ public extension LinkedItem {
self.isArchived = newIsArchivedValue
}
if let newTitle = newTitle {
self.title = newTitle
}
if let newDescription = newDescription {
self.descriptionText = newDescription
}
guard context.hasChanges else { return }
self.updatedAt = Date()

View file

@ -1,11 +1,13 @@
import CoreData
import Foundation
import Utils
public struct PDFItem {
public let objectID: NSManagedObjectID
public let itemID: String
public let pdfURL: URL?
public let localPdfURL: URL?
public let localPDF: String?
public let tempPDFURL: URL?
public let title: String
public let slug: String
public let readingProgress: Double
@ -22,7 +24,8 @@ public struct PDFItem {
objectID: item.objectID,
itemID: item.unwrappedID,
pdfURL: URL(string: item.unwrappedPageURLString),
localPdfURL: item.localPdfURL.flatMap { URL(string: $0) },
localPDF: item.localPDF,
tempPDFURL: item.tempPDFURL,
title: item.unwrappedID,
slug: item.unwrappedSlug,
readingProgress: item.readingProgress,
@ -33,4 +36,11 @@ public struct PDFItem {
highlights: item.highlights.asArray(of: Highlight.self)
)
}
public var localPdfURL: URL? {
if let localPDF = localPDF {
return PDFUtils.localPdfURL(filename: localPDF)
}
return nil
}
}

View file

@ -42,9 +42,9 @@ public extension LinkedItemSort {
var sortDescriptors: [NSSortDescriptor] {
switch self {
case .newest /* , .relevance */:
return [NSSortDescriptor(keyPath: \LinkedItem.createdAt, ascending: false)]
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)]
case .oldest:
return [NSSortDescriptor(keyPath: \LinkedItem.createdAt, ascending: true)]
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: true)]
// case .recentlyRead:
// return [NSSortDescriptor(keyPath: \LinkedItem.updatedAt, ascending: false)]
case .recentlyPublished:

View file

@ -53,7 +53,6 @@ public enum PageScraper {
var pageScrapePayload: PageScrapePayload?
let PDFKey = UTType.pdf.identifier
let publicFileKey = UTType.fileURL.identifier
let propertyListKey = UTType.propertyList.identifier
let group = DispatchGroup()
@ -257,7 +256,20 @@ private extension PageScrapePayload {
let localFile = UUID().uuidString.lowercased() + ".pdf"
dest.appendPathComponent(localFile)
do {
print("EXISTING PDF URL", url)
let attr = try? FileManager.default.attributesOfItem(atPath: url.path)
if let attr = attr {
print("EXISTING FILE SIZE", attr[.size])
}
try FileManager.default.copyItem(at: url, to: dest)
print("COPIED TO URL", dest)
let attr2 = try? FileManager.default.attributesOfItem(atPath: dest.path)
if let attr2 = attr2 {
print("COPIED FILE SIZE", attr2[.size])
}
return PageScrapePayload(url: url.absoluteString, localUrl: dest)
} catch {
print("error copying file locally", error)

View file

@ -121,9 +121,11 @@ public final class DataService: ObservableObject {
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
linkedItem.createdId = requestId
linkedItem.id = existingItem?.unwrappedID ?? requestId
linkedItem.title = normalizedURL
linkedItem.pageURLString = normalizedURL
linkedItem.state = existingItem != nil ? existingItem?.state : "PROCESSING"
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsCreation.rawValue)
linkedItem.savedAt = currentTime
linkedItem.createdAt = currentTime
@ -136,8 +138,8 @@ public final class DataService: ObservableObject {
linkedItem.author = nil
linkedItem.publishDate = nil
if let currentViewer = self.currentViewer {
linkedItem.slug = "\(currentViewer)/\(requestId)"
if let currentViewer = self.currentViewer, let username = currentViewer.username {
linkedItem.slug = "\(username)/\(requestId)"
} else {
// Technically this is invalid, but I don't think slug is used at all locally anymore
linkedItem.slug = requestId
@ -146,18 +148,14 @@ public final class DataService: ObservableObject {
switch pageScrape.contentType {
case let .pdf(localUrl):
linkedItem.contentReader = "PDF"
linkedItem.localPdfURL = localUrl.absoluteString
linkedItem.tempPDFURL = localUrl
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"
}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import Models
import SwiftGraphQL
public struct UploadFileRequestPayload {
public let pageId: String
public let uploadID: String?
public let uploadFileID: String?
public let urlString: String?
@ -19,7 +20,7 @@ public extension DataService {
let input = InputObjects.UploadFileRequestInput(
clientRequestId: OptionalArgument(id),
contentType: "application/pdf",
createPageEntry: OptionalArgument(false),
createPageEntry: OptionalArgument(true),
url: url
)
@ -29,6 +30,7 @@ public extension DataService {
uploadFileRequestSuccess: .init {
.success(
payload: UploadFileRequestPayload(
pageId: (try $0.createdPageId()) ?? id,
uploadID: try $0.id(),
uploadFileID: try $0.uploadFileId(),
urlString: try $0.uploadSignedUrl()
@ -83,6 +85,12 @@ public extension DataService {
request.httpMethod = "PUT"
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
print("UPLOADING PDF", localPdfURL)
let attr = try? FileManager.default.attributesOfItem(atPath: localPdfURL.path)
if let attr = attr {
print("UPLOADING ATTR", attr[.size])
}
return try await withCheckedThrowingContinuation { continuation in
let task = networker.urlSession.uploadTask(with: request, fromFile: localPdfURL) { _, response, _ in
if let httpResponse = response as? HTTPURLResponse, 200 ... 299 ~= httpResponse.statusCode {
@ -95,23 +103,7 @@ public extension DataService {
}
}
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(requestId: String, uploadFileId: String, url: String) async throws {
func saveFilePublisher(requestId: String, uploadFileId: String, url: String) async throws -> String? {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
@ -127,7 +119,13 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") }
saveSuccess: .init {
if let requestId = try? $0.clientRequestId(), let url = try? $0.url() {
return .saved(requestId: requestId, url: url)
} else {
return .error(errorCode: .unknown)
}
}
)
}
@ -148,8 +146,8 @@ public extension DataService {
}
switch payload.data {
case .saved:
continuation.resume()
case let .saved(requestId: requestId, url: _):
continuation.resume(returning: requestId)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:

View file

@ -3,7 +3,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func savePage(id: String, url: String, title: String, originalHtml: String) async throws {
func savePage(id: String, url: String, title: String, originalHtml: String) async throws -> String? {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
@ -20,7 +20,13 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") }
saveSuccess: .init {
if let requestId = try? $0.clientRequestId(), let url = try? $0.url() {
return .saved(requestId: requestId, url: url)
} else {
return .error(errorCode: .unknown)
}
}
)
}
@ -42,8 +48,8 @@ public extension DataService {
return
}
switch payload.data {
case .saved:
continuation.resume()
case let .saved(requestId: requestId, url: _):
continuation.resume(returning: requestId)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:

View file

@ -3,7 +3,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func saveURL(id: String, url: String) async throws {
func saveURL(id: String, url: String) async throws -> String? {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
@ -18,7 +18,13 @@ public extension DataService {
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) },
saveSuccess: .init { .saved(requestId: id, url: (try? $0.url()) ?? "") }
saveSuccess: .init {
if let requestId = try? $0.clientRequestId(), let url = try? $0.url() {
return .saved(requestId: requestId, url: url)
} else {
return .error(errorCode: .unknown)
}
}
)
}
@ -41,8 +47,8 @@ public extension DataService {
}
switch payload.data {
case .saved:
continuation.resume()
case let .saved(requestId: requestId, url: _):
continuation.resume(returning: requestId)
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:

View file

@ -0,0 +1,77 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
extension DataService {
public func updateLinkedItemTitleAndDescription(itemID: String, title: String, description: String) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
linkedItem.update(
inContext: self.backgroundContext,
newTitle: title,
newDescription: description
)
// Send update to server
self.syncLinkedItemTitleAndDescription(
itemID: itemID,
objectID: linkedItem.objectID,
title: title,
description: description
)
}
}
func syncLinkedItemTitleAndDescription(
itemID: String,
objectID: NSManagedObjectID,
title: String,
description: String
) {
enum MutationResult {
case saved(title: String)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.UpdatePageResult> {
try $0.on(
updatePageError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) },
updatePageSuccess: .init {
.saved(title: try $0.updatedPage(selection: Selection.Article { try $0.title() }))
}
)
}
let mutation = Selection.Mutation {
try $0.updatePage(
input: .init(description: OptionalArgument(description), pageId: itemID, title: OptionalArgument(title)),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
send(mutation, to: path, headers: headers) { result in
let data = try? result.get()
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {
try context.save()
logger.debug("LinkedItem updated succesfully")
} catch {
context.rollback()
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
}
}
}
}
}

View file

@ -1,6 +1,7 @@
import CoreData
import Foundation
import Models
import Utils
public extension DataService {
internal func syncOfflineItemsWithServerIfNeeded() async throws {
@ -35,27 +36,32 @@ public extension DataService {
}
}
private func updateLinkedItemStatus(id: String, status: ServerSyncStatus) async throws {
try backgroundContext.performAndWait {
private func updateLinkedItemStatus(id: String, newId: String?, status: ServerSyncStatus) async throws {
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 }
if let newId = newId {
linkedItem.id = newId
}
linkedItem.serverSyncStatus = Int64(status.rawValue)
}
}
func syncPdf(id: String, localPdfURL: URL, url: String) async throws {
func createPageFromPdf(id: String, localPdfURL: URL, url: String) async throws {
do {
try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing)
let uploadRequest = try await uploadFileRequest(id: id, url: url)
print("UPLOAD REQUEST, ORIGINAL ID, NEW ID", id, uploadRequest.pageId)
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)
try await uploadFile(id: uploadRequest.pageId, localPdfURL: localPdfURL, url: uploadUrl)
} else {
throw SaveArticleError.badData
}
try await updateLinkedItemStatus(id: id, status: .isNSync)
try await updateLinkedItemStatus(id: id, newId: uploadRequest.pageId, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
@ -67,13 +73,16 @@ public extension DataService {
}
}
func syncPage(id: String, originalHtml: String, title: String?, url: String) async throws {
func createPage(id: String, originalHtml: String, title: String?, url: String) async throws -> String {
do {
try await savePage(id: id, url: url, title: title ?? url, originalHtml: originalHtml)
try await updateLinkedItemStatus(id: id, status: .isNSync)
try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing)
let newId = try await savePage(id: id, url: url, title: title ?? url, originalHtml: originalHtml)
try await updateLinkedItemStatus(id: id, newId: newId, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
return newId ?? id
} catch {
backgroundContext.performAndWait {
backgroundContext.rollback()
@ -82,13 +91,16 @@ public extension DataService {
}
}
func syncUrl(id: String, url: String) async throws {
func createPageFromUrl(id: String, url: String) async throws -> String {
do {
try await updateLinkedItemStatus(id: id, status: .isSyncing)
try await saveURL(id: id, url: url)
try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing)
let newId = try await saveURL(id: id, url: url)
try await updateLinkedItemStatus(id: id, newId: newId, status: .isNSync)
try backgroundContext.performAndWait {
try backgroundContext.save()
}
return newId ?? id
} catch {
backgroundContext.performAndWait {
backgroundContext.rollback()
@ -101,12 +113,10 @@ public extension DataService {
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) {
if let localPDF = item.localPDF, let localPdfURL = PDFUtils.localPdfURL(filename: localPDF) {
Task {
try await syncPdf(id: id, localPdfURL: localPdfURL, url: url)
try await createPageFromPdf(id: id, localPdfURL: localPdfURL, url: url)
}
} else {
// TODO: This is an invalid object, we should have a way of reflecting that with an error state
@ -120,9 +130,9 @@ public extension DataService {
Task {
if let originalHtml = originalHtml {
try await syncPage(id: id, originalHtml: originalHtml, title: title, url: url)
try await createPage(id: id, originalHtml: originalHtml, title: title, url: url)
} else {
try await syncUrl(id: id, url: url)
try await createPageFromUrl(id: id, url: url)
}
}
default:
@ -186,23 +196,4 @@ public 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)
}
}
}
}

View file

@ -2,14 +2,15 @@ import CoreData
import Foundation
import Models
import SwiftGraphQL
import Utils
extension DataService {
struct PendingLink {
public extension DataService {
internal struct PendingLink {
let itemID: String
let retryCount: Int
}
public func prefetchPages(itemIDs: [String], username: String) async {
func prefetchPages(itemIDs: [String], username: String) async {
// TODO: make this concurrent
// TODO: make a non-pending page option for BG tasks
for itemID in itemIDs {
@ -17,7 +18,7 @@ extension DataService {
}
}
func prefetchPage(pendingLink: PendingLink, username: String) async {
internal func prefetchPage(pendingLink: PendingLink, username: String) async {
let content = try? await articleContent(username: username, itemID: pendingLink.itemID, useCache: false)
if content?.contentStatus == .processing, pendingLink.retryCount < 7 {
@ -40,7 +41,7 @@ extension DataService {
}
}
public func fetchArticleContent(
func fetchArticleContent(
itemID: String,
username: String? = nil,
requestCount: Int = 1
@ -69,7 +70,7 @@ extension DataService {
}
// swiftlint:disable:next function_body_length
public func articleContent(
func articleContent(
username: String,
itemID: String,
useCache: Bool
@ -101,6 +102,7 @@ extension DataService {
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
@ -142,8 +144,8 @@ extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { [weak self] queryResult in
let result: ArticleProps = try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: ContentFetchError.network)
return
@ -158,133 +160,158 @@ extension DataService {
continuation.resume(throwing: ContentFetchError.badData)
return
}
if status == .succeeded || result.item.isPDF {
do {
try self?.persistArticleContent(
item: result.item,
htmlContent: result.htmlContent,
highlights: result.highlights
)
} catch {
var message = "unknown error"
let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error")
if case let BasicError.message(messageText) = basicError {
message = messageText
}
continuation.resume(throwing: ContentFetchError.unknown(description: message))
}
}
let articleContent = ArticleContent(
htmlContent: result.htmlContent,
highlightsJSONString: result.highlights.asJSONString,
contentStatus: result.item.isPDF ? .succeeded : .make(from: result.contentStatus)
)
continuation.resume(returning: articleContent)
continuation.resume(returning: result)
case .error:
continuation.resume(throwing: ContentFetchError.badData)
}
}
}
let articleContent = ArticleContent(
title: result.item.title,
htmlContent: result.htmlContent,
highlightsJSONString: result.highlights.asJSONString,
contentStatus: result.item.isPDF ? .succeeded : .make(from: result.contentStatus)
)
if result.contentStatus == .succeeded || result.item.isPDF {
do {
try await persistArticleContent(
item: result.item,
htmlContent: result.htmlContent,
highlights: result.highlights
)
} catch {
var message = "unknown error"
let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error")
if case let BasicError.message(messageText) = basicError {
message = messageText
}
throw ContentFetchError.unknown(description: message)
}
}
return articleContent
}
func persistArticleContent(item: InternalLinkedItem, htmlContent: String, highlights: [InternalHighlight]) throws {
Task {
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", item.id)
internal func persistArticleContent(item: InternalLinkedItem, htmlContent: String, highlights: [InternalHighlight]) async throws {
var needsPDFDownload = false
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", item.id)
let highlightObjects = highlights.map {
$0.asManagedObject(context: self.backgroundContext)
}
linkedItem.addToHighlights(NSSet(array: highlightObjects))
linkedItem.htmlContent = htmlContent
linkedItem.id = item.id
linkedItem.title = item.title
linkedItem.createdAt = item.createdAt
linkedItem.savedAt = item.savedAt
linkedItem.readingProgress = item.readingProgress
linkedItem.readingProgressAnchor = Int64(item.readingProgressAnchor)
linkedItem.imageURLString = item.imageURLString
linkedItem.onDeviceImageURLString = item.onDeviceImageURLString
linkedItem.pageURLString = item.pageURLString
linkedItem.descriptionText = item.descriptionText
linkedItem.publisherURLString = item.publisherURLString
linkedItem.author = item.author
linkedItem.publishDate = item.publishDate
linkedItem.slug = item.slug
linkedItem.isArchived = item.isArchived
linkedItem.contentReader = item.contentReader
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
if linkedItem.isPDF, linkedItem.localPdfURL == nil {
do {
try self.fetchPDFData(slug: linkedItem.unwrappedSlug, pageURLString: linkedItem.unwrappedPageURLString)
} catch {
throw error
let highlightObjects = highlights.map {
$0.asManagedObject(context: self.backgroundContext)
}
linkedItem.addToHighlights(NSSet(array: highlightObjects))
linkedItem.htmlContent = htmlContent
linkedItem.id = item.id
linkedItem.state = item.state
linkedItem.title = item.title
linkedItem.createdAt = item.createdAt
linkedItem.savedAt = item.savedAt
linkedItem.readingProgress = item.readingProgress
linkedItem.readingProgressAnchor = Int64(item.readingProgressAnchor)
linkedItem.imageURLString = item.imageURLString
linkedItem.onDeviceImageURLString = item.onDeviceImageURLString
linkedItem.pageURLString = item.pageURLString
linkedItem.descriptionText = item.descriptionText
linkedItem.publisherURLString = item.publisherURLString
linkedItem.author = item.author
linkedItem.publishDate = item.publishDate
linkedItem.slug = item.slug
linkedItem.isArchived = item.isArchived
linkedItem.contentReader = item.contentReader
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
if item.isPDF {
needsPDFDownload = true
// Check if we already have the PDF item locally. Either in temporary
// space, or in the documents directory
if let localPDF = existingItem?.localPDF {
if PDFUtils.exists(filename: localPDF) {
linkedItem.localPDF = localPDF
needsPDFDownload = false
}
}
do {
try self.backgroundContext.save()
logger.debug("ArticleContent saved succesfully")
} catch {
self.backgroundContext.rollback()
logger.debug("Failed to save ArticleContent")
throw error
if let tempPDFURL = existingItem?.tempPDFURL {
linkedItem.localPDF = try? PDFUtils.moveToLocal(url: tempPDFURL)
PDFUtils.exists(filename: linkedItem.localPDF)
if linkedItem.localPDF != nil {
needsPDFDownload = false
}
}
}
}
if item.isPDF, needsPDFDownload {
try await fetchPDFData(slug: item.slug, pageURLString: item.pageURLString)
}
try await backgroundContext.perform { [weak self] in
do {
try self?.backgroundContext.save()
logger.debug("ArticleContent saved succesfully")
} catch {
self?.backgroundContext.rollback()
logger.debug("Failed to save ArticleContent")
throw error
}
}
}
func fetchPDFData(slug: String, pageURLString: String) throws {
Task {
guard let url = URL(string: pageURLString) else { return }
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "pdfFetch failed. no data received.")
func fetchPDFData(slug: String, pageURLString: String) async throws -> URL? {
guard let url = URL(string: pageURLString) else {
throw BasicError.message(messageText: "No PDF URL found")
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "pdfFetch failed. no data received.")
}
var localPdfURL: URL?
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(UUID().uuidString + ".pdf")
try await backgroundContext.perform { [weak self] in
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
guard let linkedItem = linkedItem else {
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
throw BasicError.message(messageText: errorMessage)
}
try await backgroundContext.perform { [weak self] in
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
guard let linkedItem = linkedItem else {
let errorMessage = "pdfFetch failed. could not find LinkedItem from fetch request"
throw BasicError.message(messageText: errorMessage)
}
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 {
self?.backgroundContext.rollback()
logger.debug("PDF data saved succesfully")
let errorMessage = "pdfFetch failed. core data save failed."
throw BasicError.message(messageText: errorMessage)
}
do {
try data.write(to: tempPath)
let localPDF = try PDFUtils.moveToLocal(url: tempPath)
localPdfURL = PDFUtils.localPdfURL(filename: localPDF)
linkedItem.tempPDFURL = nil
linkedItem.localPDF = localPDF
try self?.backgroundContext.save()
} catch {
self?.backgroundContext.rollback()
let errorMessage = "pdfFetch failed. core data save failed."
throw BasicError.message(messageText: errorMessage)
}
}
return localPdfURL
}
func cachedArticleContent(itemID: String) async -> ArticleContent? {
internal func cachedArticleContent(itemID: String) async -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
@ -302,6 +329,7 @@ extension DataService {
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
return ArticleContent(
title: linkedItem.unwrappedTitle,
htmlContent: htmlContent,
highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString,
contentStatus: .succeeded
@ -309,7 +337,7 @@ extension DataService {
}
}
public func syncUnsyncedArticleContent(itemID: String) async {
func syncUnsyncedArticleContent(itemID: String) async {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
@ -334,7 +362,7 @@ extension DataService {
if let id = id, let url = url, let title = title,
let serverSyncStatus = serverSyncStatus,
serverSyncStatus != ServerSyncStatus.isNSync.rawValue
serverSyncStatus == ServerSyncStatus.needsCreation.rawValue
{
do {
if let originalHtml = originalHtml {
@ -342,12 +370,10 @@ extension DataService {
} 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.
print("Error syncUnsyncedArticleContent")
}
}
}

View file

@ -98,6 +98,7 @@ public extension DataService {
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
@ -164,6 +165,7 @@ private let libraryArticleSelection = Selection.Article {
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
updatedAt: try $0.updatedAt().value ?? Date(),
state: try $0.state()?.rawValue ?? "SUCCEEDED",
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),

View file

@ -8,6 +8,7 @@ struct InternalLinkedItem {
let createdAt: Date
let savedAt: Date
let updatedAt: Date
let state: String
var readingProgress: Double
var readingProgressAnchor: Int
let imageURLString: String?
@ -41,6 +42,7 @@ struct InternalLinkedItem {
linkedItem.createdAt = createdAt
linkedItem.savedAt = savedAt
linkedItem.updatedAt = updatedAt
linkedItem.state = state
linkedItem.readingProgress = readingProgress
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
linkedItem.imageURLString = imageURLString
@ -98,6 +100,7 @@ extension JSONArticle {
createdAt: createdAt,
savedAt: savedAt,
updatedAt: updatedAt,
state: "SUCCEEDED",
readingProgress: readingProgressPercent,
readingProgressAnchor: readingProgressAnchorIndex,
imageURLString: image,

View file

@ -26,9 +26,13 @@ public func normalizeURL(_ dirtyURL: String) -> String {
}
urlObject.queryItems = urlObject.queryItems?.filter { item in
item.name.starts(with: "utm_")
!item.name.starts(with: "utm_")
}
urlObject.queryItems = urlObject.queryItems?.sorted(by: { first, second in
first.name <= second.name
})
if /* options.removeTrailingSlash */ true {
urlObject.path = urlObject.path.replacingRegex(pattern: "/$", replaceWith: "")
}

View file

@ -11,6 +11,49 @@ import QuickLookThumbnailing
import UIKit
public enum PDFUtils {
public static func copyToLocal(url: URL) throws -> String {
let subPath = UUID().uuidString + ".pdf"
let dest = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(subPath)
try FileManager.default.copyItem(at: url, to: dest)
return subPath
}
public static func moveToLocal(url: URL) throws -> String {
let subPath = UUID().uuidString + ".pdf"
let dest = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(subPath)
try FileManager.default.moveItem(at: url, to: dest)
return subPath
}
public static func localPdfURL(filename: String) -> URL? {
let url = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(filename)
return url
}
public static func exists(filename: String?) -> Bool {
if let filename = filename, let localPdfURL = localPdfURL(filename: filename) {
let result = FileManager.default.fileExists(atPath: localPdfURL.path)
return result
}
return false
}
public static func tempExists(tempPDFURL: URL?) -> Bool {
if let tempPDFURL = tempPDFURL {
return FileManager.default.fileExists(atPath: tempPDFURL.path)
}
return false
}
public static func titleFromPdfFile(_ urlStr: String) -> String {
let url = URL(string: urlStr)
if let url = url {

View file

@ -4,7 +4,7 @@ public enum UserDefaultKey: String {
case preferredWebFont
case preferredWebFontSize
case preferredWebLineSpacing
case preferredWebMargin
case preferredWebMaxWidthPercentage
case prefersHighContrastWebFont
case userHasDeniedPushPrimer
case firebasePushToken

View file

@ -39,9 +39,9 @@ public final class WebView: WKWebView {
}
}
public func updateMargin() {
if let margin = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebMargin.rawValue) as? Int {
dispatchEvent(.updateMargin(width: margin))
public func updateMaxWidthPercentage() {
if let maxWidthPercentage = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) as? Int {
dispatchEvent(.updateMaxWidthPercentage(maxWidthPercentage: maxWidthPercentage))
}
}
@ -240,7 +240,7 @@ public final class WebView: WKWebView {
public enum WebViewDispatchEvent {
case handleFontContrastChange(isHighContrast: Bool)
case updateLineHeight(height: Int)
case updateMargin(width: Int)
case updateMaxWidthPercentage(maxWidthPercentage: Int)
case updateFontSize(size: Int)
case updateColorMode(isDark: Bool)
case updateFontFamily(family: String)
@ -262,8 +262,8 @@ public enum WebViewDispatchEvent {
return "handleFontContrastChange"
case .updateLineHeight:
return "updateLineHeight"
case .updateMargin:
return "updateMargin"
case .updateMaxWidthPercentage:
return "updateMaxWidthPercentage"
case .updateFontSize:
return "updateFontSize"
case .updateColorMode:
@ -293,8 +293,8 @@ public enum WebViewDispatchEvent {
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 .updateMaxWidthPercentage(maxWidthPercentage: maxWidthPercentage):
return "event.maxWidthPercentage = '\(maxWidthPercentage)';"
case let .updateFontSize(size: size):
return "event.fontSize = '\(size)';"
case let .updateColorMode(isDark: isDark):

View file

@ -6,6 +6,7 @@ public enum GridCardAction {
case toggleArchiveStatus
case delete
case editLabels
case editTitle
}
public struct GridCard: View {
@ -43,6 +44,10 @@ public struct GridCard: View {
var contextMenuView: some View {
Group {
Button(
action: { menuActionHandler(.editTitle) },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { menuActionHandler(.editLabels) },
label: { Label("Edit Labels", systemImage: "tag") }
@ -161,6 +166,10 @@ public struct GridCard: View {
}
.onTapGesture { tapHandler() }
}
if let status = item.serverSyncStatus, status != ServerSyncStatus.isNSync.rawValue {
SyncStatusIcon(status: ServerSyncStatus(rawValue: Int(status)) ?? ServerSyncStatus.isNSync)
}
}
.padding(.horizontal, 0)
.padding(.top, 0)

View file

@ -66,7 +66,7 @@ public struct FeedCard: View {
}
}
if item.sortedLabels.count > 0 {
if item.hasLabels {
// Category Labels
ScrollView(.horizontal, showsIndicators: false) {
HStack {

View file

@ -3,14 +3,17 @@ import Utils
public enum WebFont: String, CaseIterable {
case inter = "Inter"
case merriweather = "Merriweather"
case lyon = "Lyon"
case tisa = "Tisa"
case system = "unset"
case merriweather = "Merriweather"
case lora = "Lora"
case opensans = "Open Sans"
case roboto = "Roboto"
case crimsontext = "Crimson Text"
case sourceserifpro = "Source Serif Pro"
var displayValue: String {
switch self {
case .inter, .merriweather, .lyon, .tisa:
case .inter, .merriweather, .lora, .opensans, .roboto, .crimsontext, .sourceserifpro:
return rawValue
case .system:
return "System Default"
@ -22,7 +25,7 @@ public struct WebPreferencesPopoverView: View {
let updateFontFamilyAction: () -> Void
let updateFontAction: () -> Void
let updateTextContrastAction: () -> Void
let updateMarginAction: () -> Void
let updateMaxWidthAction: () -> Void
let updateLineHeightAction: () -> Void
let dismissAction: () -> Void
@ -34,7 +37,7 @@ public struct WebPreferencesPopoverView: View {
#endif
@AppStorage(UserDefaultKey.preferredWebLineSpacing.rawValue) var storedLineSpacing = 150
@AppStorage(UserDefaultKey.preferredWebMargin.rawValue) var storedMargin = 360
@AppStorage(UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) var storedMaxWidthPercentage = 100
@AppStorage(UserDefaultKey.preferredWebFont.rawValue) var preferredFont = WebFont.inter.rawValue
@AppStorage(UserDefaultKey.prefersHighContrastWebFont.rawValue) var prefersHighContrastText = false
@ -42,14 +45,14 @@ public struct WebPreferencesPopoverView: View {
updateFontFamilyAction: @escaping () -> Void,
updateFontAction: @escaping () -> Void,
updateTextContrastAction: @escaping () -> Void,
updateMarginAction: @escaping () -> Void,
updateMaxWidthAction: @escaping () -> Void,
updateLineHeightAction: @escaping () -> Void,
dismissAction: @escaping () -> Void
) {
self.updateFontFamilyAction = updateFontFamilyAction
self.updateFontAction = updateFontAction
self.updateTextContrastAction = updateTextContrastAction
self.updateMarginAction = updateMarginAction
self.updateMaxWidthAction = updateMaxWidthAction
self.updateLineHeightAction = updateLineHeightAction
self.dismissAction = dismissAction
}
@ -96,19 +99,17 @@ public struct WebPreferencesPopoverView: View {
}
)
if UIDevice.isIPad {
LabelledStepper(
labelText: "Margin:",
onIncrement: {
storedMargin = min(storedMargin + 45, 560)
updateMarginAction()
},
onDecrement: {
storedMargin = max(storedMargin - 45, 200)
updateMarginAction()
}
)
}
LabelledStepper(
labelText: "Margin:",
onIncrement: {
storedMaxWidthPercentage = max(storedMaxWidthPercentage - 10, 40)
updateMaxWidthAction()
},
onDecrement: {
storedMaxWidthPercentage = min(storedMaxWidthPercentage + 10, 100)
updateMaxWidthAction()
}
)
LabelledStepper(
labelText: "Line Spacing:",
@ -145,7 +146,16 @@ public struct WebPreferencesPopoverView: View {
.padding()
.navigationTitle("Reader Preferences")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: dismissAction,
label: { Text("Done").foregroundColor(.appGrayTextContrast).padding() }
)
}
}
}
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
}
}

File diff suppressed because one or more lines are too long

View file

@ -7,8 +7,11 @@ public class ShareExtensionChildViewModel: ObservableObject {
@Published public var title: String?
@Published public var url: String?
@Published public var iconURL: String?
@Published public var requestId: String
public init() {}
public init() {
self.requestId = UUID().uuidString.lowercased()
}
}
public enum ShareExtensionStatus {
@ -126,7 +129,7 @@ struct CheckmarkButtonView: View {
public struct ShareExtensionChildView: View {
let viewModel: ShareExtensionChildViewModel
let onAppearAction: () -> Void
let readNowButtonAction: () -> Void
let readNowButtonAction: (String) -> Void
let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void
@State var reminderTime: ReminderTime?
@ -135,7 +138,7 @@ public struct ShareExtensionChildView: View {
public init(
viewModel: ShareExtensionChildViewModel,
onAppearAction: @escaping () -> Void,
readNowButtonAction: @escaping () -> Void,
readNowButtonAction: @escaping (String) -> Void,
dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void
) {
self.viewModel = viewModel
@ -178,8 +181,10 @@ public struct ShareExtensionChildView: View {
private var cloudIconColor: Color {
switch viewModel.status {
case .saved, .processing:
case .saved:
return .appGrayText
case .processing:
return .clear
case .failed(error: _), .syncFailed(error: _):
return .red
case .synced:
@ -187,13 +192,9 @@ public struct ShareExtensionChildView: View {
}
}
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
private func localImage(from url: URL) -> Image? {
if let data = try? Data(contentsOf: url), let img = UIImage(data: data) {
return Image(uiImage: img)
}
return nil
}
@ -283,13 +284,12 @@ public struct ShareExtensionChildView: View {
Spacer()
HStack {
if FeatureFlag.enableReadNow {
Button(
action: { readNowButtonAction() },
label: { Text("Read Now").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
}
Button(
action: { readNowButtonAction(self.viewModel.requestId) },
label: { Text("Read Now").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
Button(
action: {
dismissButtonTappedAction(reminderTime, hideUntilReminded)

View file

@ -0,0 +1,50 @@
//
// File.swift
//
//
// Created by Jackson Harper on 6/5/22.
//
import Foundation
import Models
import SwiftUI
import Utils
public struct SyncStatusIcon: View {
let status: ServerSyncStatus
init(status: ServerSyncStatus) {
self.status = status
}
private var cloudIconName: String {
switch status {
// case .isNSync:
// return "checkmark.icloud"
case .isNSync:
return "exclamationmark.icloud"
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate:
return "icloud"
}
}
private var cloudIconColor: Color {
switch status {
// case .isNSync:
// return .blue
case .isNSync:
return .red
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate:
return .appGrayText
}
}
public var body: some View {
Image(systemName: cloudIconName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 12, height: 12, alignment: .trailing)
.foregroundColor(cloudIconColor)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8))
}
}

View file

@ -1,13 +1,18 @@
import SwiftUI
public struct StandardTextFieldStyle: TextFieldStyle {
public init() {}
let textColor: Color?
public init(textColor: Color = .appGrayText) {
self.textColor = textColor
}
// swiftlint:disable:next identifier_name
public func _body(configuration: TextField<_Label>) -> some View {
configuration
.textFieldStyle(PlainTextFieldStyle())
.multilineTextAlignment(.leading)
.foregroundColor(.appGrayText)
.foregroundColor(textColor)
.font(.appBody)
.padding(.vertical, 12)
.padding(.horizontal, 16)
@ -15,9 +20,9 @@ public struct StandardTextFieldStyle: TextFieldStyle {
}
public var border: some View {
RoundedRectangle(cornerRadius: 16)
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.systemBackground))
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
}
}

View file

@ -9,7 +9,20 @@ final class UtilsTests: XCTestCase {
XCTAssertEqual("Hello", "Hello")
}
func testNormalizeUrl() {
// trailing slash removed
XCTAssertEqual(normalizeURL("https://omnivore.app/"), "https://omnivore.app")
// utm_ removed
XCTAssertEqual(normalizeURL("https://omnivore.app/?aa=a&bb=b&utm_track=track&cc=c"), "https://omnivore.app?aa=a&bb=b&cc=c")
// query params sorted
XCTAssertEqual(normalizeURL("https://omnivore.app/?aa=a&cc=c&bb=b"), "https://omnivore.app?aa=a&bb=b&cc=c")
XCTAssertEqual(normalizeURL("https://omnivore.app/?cc=c&bb=b&aa=a"), "https://omnivore.app?aa=a&bb=b&cc=c")
}
static var allTests = [
("testExample", testExample)
("testExample", testExample),
("testNormalizeUrl", testNormalizeUrl)
]
}

View file

@ -1 +1 @@
"use strict";!function(){const e={};function t(t){const n=t.tagName.toLowerCase();if("iframe"===n){const n=e[t.src];if(!n)return;const o=document.createElement("div");o.className="omnivore-instagram-embed",o.innerHTML=n;const r=t.parentNode;if(!r)return;return void r.replaceChild(o,t)}if("img"===n||"image"===n){if(-1===window.getComputedStyle(t).getPropertyValue("filter").indexOf("blur("))return;return void t.remove()}const o=window.getComputedStyle(t),r=o.getPropertyValue("background-image");if(r&&"none"!==r)return;const i=o.getPropertyValue("filter");if(i&&-1!==i.indexOf("blur("))return void t.remove();if(t.src)return;if(t.innerHTML.length>24)return;const a=/url\("(.+?)"\)/gi,c=a.exec(r);a.lastIndex=0;const l=c&&c[1];if(!l)return;const s=document.createElement("img");s.src=l;const p=t.parentNode;p&&p.replaceChild(s,t)}function n(){const e=document.createElement("div");e.style.position="absolute",e.style.left="-2000px",e.style.zIndex="-2000",e.innerHTML=document.body.innerHTML,document.documentElement.appendChild(e),Array.from(e.getElementsByTagName("*")).forEach(t);const n=`<head>${document.head.innerHTML}</head><body>${e.innerHTML}</body>`;return e.remove(),n}function o(){const e=document.querySelectorAll(".webext-omnivore-backdrop");for(let t=0;t<e.length;t++)e[t].style.setProperty("opacity","0","important");setTimeout((()=>{for(let t=0;t<e.length;t++)e[t].remove()}),500)}browserApi.runtime.onMessage.addListener((({action:t,payload:n},o,r)=>{if(t!==ACTIONS.AddIframeContent)return;const{url:i,content:a}=n;e[i]=a,r({})})),window.prepareContent=async function(){return await async function(){const e=".pdf"===window.location.pathname.slice(-4).toLowerCase(),t=-1!==["application/acrobat","application/pdf","application/x-pdf","applications/vnd.pdf","text/pdf","text/x-pdf"].indexOf(document.contentType);if(!e&&!t)return Promise.resolve(null);const n=document.querySelector("embed");return n&&"application/pdf"!==n.type?Promise.resolve(null):ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS&&n.src?Promise.resolve({type:"url",uploadContentObjUrl:n.src}):new Promise(((e,t)=>{const n=new XMLHttpRequest;n.open("GET","",!0),n.responseType="blob",n.onload=function(n){200===this.status?e({type:"pdf",uploadContentObjUrl:URL.createObjectURL(this.response)}):t(n)},n.send()}))}()||(await async function(){const e=document.scrollingElement||document.body,t=e.scrollTop,n=e.scrollHeight;o();const r=function(){const e=document.createElement("div");return e.className="webext-omnivore-backdrop",e.style.cssText="all: initial !important;\n position: fixed !important;\n top: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n left: 0 !important;\n z-index: 99999 !important;\n background: #fff !important;\n opacity: 0.8 !important;\n transition: opacity 0.3s !important;\n -webkit-backdrop-filter: blur(4px) !important;\n backdrop-filter: blur(4px) !important;\n ",e}();for(document.body.appendChild(r);e.scrollTop<=n-500;){const t=e.scrollTop;if(e.scrollTop+=500,await new Promise((e=>{setTimeout(e,10)})),e.scrollTop===t)break}e.scrollTop=t,await new Promise((e=>{setTimeout(e,10)}))}(),o(),{type:"html",content:n()})}}();
"use strict";!function(){const e={};function t(t){const n=t.tagName.toLowerCase();if("iframe"===n){const n=e[t.src];if(!n)return;const o=document.createElement("div");o.className="omnivore-instagram-embed",o.innerHTML=n;const r=t.parentNode;if(!r)return;return void r.replaceChild(o,t)}if("img"===n||"image"===n){if(-1===window.getComputedStyle(t).getPropertyValue("filter").indexOf("blur("))return;return void t.remove()}const o=window.getComputedStyle(t),r=o.getPropertyValue("background-image");if(r&&"none"!==r)return;const i=o.getPropertyValue("filter");if(i&&-1!==i.indexOf("blur("))return void t.remove();if(t.src)return;if(t.innerHTML.length>24)return;const a=/url\("(.+?)"\)/gi,c=a.exec(r);a.lastIndex=0;const l=c&&c[1];if(!l)return;const s=document.createElement("img");s.src=l;const p=t.parentNode;p&&p.replaceChild(s,t)}function n(){const e=document.createElement("div");e.style.position="absolute",e.style.left="-2000px",e.style.zIndex="-2000",e.innerHTML=document.body.innerHTML,document.documentElement.appendChild(e),Array.from(e.getElementsByTagName("*")).forEach(t);const n=`<html><head>${document.head.innerHTML}</head><body>${e.innerHTML}</body></html>`;return e.remove(),n}function o(){const e=document.querySelectorAll(".webext-omnivore-backdrop");for(let t=0;t<e.length;t++)e[t].style.setProperty("opacity","0","important");setTimeout((()=>{for(let t=0;t<e.length;t++)e[t].remove()}),500)}browserApi.runtime.onMessage.addListener((({action:t,payload:n},o,r)=>{if(t!==ACTIONS.AddIframeContent)return;const{url:i,content:a}=n;e[i]=a,r({})})),window.prepareContent=async function(){return await async function(){const e=".pdf"===window.location.pathname.slice(-4).toLowerCase(),t=-1!==["application/acrobat","application/pdf","application/x-pdf","applications/vnd.pdf","text/pdf","text/x-pdf"].indexOf(document.contentType);if(!e&&!t)return Promise.resolve(null);const n=document.querySelector("embed");return n&&"application/pdf"!==n.type?Promise.resolve(null):ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS&&n.src?Promise.resolve({type:"url",uploadContentObjUrl:n.src}):new Promise(((e,t)=>{const n=new XMLHttpRequest;n.open("GET","",!0),n.responseType="blob",n.onload=function(n){200===this.status?e({type:"pdf",uploadContentObjUrl:URL.createObjectURL(this.response)}):t(n)},n.send()}))}()||(await async function(){const e=document.scrollingElement||document.body,t=e.scrollTop,n=e.scrollHeight;o();const r=function(){const e=document.createElement("div");return e.className="webext-omnivore-backdrop",e.style.cssText="all: initial !important;\n position: fixed !important;\n top: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n left: 0 !important;\n z-index: 99999 !important;\n background: #fff !important;\n opacity: 0.8 !important;\n transition: opacity 0.3s !important;\n -webkit-backdrop-filter: blur(4px) !important;\n backdrop-filter: blur(4px) !important;\n ",e}();for(document.body.appendChild(r);e.scrollTop<=n-500;){const t=e.scrollTop;if(e.scrollTop+=500,await new Promise((e=>{setTimeout(e,10)})),e.scrollTop===t)break}e.scrollTop=t,await new Promise((e=>{setTimeout(e,10)}))}(),o(),{type:"html",content:n()})}}();

View file

@ -25,9 +25,9 @@
"@opentelemetry/instrumentation-dns": "^0.27.1",
"@opentelemetry/instrumentation-express": "^0.28.0",
"@opentelemetry/instrumentation-graphql": "^0.24.0",
"@opentelemetry/instrumentation-grpc": "^0.27.0",
"@opentelemetry/instrumentation-grpc": "^0.29.2",
"@opentelemetry/instrumentation-http": "^0.27.0",
"@opentelemetry/instrumentation-pg": "^0.24.0",
"@opentelemetry/instrumentation-pg": "^0.29.0",
"@opentelemetry/node": "^0.24.0",
"@opentelemetry/resources": "^0.24.0",
"@opentelemetry/semantic-conventions": "^1.0.1",

View file

@ -46,7 +46,7 @@ export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<UploadFileData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = ['status'] as const
export const updateKeys = ['url', 'status'] as const
export type UpdateSet = PickTuple<UploadFileData, typeof updateKeys>

View file

@ -31,3 +31,15 @@ export const initElasticsearch = async (): Promise<void> => {
throw e
}
}
export const refreshIndex = async (): Promise<void> => {
try {
const { body } = await client.indices.refresh({
index: INDEX_ALIAS,
})
console.log('elastic refresh: ', body)
} catch (e) {
console.error('failed to refresh elastic index', e)
throw e
}
}

View file

@ -94,18 +94,22 @@ export const updateLabelsInPage = async (
}
}
export const deleteLabelInPages = async (
userId: string,
export const deleteLabel = async (
label: string,
ctx: PageContext
): Promise<boolean> => {
try {
console.log('deleting label', label)
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source:
'ctx._source.labels.removeIf(label -> label.name == params.label)',
source: `if (ctx._source.highlights != null) {
ctx._source.highlights[0].labels.removeIf(label -> label.name == params.label)
}
if (ctx._source.labels != null) {
ctx._source.labels.removeIf(label -> label.name == params.label)
}`,
lang: 'painless',
params: {
label: label,
@ -113,12 +117,12 @@ export const deleteLabelInPages = async (
},
query: {
bool: {
filter: [
{
term: {
userId,
},
must: {
term: {
userId: ctx.uid,
},
},
should: [
{
nested: {
path: 'labels',
@ -129,11 +133,28 @@ export const deleteLabelInPages = async (
},
},
},
{
nested: {
path: 'highlights',
query: {
nested: {
path: 'highlights.labels',
query: {
term: {
'highlights.labels.name': label,
},
},
},
},
},
},
],
minimum_should_match: 1,
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed', // ignore conflicts
})
body.updated > 0 &&
@ -146,7 +167,7 @@ export const deleteLabelInPages = async (
}
}
export const updateLabelInPage = async (
export const updateLabel = async (
label: Label,
ctx: PageContext
): Promise<boolean> => {
@ -155,8 +176,14 @@ export const updateLabelInPage = async (
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.labels.removeIf(l -> l.id == params.label.id);
ctx._source.labels.add(params.label)`,
source: `if (ctx._source.labels != null) {
ctx._source.labels.removeIf(l -> l.id == params.label.id);
ctx._source.labels.add(params.label)
}
if (ctx._source.highlights != null) {
ctx._source.highlights[0].labels.removeIf(l -> l.id == params.label.id);
ctx._source.highlights[0].labels.add(params.label)
}`,
lang: 'painless',
params: {
label: label,
@ -164,12 +191,12 @@ export const updateLabelInPage = async (
},
query: {
bool: {
filter: [
{
term: {
userId: ctx.uid,
},
must: {
term: {
userId: ctx.uid,
},
},
should: [
{
nested: {
path: 'labels',
@ -180,7 +207,23 @@ export const updateLabelInPage = async (
},
},
},
{
nested: {
path: 'highlights',
query: {
nested: {
path: 'highlights.labels',
query: {
term: {
'highlights.labels.id': label.id,
},
},
},
},
},
},
],
minimum_should_match: 1,
},
},
},
@ -198,3 +241,60 @@ export const updateLabelInPage = async (
return false
}
}
export const setLabelsForHighlight = async (
highlightId: string,
labels: Label[],
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.highlights[0].labels = params.labels`,
lang: 'painless',
params: {
labels: labels,
},
},
query: {
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': highlightId,
},
},
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed', // ignore conflicts
})
if (!body.updated) {
return false
}
for (const label of labels) {
await ctx.pubsub.entityCreated<Label & { highlightId: string }>(
EntityType.LABEL,
{ highlightId, ...label },
ctx.uid
)
}
return true
} catch (e) {
if (
e instanceof ResponseError &&
e.message === 'document_missing_exception'
) {
console.log('highlight has been deleted', highlightId)
return false
}
console.error('failed to set labels for highlight in elastic', e)
return false
}
}

View file

@ -188,7 +188,7 @@ export const createPage = async (
return body._id as string
} catch (e) {
console.error('failed to create a page in elastic', e)
console.error('failed to create a page in elastic', JSON.stringify(e))
return undefined
}
}

View file

@ -163,6 +163,7 @@ export interface Highlight {
annotation?: string | null
sharedAt?: Date | null
updatedAt?: Date | null
labels?: Label[]
}
export interface Page {
@ -223,6 +224,7 @@ export interface SearchItem {
userId: string
state?: ArticleSavingRequestStatus
language?: string
readAt?: Date
}
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const

View file

@ -107,12 +107,13 @@ export type Article = {
pageType?: Maybe<PageType>;
postedByViewer?: Maybe<Scalars['Boolean']>;
publishedAt?: Maybe<Scalars['Date']>;
readAt?: Maybe<Scalars['Date']>;
readingProgressAnchorIndex: Scalars['Int'];
readingProgressPercent: Scalars['Float'];
savedAt: Scalars['Date'];
savedByViewer?: Maybe<Scalars['Boolean']>;
sharedComment?: Maybe<Scalars['String']>;
shareInfo?: Maybe<LinkShareInfo>;
sharedComment?: Maybe<Scalars['String']>;
siteIcon?: Maybe<Scalars['String']>;
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
@ -192,6 +193,11 @@ export type ArticleSavingRequestSuccess = {
articleSavingRequest: ArticleSavingRequest;
};
export type ArticleSuccess = {
__typename?: 'ArticleSuccess';
article: Article;
};
export type ArticlesError = {
__typename?: 'ArticlesError';
errorCodes: Array<ArticlesErrorCode>;
@ -209,11 +215,6 @@ export type ArticlesSuccess = {
pageInfo: PageInfo;
};
export type ArticleSuccess = {
__typename?: 'ArticleSuccess';
article: Article;
};
export enum ContentReader {
Pdf = 'PDF',
Web = 'WEB'
@ -776,6 +777,22 @@ export type LinkShareInfo = {
title: Scalars['String'];
};
export type LogOutError = {
__typename?: 'LogOutError';
errorCodes: Array<LogOutErrorCode>;
};
export enum LogOutErrorCode {
LogOutFailed = 'LOG_OUT_FAILED'
}
export type LogOutResult = LogOutError | LogOutSuccess;
export type LogOutSuccess = {
__typename?: 'LogOutSuccess';
message?: Maybe<Scalars['String']>;
};
export type LoginError = {
__typename?: 'LoginError';
errorCodes: Array<LoginErrorCode>;
@ -802,22 +819,6 @@ export type LoginSuccess = {
me: User;
};
export type LogOutError = {
__typename?: 'LogOutError';
errorCodes: Array<LogOutErrorCode>;
};
export enum LogOutErrorCode {
LogOutFailed = 'LOG_OUT_FAILED'
}
export type LogOutResult = LogOutError | LogOutSuccess;
export type LogOutSuccess = {
__typename?: 'LogOutSuccess';
message?: Maybe<Scalars['String']>;
};
export type MergeHighlightError = {
__typename?: 'MergeHighlightError';
errorCodes: Array<MergeHighlightErrorCode>;
@ -872,8 +873,8 @@ export type Mutation = {
generateApiKey: GenerateApiKeyResult;
googleLogin: LoginResult;
googleSignup: GoogleSignupResult;
login: LoginResult;
logOut: LogOutResult;
login: LoginResult;
mergeHighlight: MergeHighlightResult;
reportItem: ReportItemResult;
revokeApiKey: RevokeApiKeyResult;
@ -885,6 +886,7 @@ export type Mutation = {
setDeviceToken: SetDeviceTokenResult;
setFollow: SetFollowResult;
setLabels: SetLabelsResult;
setLabelsForHighlight: SetLabelsResult;
setLinkArchived: ArchiveLinkResult;
setShareArticle: SetShareArticleResult;
setShareHighlight: SetShareHighlightResult;
@ -1056,6 +1058,11 @@ export type MutationSetLabelsArgs = {
};
export type MutationSetLabelsForHighlightArgs = {
input: SetLabelsForHighlightInput;
};
export type MutationSetLinkArchivedArgs = {
input: ArchiveLinkInput;
};
@ -1171,9 +1178,9 @@ export type NewsletterEmailsSuccess = {
export type Page = {
__typename?: 'Page';
author: Scalars['String'];
author?: Maybe<Scalars['String']>;
createdAt: Scalars['Date'];
description: Scalars['String'];
description?: Maybe<Scalars['String']>;
hash: Scalars['String'];
id: Scalars['ID'];
image: Scalars['String'];
@ -1234,8 +1241,8 @@ export type Query = {
__typename?: 'Query';
apiKeys: ApiKeysResult;
article: ArticleResult;
articles: ArticlesResult;
articleSavingRequest: ArticleSavingRequestResult;
articles: ArticlesResult;
feedArticles: FeedArticlesResult;
getFollowers: GetFollowersResult;
getFollowing: GetFollowingResult;
@ -1263,6 +1270,11 @@ export type QueryArticleArgs = {
};
export type QueryArticleSavingRequestArgs = {
id: Scalars['ID'];
};
export type QueryArticlesArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
@ -1273,11 +1285,6 @@ export type QueryArticlesArgs = {
};
export type QueryArticleSavingRequestArgs = {
id: Scalars['ID'];
};
export type QueryFeedArticlesArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
@ -1643,6 +1650,11 @@ export enum SetLabelsErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type SetLabelsForHighlightInput = {
highlightId: Scalars['ID'];
labelIds: Array<Scalars['ID']>;
};
export type SetLabelsInput = {
labelIds: Array<Scalars['ID']>;
pageId: Scalars['ID'];
@ -1757,6 +1769,13 @@ export type SetWebhookSuccess = {
webhook: Webhook;
};
export type ShareStats = {
__typename?: 'ShareStats';
readDuration: Scalars['Int'];
saveCount: Scalars['Int'];
viewCount: Scalars['Int'];
};
export type SharedArticleError = {
__typename?: 'SharedArticleError';
errorCodes: Array<SharedArticleErrorCode>;
@ -1773,13 +1792,6 @@ export type SharedArticleSuccess = {
article: Article;
};
export type ShareStats = {
__typename?: 'ShareStats';
readDuration: Scalars['Int'];
saveCount: Scalars['Int'];
viewCount: Scalars['Int'];
};
export type SignupError = {
__typename?: 'SignupError';
errorCodes: Array<Maybe<SignupErrorCode>>;
@ -1861,6 +1873,12 @@ export type Subscription = {
url?: Maybe<Scalars['String']>;
};
export enum SubscriptionStatus {
Active = 'ACTIVE',
Deleted = 'DELETED',
Unsubscribed = 'UNSUBSCRIBED'
}
export type SubscriptionsError = {
__typename?: 'SubscriptionsError';
errorCodes: Array<SubscriptionsErrorCode>;
@ -1878,12 +1896,6 @@ export type SubscriptionsSuccess = {
subscriptions: Array<Subscription>;
};
export enum SubscriptionStatus {
Active = 'ACTIVE',
Deleted = 'DELETED',
Unsubscribed = 'UNSUBSCRIBED'
}
export type UnsubscribeError = {
__typename?: 'UnsubscribeError';
errorCodes: Array<UnsubscribeErrorCode>;
@ -2024,7 +2036,7 @@ export type UpdatePageResult = UpdatePageError | UpdatePageSuccess;
export type UpdatePageSuccess = {
__typename?: 'UpdatePageSuccess';
updatedPage: Page;
updatedPage: Article;
};
export type UpdateReminderError = {
@ -2148,6 +2160,7 @@ export type UploadFileRequestResult = UploadFileRequestError | UploadFileRequest
export type UploadFileRequestSuccess = {
__typename?: 'UploadFileRequestSuccess';
createdPageId?: Maybe<Scalars['String']>;
id: Scalars['ID'];
uploadFileId?: Maybe<Scalars['ID']>;
uploadSignedUrl?: Maybe<Scalars['String']>;
@ -2200,6 +2213,11 @@ export type UserPersonalization = {
export type UserResult = UserError | UserSuccess;
export type UserSuccess = {
__typename?: 'UserSuccess';
user: User;
};
export type UsersError = {
__typename?: 'UsersError';
errorCodes: Array<UsersErrorCode>;
@ -2216,11 +2234,6 @@ export type UsersSuccess = {
users: Array<User>;
};
export type UserSuccess = {
__typename?: 'UserSuccess';
user: User;
};
export type Webhook = {
__typename?: 'Webhook';
contentType: Scalars['String'];
@ -2258,6 +2271,11 @@ export enum WebhookEvent {
export type WebhookResult = WebhookError | WebhookSuccess;
export type WebhookSuccess = {
__typename?: 'WebhookSuccess';
webhook: Webhook;
};
export type WebhooksError = {
__typename?: 'WebhooksError';
errorCodes: Array<WebhooksErrorCode>;
@ -2275,11 +2293,6 @@ export type WebhooksSuccess = {
webhooks: Array<Webhook>;
};
export type WebhookSuccess = {
__typename?: 'WebhookSuccess';
webhook: Webhook;
};
export type ResolverTypeWrapper<T> = Promise<T> | T;
@ -2375,11 +2388,11 @@ export type ResolversTypes = {
ArticleSavingRequestResult: ResolversTypes['ArticleSavingRequestError'] | ResolversTypes['ArticleSavingRequestSuccess'];
ArticleSavingRequestStatus: ArticleSavingRequestStatus;
ArticleSavingRequestSuccess: ResolverTypeWrapper<ArticleSavingRequestSuccess>;
ArticleSuccess: ResolverTypeWrapper<ArticleSuccess>;
ArticlesError: ResolverTypeWrapper<ArticlesError>;
ArticlesErrorCode: ArticlesErrorCode;
ArticlesResult: ResolversTypes['ArticlesError'] | ResolversTypes['ArticlesSuccess'];
ArticlesSuccess: ResolverTypeWrapper<ArticlesSuccess>;
ArticleSuccess: ResolverTypeWrapper<ArticleSuccess>;
Boolean: ResolverTypeWrapper<Scalars['Boolean']>;
ContentReader: ContentReader;
CreateArticleError: ResolverTypeWrapper<CreateArticleError>;
@ -2492,15 +2505,15 @@ export type ResolversTypes = {
LabelsSuccess: ResolverTypeWrapper<LabelsSuccess>;
Link: ResolverTypeWrapper<Link>;
LinkShareInfo: ResolverTypeWrapper<LinkShareInfo>;
LogOutError: ResolverTypeWrapper<LogOutError>;
LogOutErrorCode: LogOutErrorCode;
LogOutResult: ResolversTypes['LogOutError'] | ResolversTypes['LogOutSuccess'];
LogOutSuccess: ResolverTypeWrapper<LogOutSuccess>;
LoginError: ResolverTypeWrapper<LoginError>;
LoginErrorCode: LoginErrorCode;
LoginInput: LoginInput;
LoginResult: ResolversTypes['LoginError'] | ResolversTypes['LoginSuccess'];
LoginSuccess: ResolverTypeWrapper<LoginSuccess>;
LogOutError: ResolverTypeWrapper<LogOutError>;
LogOutErrorCode: LogOutErrorCode;
LogOutResult: ResolversTypes['LogOutError'] | ResolversTypes['LogOutSuccess'];
LogOutSuccess: ResolverTypeWrapper<LogOutSuccess>;
MergeHighlightError: ResolverTypeWrapper<MergeHighlightError>;
MergeHighlightErrorCode: MergeHighlightErrorCode;
MergeHighlightInput: MergeHighlightInput;
@ -2573,6 +2586,7 @@ export type ResolversTypes = {
SetFollowSuccess: ResolverTypeWrapper<SetFollowSuccess>;
SetLabelsError: ResolverTypeWrapper<SetLabelsError>;
SetLabelsErrorCode: SetLabelsErrorCode;
SetLabelsForHighlightInput: SetLabelsForHighlightInput;
SetLabelsInput: SetLabelsInput;
SetLabelsResult: ResolversTypes['SetLabelsError'] | ResolversTypes['SetLabelsSuccess'];
SetLabelsSuccess: ResolverTypeWrapper<SetLabelsSuccess>;
@ -2596,11 +2610,11 @@ export type ResolversTypes = {
SetWebhookInput: SetWebhookInput;
SetWebhookResult: ResolversTypes['SetWebhookError'] | ResolversTypes['SetWebhookSuccess'];
SetWebhookSuccess: ResolverTypeWrapper<SetWebhookSuccess>;
ShareStats: ResolverTypeWrapper<ShareStats>;
SharedArticleError: ResolverTypeWrapper<SharedArticleError>;
SharedArticleErrorCode: SharedArticleErrorCode;
SharedArticleResult: ResolversTypes['SharedArticleError'] | ResolversTypes['SharedArticleSuccess'];
SharedArticleSuccess: ResolverTypeWrapper<SharedArticleSuccess>;
ShareStats: ResolverTypeWrapper<ShareStats>;
SignupError: ResolverTypeWrapper<SignupError>;
SignupErrorCode: SignupErrorCode;
SignupInput: SignupInput;
@ -2615,11 +2629,11 @@ export type ResolversTypes = {
SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess'];
SubscribeSuccess: ResolverTypeWrapper<SubscribeSuccess>;
Subscription: ResolverTypeWrapper<{}>;
SubscriptionStatus: SubscriptionStatus;
SubscriptionsError: ResolverTypeWrapper<SubscriptionsError>;
SubscriptionsErrorCode: SubscriptionsErrorCode;
SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess'];
SubscriptionsSuccess: ResolverTypeWrapper<SubscriptionsSuccess>;
SubscriptionStatus: SubscriptionStatus;
UnsubscribeError: ResolverTypeWrapper<UnsubscribeError>;
UnsubscribeErrorCode: UnsubscribeErrorCode;
UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess'];
@ -2680,21 +2694,21 @@ export type ResolversTypes = {
UserErrorCode: UserErrorCode;
UserPersonalization: ResolverTypeWrapper<UserPersonalization>;
UserResult: ResolversTypes['UserError'] | ResolversTypes['UserSuccess'];
UserSuccess: ResolverTypeWrapper<UserSuccess>;
UsersError: ResolverTypeWrapper<UsersError>;
UsersErrorCode: UsersErrorCode;
UsersResult: ResolversTypes['UsersError'] | ResolversTypes['UsersSuccess'];
UsersSuccess: ResolverTypeWrapper<UsersSuccess>;
UserSuccess: ResolverTypeWrapper<UserSuccess>;
Webhook: ResolverTypeWrapper<Webhook>;
WebhookError: ResolverTypeWrapper<WebhookError>;
WebhookErrorCode: WebhookErrorCode;
WebhookEvent: WebhookEvent;
WebhookResult: ResolversTypes['WebhookError'] | ResolversTypes['WebhookSuccess'];
WebhookSuccess: ResolverTypeWrapper<WebhookSuccess>;
WebhooksError: ResolverTypeWrapper<WebhooksError>;
WebhooksErrorCode: WebhooksErrorCode;
WebhooksResult: ResolversTypes['WebhooksError'] | ResolversTypes['WebhooksSuccess'];
WebhooksSuccess: ResolverTypeWrapper<WebhooksSuccess>;
WebhookSuccess: ResolverTypeWrapper<WebhookSuccess>;
};
/** Mapping between all available schema types and the resolvers parents */
@ -2719,10 +2733,10 @@ export type ResolversParentTypes = {
ArticleSavingRequestError: ArticleSavingRequestError;
ArticleSavingRequestResult: ResolversParentTypes['ArticleSavingRequestError'] | ResolversParentTypes['ArticleSavingRequestSuccess'];
ArticleSavingRequestSuccess: ArticleSavingRequestSuccess;
ArticleSuccess: ArticleSuccess;
ArticlesError: ArticlesError;
ArticlesResult: ResolversParentTypes['ArticlesError'] | ResolversParentTypes['ArticlesSuccess'];
ArticlesSuccess: ArticlesSuccess;
ArticleSuccess: ArticleSuccess;
Boolean: Scalars['Boolean'];
CreateArticleError: CreateArticleError;
CreateArticleInput: CreateArticleInput;
@ -2813,13 +2827,13 @@ export type ResolversParentTypes = {
LabelsSuccess: LabelsSuccess;
Link: Link;
LinkShareInfo: LinkShareInfo;
LogOutError: LogOutError;
LogOutResult: ResolversParentTypes['LogOutError'] | ResolversParentTypes['LogOutSuccess'];
LogOutSuccess: LogOutSuccess;
LoginError: LoginError;
LoginInput: LoginInput;
LoginResult: ResolversParentTypes['LoginError'] | ResolversParentTypes['LoginSuccess'];
LoginSuccess: LoginSuccess;
LogOutError: LogOutError;
LogOutResult: ResolversParentTypes['LogOutError'] | ResolversParentTypes['LogOutSuccess'];
LogOutSuccess: LogOutSuccess;
MergeHighlightError: MergeHighlightError;
MergeHighlightInput: MergeHighlightInput;
MergeHighlightResult: ResolversParentTypes['MergeHighlightError'] | ResolversParentTypes['MergeHighlightSuccess'];
@ -2877,6 +2891,7 @@ export type ResolversParentTypes = {
SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess'];
SetFollowSuccess: SetFollowSuccess;
SetLabelsError: SetLabelsError;
SetLabelsForHighlightInput: SetLabelsForHighlightInput;
SetLabelsInput: SetLabelsInput;
SetLabelsResult: ResolversParentTypes['SetLabelsError'] | ResolversParentTypes['SetLabelsSuccess'];
SetLabelsSuccess: SetLabelsSuccess;
@ -2896,10 +2911,10 @@ export type ResolversParentTypes = {
SetWebhookInput: SetWebhookInput;
SetWebhookResult: ResolversParentTypes['SetWebhookError'] | ResolversParentTypes['SetWebhookSuccess'];
SetWebhookSuccess: SetWebhookSuccess;
ShareStats: ShareStats;
SharedArticleError: SharedArticleError;
SharedArticleResult: ResolversParentTypes['SharedArticleError'] | ResolversParentTypes['SharedArticleSuccess'];
SharedArticleSuccess: SharedArticleSuccess;
ShareStats: ShareStats;
SignupError: SignupError;
SignupInput: SignupInput;
SignupResult: ResolversParentTypes['SignupError'] | ResolversParentTypes['SignupSuccess'];
@ -2960,17 +2975,17 @@ export type ResolversParentTypes = {
UserError: UserError;
UserPersonalization: UserPersonalization;
UserResult: ResolversParentTypes['UserError'] | ResolversParentTypes['UserSuccess'];
UserSuccess: UserSuccess;
UsersError: UsersError;
UsersResult: ResolversParentTypes['UsersError'] | ResolversParentTypes['UsersSuccess'];
UsersSuccess: UsersSuccess;
UserSuccess: UserSuccess;
Webhook: Webhook;
WebhookError: WebhookError;
WebhookResult: ResolversParentTypes['WebhookError'] | ResolversParentTypes['WebhookSuccess'];
WebhookSuccess: WebhookSuccess;
WebhooksError: WebhooksError;
WebhooksResult: ResolversParentTypes['WebhooksError'] | ResolversParentTypes['WebhooksSuccess'];
WebhooksSuccess: WebhooksSuccess;
WebhookSuccess: WebhookSuccess;
};
export type SanitizeDirectiveArgs = {
@ -3056,12 +3071,13 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
pageType?: Resolver<Maybe<ResolversTypes['PageType']>, ParentType, ContextType>;
postedByViewer?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
publishedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
readAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
readingProgressAnchorIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
readingProgressPercent?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
savedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
savedByViewer?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
sharedComment?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
shareInfo?: Resolver<Maybe<ResolversTypes['LinkShareInfo']>, ParentType, ContextType>;
sharedComment?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteIcon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@ -3118,6 +3134,11 @@ export type ArticleSavingRequestSuccessResolvers<ContextType = ResolverContext,
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ArticleSuccess'] = ResolversParentTypes['ArticleSuccess']> = {
article?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ArticlesErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ArticlesError'] = ResolversParentTypes['ArticlesError']> = {
errorCodes?: Resolver<Array<ResolversTypes['ArticlesErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3133,11 +3154,6 @@ export type ArticlesSuccessResolvers<ContextType = ResolverContext, ParentType e
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ArticleSuccess'] = ResolversParentTypes['ArticleSuccess']> = {
article?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['CreateArticleError'] = ResolversParentTypes['CreateArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['CreateArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3546,20 +3562,6 @@ export type LinkShareInfoResolvers<ContextType = ResolverContext, ParentType ext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type LoginErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LoginError'] = ResolversParentTypes['LoginError']> = {
errorCodes?: Resolver<Array<ResolversTypes['LoginErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type LoginResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LoginResult'] = ResolversParentTypes['LoginResult']> = {
__resolveType: TypeResolveFn<'LoginError' | 'LoginSuccess', ParentType, ContextType>;
};
export type LoginSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LoginSuccess'] = ResolversParentTypes['LoginSuccess']> = {
me?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type LogOutErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LogOutError'] = ResolversParentTypes['LogOutError']> = {
errorCodes?: Resolver<Array<ResolversTypes['LogOutErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3574,6 +3576,20 @@ export type LogOutSuccessResolvers<ContextType = ResolverContext, ParentType ext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type LoginErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LoginError'] = ResolversParentTypes['LoginError']> = {
errorCodes?: Resolver<Array<ResolversTypes['LoginErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type LoginResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LoginResult'] = ResolversParentTypes['LoginResult']> = {
__resolveType: TypeResolveFn<'LoginError' | 'LoginSuccess', ParentType, ContextType>;
};
export type LoginSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['LoginSuccess'] = ResolversParentTypes['LoginSuccess']> = {
me?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type MergeHighlightErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['MergeHighlightError'] = ResolversParentTypes['MergeHighlightError']> = {
errorCodes?: Resolver<Array<ResolversTypes['MergeHighlightErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3609,8 +3625,8 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
googleSignup?: Resolver<ResolversTypes['GoogleSignupResult'], ParentType, ContextType, RequireFields<MutationGoogleSignupArgs, 'input'>>;
login?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationLoginArgs, 'input'>>;
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
login?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationLoginArgs, 'input'>>;
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
@ -3622,6 +3638,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
setLabelsForHighlight?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsForHighlightArgs, 'input'>>;
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
setShareArticle?: Resolver<ResolversTypes['SetShareArticleResult'], ParentType, ContextType, RequireFields<MutationSetShareArticleArgs, 'input'>>;
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
@ -3664,9 +3681,9 @@ export type NewsletterEmailsSuccessResolvers<ContextType = ResolverContext, Pare
};
export type PageResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Page'] = ResolversParentTypes['Page']> = {
author?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
hash?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@ -3702,8 +3719,8 @@ export type ProfileResolvers<ContextType = ResolverContext, ParentType extends R
export type QueryResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = {
apiKeys?: Resolver<ResolversTypes['ApiKeysResult'], ParentType, ContextType>;
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
articles?: Resolver<ResolversTypes['ArticlesResult'], ParentType, ContextType, Partial<QueryArticlesArgs>>;
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, RequireFields<QueryArticleSavingRequestArgs, 'id'>>;
articles?: Resolver<ResolversTypes['ArticlesResult'], ParentType, ContextType, Partial<QueryArticlesArgs>>;
feedArticles?: Resolver<ResolversTypes['FeedArticlesResult'], ParentType, ContextType, Partial<QueryFeedArticlesArgs>>;
getFollowers?: Resolver<ResolversTypes['GetFollowersResult'], ParentType, ContextType, Partial<QueryGetFollowersArgs>>;
getFollowing?: Resolver<ResolversTypes['GetFollowingResult'], ParentType, ContextType, Partial<QueryGetFollowingArgs>>;
@ -3995,6 +4012,13 @@ export type SetWebhookSuccessResolvers<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ShareStatsResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ShareStats'] = ResolversParentTypes['ShareStats']> = {
readDuration?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
saveCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
viewCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SharedArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SharedArticleError'] = ResolversParentTypes['SharedArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SharedArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4009,13 +4033,6 @@ export type SharedArticleSuccessResolvers<ContextType = ResolverContext, ParentT
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ShareStatsResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ShareStats'] = ResolversParentTypes['ShareStats']> = {
readDuration?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
saveCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
viewCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SignupErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SignupError'] = ResolversParentTypes['SignupError']> = {
errorCodes?: Resolver<Array<Maybe<ResolversTypes['SignupErrorCode']>>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4151,7 +4168,7 @@ export type UpdatePageResultResolvers<ContextType = ResolverContext, ParentType
};
export type UpdatePageSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdatePageSuccess'] = ResolversParentTypes['UpdatePageSuccess']> = {
updatedPage?: Resolver<ResolversTypes['Page'], ParentType, ContextType>;
updatedPage?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -4222,6 +4239,7 @@ export type UploadFileRequestResultResolvers<ContextType = ResolverContext, Pare
};
export type UploadFileRequestSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UploadFileRequestSuccess'] = ResolversParentTypes['UploadFileRequestSuccess']> = {
createdPageId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
uploadSignedUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -4265,6 +4283,11 @@ export type UserResultResolvers<ContextType = ResolverContext, ParentType extend
__resolveType: TypeResolveFn<'UserError' | 'UserSuccess', ParentType, ContextType>;
};
export type UserSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UserSuccess'] = ResolversParentTypes['UserSuccess']> = {
user?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UsersErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UsersError'] = ResolversParentTypes['UsersError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UsersErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4279,11 +4302,6 @@ export type UsersSuccessResolvers<ContextType = ResolverContext, ParentType exte
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UserSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UserSuccess'] = ResolversParentTypes['UserSuccess']> = {
user?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhookResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Webhook'] = ResolversParentTypes['Webhook']> = {
contentType?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
@ -4305,6 +4323,11 @@ export type WebhookResultResolvers<ContextType = ResolverContext, ParentType ext
__resolveType: TypeResolveFn<'WebhookError' | 'WebhookSuccess', ParentType, ContextType>;
};
export type WebhookSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhookSuccess'] = ResolversParentTypes['WebhookSuccess']> = {
webhook?: Resolver<ResolversTypes['Webhook'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhooksErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhooksError'] = ResolversParentTypes['WebhooksError']> = {
errorCodes?: Resolver<Array<ResolversTypes['WebhooksErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4319,11 +4342,6 @@ export type WebhooksSuccessResolvers<ContextType = ResolverContext, ParentType e
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhookSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhookSuccess'] = ResolversParentTypes['WebhookSuccess']> = {
webhook?: Resolver<ResolversTypes['Webhook'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type Resolvers<ContextType = ResolverContext> = {
AddPopularReadError?: AddPopularReadErrorResolvers<ContextType>;
AddPopularReadResult?: AddPopularReadResultResolvers<ContextType>;
@ -4343,10 +4361,10 @@ export type Resolvers<ContextType = ResolverContext> = {
ArticleSavingRequestError?: ArticleSavingRequestErrorResolvers<ContextType>;
ArticleSavingRequestResult?: ArticleSavingRequestResultResolvers<ContextType>;
ArticleSavingRequestSuccess?: ArticleSavingRequestSuccessResolvers<ContextType>;
ArticleSuccess?: ArticleSuccessResolvers<ContextType>;
ArticlesError?: ArticlesErrorResolvers<ContextType>;
ArticlesResult?: ArticlesResultResolvers<ContextType>;
ArticlesSuccess?: ArticlesSuccessResolvers<ContextType>;
ArticleSuccess?: ArticleSuccessResolvers<ContextType>;
CreateArticleError?: CreateArticleErrorResolvers<ContextType>;
CreateArticleResult?: CreateArticleResultResolvers<ContextType>;
CreateArticleSavingRequestError?: CreateArticleSavingRequestErrorResolvers<ContextType>;
@ -4423,12 +4441,12 @@ export type Resolvers<ContextType = ResolverContext> = {
LabelsSuccess?: LabelsSuccessResolvers<ContextType>;
Link?: LinkResolvers<ContextType>;
LinkShareInfo?: LinkShareInfoResolvers<ContextType>;
LoginError?: LoginErrorResolvers<ContextType>;
LoginResult?: LoginResultResolvers<ContextType>;
LoginSuccess?: LoginSuccessResolvers<ContextType>;
LogOutError?: LogOutErrorResolvers<ContextType>;
LogOutResult?: LogOutResultResolvers<ContextType>;
LogOutSuccess?: LogOutSuccessResolvers<ContextType>;
LoginError?: LoginErrorResolvers<ContextType>;
LoginResult?: LoginResultResolvers<ContextType>;
LoginSuccess?: LoginSuccessResolvers<ContextType>;
MergeHighlightError?: MergeHighlightErrorResolvers<ContextType>;
MergeHighlightResult?: MergeHighlightResultResolvers<ContextType>;
MergeHighlightSuccess?: MergeHighlightSuccessResolvers<ContextType>;
@ -4489,10 +4507,10 @@ export type Resolvers<ContextType = ResolverContext> = {
SetWebhookError?: SetWebhookErrorResolvers<ContextType>;
SetWebhookResult?: SetWebhookResultResolvers<ContextType>;
SetWebhookSuccess?: SetWebhookSuccessResolvers<ContextType>;
ShareStats?: ShareStatsResolvers<ContextType>;
SharedArticleError?: SharedArticleErrorResolvers<ContextType>;
SharedArticleResult?: SharedArticleResultResolvers<ContextType>;
SharedArticleSuccess?: SharedArticleSuccessResolvers<ContextType>;
ShareStats?: ShareStatsResolvers<ContextType>;
SignupError?: SignupErrorResolvers<ContextType>;
SignupResult?: SignupResultResolvers<ContextType>;
SignupSuccess?: SignupSuccessResolvers<ContextType>;
@ -4540,17 +4558,17 @@ export type Resolvers<ContextType = ResolverContext> = {
UserError?: UserErrorResolvers<ContextType>;
UserPersonalization?: UserPersonalizationResolvers<ContextType>;
UserResult?: UserResultResolvers<ContextType>;
UserSuccess?: UserSuccessResolvers<ContextType>;
UsersError?: UsersErrorResolvers<ContextType>;
UsersResult?: UsersResultResolvers<ContextType>;
UsersSuccess?: UsersSuccessResolvers<ContextType>;
UserSuccess?: UserSuccessResolvers<ContextType>;
Webhook?: WebhookResolvers<ContextType>;
WebhookError?: WebhookErrorResolvers<ContextType>;
WebhookResult?: WebhookResultResolvers<ContextType>;
WebhookSuccess?: WebhookSuccessResolvers<ContextType>;
WebhooksError?: WebhooksErrorResolvers<ContextType>;
WebhooksResult?: WebhooksResultResolvers<ContextType>;
WebhooksSuccess?: WebhooksSuccessResolvers<ContextType>;
WebhookSuccess?: WebhookSuccessResolvers<ContextType>;
};
export type DirectiveResolvers<ContextType = ResolverContext> = {

View file

@ -83,12 +83,13 @@ type Article {
pageType: PageType
postedByViewer: Boolean
publishedAt: Date
readAt: Date
readingProgressAnchorIndex: Int!
readingProgressPercent: Float!
savedAt: Date!
savedByViewer: Boolean
sharedComment: String
shareInfo: LinkShareInfo
sharedComment: String
siteIcon: String
siteName: String
slug: String!
@ -156,6 +157,10 @@ type ArticleSavingRequestSuccess {
articleSavingRequest: ArticleSavingRequest!
}
type ArticleSuccess {
article: Article!
}
type ArticlesError {
errorCodes: [ArticlesErrorCode!]!
}
@ -171,10 +176,6 @@ type ArticlesSuccess {
pageInfo: PageInfo!
}
type ArticleSuccess {
article: Article!
}
enum ContentReader {
PDF
WEB
@ -686,6 +687,20 @@ type LinkShareInfo {
title: String!
}
type LogOutError {
errorCodes: [LogOutErrorCode!]!
}
enum LogOutErrorCode {
LOG_OUT_FAILED
}
union LogOutResult = LogOutError | LogOutSuccess
type LogOutSuccess {
message: String
}
type LoginError {
errorCodes: [LoginErrorCode!]!
}
@ -710,20 +725,6 @@ type LoginSuccess {
me: User!
}
type LogOutError {
errorCodes: [LogOutErrorCode!]!
}
enum LogOutErrorCode {
LOG_OUT_FAILED
}
union LogOutResult = LogOutError | LogOutSuccess
type LogOutSuccess {
message: String
}
type MergeHighlightError {
errorCodes: [MergeHighlightErrorCode!]!
}
@ -775,8 +776,8 @@ type Mutation {
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
login(input: LoginInput!): LoginResult!
logOut: LogOutResult!
login(input: LoginInput!): LoginResult!
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
reportItem(input: ReportItemInput!): ReportItemResult!
revokeApiKey(id: ID!): RevokeApiKeyResult!
@ -788,6 +789,7 @@ type Mutation {
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
setFollow(input: SetFollowInput!): SetFollowResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
setShareArticle(input: SetShareArticleInput!): SetShareArticleResult!
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
@ -830,9 +832,9 @@ type NewsletterEmailsSuccess {
}
type Page {
author: String!
author: String
createdAt: Date!
description: String!
description: String
hash: String!
id: ID!
image: String!
@ -890,8 +892,8 @@ type Profile {
type Query {
apiKeys: ApiKeysResult!
article(slug: String!, username: String!): ArticleResult!
articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult!
articleSavingRequest(id: ID!): ArticleSavingRequestResult!
articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult!
feedArticles(after: String, first: Int, sharedByUser: ID, sort: SortParams): FeedArticlesResult!
getFollowers(userId: ID): GetFollowersResult!
getFollowing(userId: ID): GetFollowingResult!
@ -1195,6 +1197,11 @@ enum SetLabelsErrorCode {
UNAUTHORIZED
}
input SetLabelsForHighlightInput {
highlightId: ID!
labelIds: [ID!]!
}
input SetLabelsInput {
labelIds: [ID!]!
pageId: ID!
@ -1300,6 +1307,12 @@ type SetWebhookSuccess {
webhook: Webhook!
}
type ShareStats {
readDuration: Int!
saveCount: Int!
viewCount: Int!
}
type SharedArticleError {
errorCodes: [SharedArticleErrorCode!]!
}
@ -1314,12 +1327,6 @@ type SharedArticleSuccess {
article: Article!
}
type ShareStats {
readDuration: Int!
saveCount: Int!
viewCount: Int!
}
type SignupError {
errorCodes: [SignupErrorCode]!
}
@ -1396,6 +1403,12 @@ type Subscription {
url: String
}
enum SubscriptionStatus {
ACTIVE
DELETED
UNSUBSCRIBED
}
type SubscriptionsError {
errorCodes: [SubscriptionsErrorCode!]!
}
@ -1411,12 +1424,6 @@ type SubscriptionsSuccess {
subscriptions: [Subscription!]!
}
enum SubscriptionStatus {
ACTIVE
DELETED
UNSUBSCRIBED
}
type UnsubscribeError {
errorCodes: [UnsubscribeErrorCode!]!
}
@ -1545,7 +1552,7 @@ input UpdatePageInput {
union UpdatePageResult = UpdatePageError | UpdatePageSuccess
type UpdatePageSuccess {
updatedPage: Page!
updatedPage: Article!
}
type UpdateReminderError {
@ -1659,6 +1666,7 @@ input UploadFileRequestInput {
union UploadFileRequestResult = UploadFileRequestError | UploadFileRequestSuccess
type UploadFileRequestSuccess {
createdPageId: String
id: ID!
uploadFileId: ID
uploadSignedUrl: String
@ -1707,6 +1715,10 @@ type UserPersonalization {
union UserResult = UserError | UserSuccess
type UserSuccess {
user: User!
}
type UsersError {
errorCodes: [UsersErrorCode!]!
}
@ -1721,10 +1733,6 @@ type UsersSuccess {
users: [User!]!
}
type UserSuccess {
user: User!
}
type Webhook {
contentType: String!
createdAt: Date!
@ -1760,6 +1768,10 @@ enum WebhookEvent {
union WebhookResult = WebhookError | WebhookSuccess
type WebhookSuccess {
webhook: Webhook!
}
type WebhooksError {
errorCodes: [WebhooksErrorCode!]!
}
@ -1774,7 +1786,3 @@ union WebhooksResult = WebhooksError | WebhooksSuccess
type WebhooksSuccess {
webhooks: [Webhook!]!
}
type WebhookSuccess {
webhook: Webhook!
}

View file

@ -34,7 +34,10 @@ export const apiKeysResolver = authorized<ApiKeysSuccess, ApiKeysError>(
const apiKeys = await getRepository(ApiKey).find({
select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'],
where: { user: { id: uid } },
order: { usedAt: 'DESC', createdAt: 'DESC' },
order: {
usedAt: { direction: 'DESC', nulls: 'last' },
createdAt: 'DESC',
},
})
return {

View file

@ -258,7 +258,9 @@ export const createArticleResolver = authorized<
title ||
parsedContent?.title ||
preparedDocument?.pageInfo.title ||
croppedPathname,
croppedPathname ||
parsedContent?.siteName ||
url,
author: parsedContent?.byline,
url: normalizeUrl(canonicalUrl || url, {
stripHash: true,

View file

@ -65,6 +65,7 @@ import {
setBookmarkArticleResolver,
setDeviceTokenResolver,
setFollowResolver,
setLabelsForHighlightResolver,
setLabelsResolver,
setLinkArchivedResolver,
setShareArticleResolver,
@ -161,6 +162,7 @@ export const functionResolvers = {
setWebhook: setWebhookResolver,
deleteWebhook: deleteWebhookResolver,
revokeApiKey: revokeApiKeyResolver,
setLabelsForHighlight: setLabelsForHighlightResolver,
},
Query: {
me: getMeUserResolver,

View file

@ -12,6 +12,7 @@ import {
MutationCreateLabelArgs,
MutationDeleteLabelArgs,
MutationSetLabelsArgs,
MutationSetLabelsForHighlightArgs,
MutationUpdateLabelArgs,
SetLabelsError,
SetLabelsErrorCode,
@ -24,16 +25,19 @@ import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { User } from '../../entity/user'
import { Label } from '../../entity/label'
import { ILike, In } from 'typeorm'
import { ILike } from 'typeorm'
import { getRepository, setClaims } from '../../entity/utils'
import { createPubSubClient } from '../../datalayer/pubsub'
import { AppDataSource } from '../../server'
import { getPageById } from '../../elastic/pages'
import {
deleteLabelInPages,
updateLabelInPage,
deleteLabel,
setLabelsForHighlight,
updateLabel,
updateLabelsInPage,
} from '../../elastic/labels'
import { getHighlightById } from '../../elastic/highlights'
import { getLabelsByIds } from '../../services/labels'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { claims: { uid }, log }) => {
@ -174,8 +178,8 @@ export const deleteLabelResolver = authorized<
}
}
// delete label in elastic pages
await deleteLabelInPages(uid, label.name, {
// delete label in elastic pages and highlights
await deleteLabel(label.name, {
pubsub: createPubSubClient(),
uid,
})
@ -223,11 +227,13 @@ export const setLabelsResolver = authorized<
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
if (page.userId !== uid) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
const labels = await getRepository(Label).find({
where: { id: In(labelIds), user: { id: user.id } },
select: ['id', 'name', 'color', 'description', 'createdAt'],
})
const labels = await getLabelsByIds(uid, labelIds)
if (labels.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
@ -316,15 +322,10 @@ export const updateLabelResolver = authorized<
}
}
const updated = await updateLabelInPage(label, {
await updateLabel(label, {
pubsub,
uid,
})
if (!updated) {
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
return { label }
} catch (error) {
@ -334,3 +335,71 @@ export const updateLabelResolver = authorized<
}
}
})
export const setLabelsForHighlightResolver = authorized<
SetLabelsSuccess,
SetLabelsError,
MutationSetLabelsForHighlightArgs
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
log.info('setLabelsForHighlightResolver')
const { highlightId, labelIds } = input
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
const highlight = await getHighlightById(highlightId)
if (!highlight) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
if (highlight.userId !== uid) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
const labels = await getLabelsByIds(uid, labelIds)
if (labels.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
// set labels in the highlights
const updated = await setLabelsForHighlight(highlightId, labels, {
pubsub,
uid,
})
if (!updated) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
analytics.track({
userId: uid,
event: 'labels_set_for_highlight',
properties: {
highlightId,
labelIds,
env: env.server.apiEnv,
},
})
return {
labels,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
})

View file

@ -11,6 +11,7 @@ import { WithDataSourcesContext } from '../types'
import {
generateUploadSignedUrl,
generateUploadFilePathName,
getFilePublicUrl,
} from '../../utils/uploads'
import path from 'path'
import normalizeUrl from 'normalize-url'
@ -19,6 +20,12 @@ import { env } from '../../env'
import { createPage, getPageByParam, updatePage } from '../../elastic/pages'
import { PageType } from '../../elastic/types'
import { generateSlug } from '../../utils/helpers'
import { validateUrl } from '../../services/create_page_save_request'
const isFileUrl = (url: string): boolean => {
const parsedUrl = new URL(url)
return parsedUrl.protocol == 'file:'
}
export const uploadFileRequestResolver: ResolverFn<
UploadFileRequestResult,
@ -60,6 +67,17 @@ export const uploadFileRequestResolver: ResolverFn<
if (!fileName) {
fileName = 'content.pdf'
}
if (!isFileUrl(url)) {
try {
validateUrl(url)
} catch (error) {
console.log('illegal file input url', error)
return {
errorCodes: [UploadFileRequestErrorCode.BadInput],
}
}
}
} catch {
return { errorCodes: [UploadFileRequestErrorCode.BadInput] }
}
@ -82,24 +100,46 @@ export const uploadFileRequestResolver: ResolverFn<
input.contentType
)
if (input.createPageEntry) {
const page = await getPageByParam({
userId: claims.uid,
url: input.url,
const publicUrl = getFilePublicUrl(uploadFilePathName)
// If this is a file URL, we swap in the GCS public URL
if (isFileUrl(input.url)) {
await models.uploadFile.update(uploadFileData.id, {
url: publicUrl,
status: UploadFileStatus.Initialized,
})
}
let createdPageId: string | undefined = undefined
if (input.createPageEntry) {
// If we have a file:// URL, don't try to match it
// and create a copy of the page, just create a
// new item.
const page = isFileUrl(input.url)
? await getPageByParam({
userId: claims.uid,
url: input.url,
})
: undefined
if (page) {
await updatePage(
page.id,
{
savedAt: new Date(),
archivedAt: null,
},
ctx
)
if (
!(await updatePage(
page.id,
{
savedAt: new Date(),
archivedAt: null,
},
ctx
))
) {
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
}
createdPageId = page.id
} else {
const pageId = await createPage(
{
url: input.url,
url: isFileUrl(input.url) ? publicUrl : input.url,
id: input.clientRequestId || '',
userId: claims.uid,
title: title,
@ -119,10 +159,15 @@ export const uploadFileRequestResolver: ResolverFn<
if (!pageId) {
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
}
createdPageId = pageId
}
}
return { id: uploadFileData.id, uploadSignedUrl }
return {
id: uploadFileData.id,
uploadSignedUrl,
createdPageId: createdPageId,
}
} else {
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
}

View file

@ -302,8 +302,8 @@ const schema = gql`
type: PageType!
image: String!
title: String!
author: String!
description: String!
author: String
description: String
publishedAt: Date
originalHtml: String!
@ -351,6 +351,7 @@ const schema = gql`
unsubHttpUrl: String
state: ArticleSavingRequestStatus
language: String
readAt: Date
}
# Query: article
@ -435,6 +436,7 @@ const schema = gql`
id: ID!
uploadSignedUrl: String
uploadFileId: ID
createdPageId: String
}
# Mutation: createArticle
@ -513,7 +515,7 @@ const schema = gql`
}
type UpdatePageSuccess {
updatedPage: Page!
updatedPage: Article!
}
enum UpdatePageErrorCode {
@ -1738,6 +1740,11 @@ const schema = gql`
NOT_FOUND
}
input SetLabelsForHighlightInput {
highlightId: ID!
labelIds: [ID!]!
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1806,6 +1813,7 @@ const schema = gql`
setWebhook(input: SetWebhookInput!): SetWebhookResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
revokeApiKey(id: ID!): RevokeApiKeyResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed

View file

@ -66,3 +66,13 @@ export const addLabelToPage = async (
ctx
)
}
export const getLabelsByIds = async (
userId: string,
labelIds: string[]
): Promise<Label[]> => {
return getRepository(Label).find({
where: { id: In(labelIds), user: { id: userId } },
select: ['id', 'name', 'color', 'description', 'createdAt'],
})
}

View file

@ -1,6 +1,11 @@
import { PubsubClient } from '../datalayer/pubsub'
import { homePageURL } from '../env'
import { Maybe, SavePageInput, SaveResult } from '../generated/graphql'
import {
Maybe,
SaveErrorCode,
SavePageInput,
SaveResult,
} from '../generated/graphql'
import { DataModels } from '../resolvers/types'
import { generateSlug, stringToHash, validatedDate } from '../utils/helpers'
import { parsePreparedContent } from '../utils/parser'
@ -102,25 +107,44 @@ export const savePage = async (
state: ArticleSavingRequestStatus.Succeeded,
})
if (existingPage) {
await updatePage(
existingPage.id,
{
savedAt: new Date(),
archivedAt: undefined,
},
ctx
)
if (
!(await updatePage(
existingPage.id,
{
savedAt: new Date(),
archivedAt: undefined,
},
ctx
))
) {
return {
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to update existing page',
}
}
input.clientRequestId = existingPage.id
} else if (shouldParseInBackend(input)) {
await createPageSaveRequest(
saver.userId,
input.url,
ctx.models,
ctx.pubsub,
input.clientRequestId
)
try {
await createPageSaveRequest(
saver.userId,
input.url,
ctx.models,
ctx.pubsub,
input.clientRequestId
)
} catch (e) {
return {
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to create page save request',
}
}
} else {
await createPage(articleToSave, ctx)
if (!(await createPage(articleToSave, ctx))) {
return {
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to create new page',
}
}
}
return {

View file

@ -15,6 +15,10 @@ const storage = env.fileUpload?.gcsUploadSAKeyFilePath
: new Storage()
const bucketName = env.fileUpload.gcsUploadBucket
export const getFilePublicUrl = (filePathName: string): string => {
return storage.bucket(bucketName).file(filePathName).publicUrl()
}
export const generateUploadSignedUrl = async (
filePathName: string,
contentType: string,

View file

@ -184,9 +184,9 @@ export const createTestLabel = async (
color: string
): Promise<Label> => {
return getRepository(Label).save({
user: user,
name: name,
color: color,
user,
name,
color,
})
}

View file

@ -4,6 +4,7 @@ import { InFilter, ReadFilter } from '../../src/utils/search'
import {
ArticleSavingRequestStatus,
Highlight,
Label,
Page,
PageContext,
PageType,
@ -18,7 +19,7 @@ import {
searchPages,
updatePage,
} from '../../src/elastic/pages'
import { addLabelInPage } from '../../src/elastic/labels'
import { addLabelInPage, setLabelsForHighlight } from '../../src/elastic/labels'
import {
addHighlightToPage,
searchHighlights,
@ -250,4 +251,54 @@ describe('elastic api', () => {
expect(searchResults[0].id).to.eq(highlightId)
})
})
describe('setLabelsForHighlight', () => {
const page = {
id: 'testPageId',
hash: 'test set labels for highlight hash',
userId: userId,
pageType: PageType.Article,
title: 'test set labels for highlight title',
content: '<p>test</p>',
slug: 'test set labels for highlight slug',
createdAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/setting-labels-for-highlight',
state: ArticleSavingRequestStatus.Succeeded,
}
const highlightId = 'highlightId'
const label: Label = {
id: 'test label id',
name: 'test label',
color: '#07D2D1',
}
before(async () => {
// create a testing page
await createPage(page, ctx)
const highlightData: Highlight = {
patch: 'test set labels patch',
quote: 'test set labels quote',
shortId: 'test set labels shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
}
await addHighlightToPage(page.id, highlightData, ctx)
})
after(async () => {
await deletePage(page.id, ctx)
})
it('sets labels for highlights', async () => {
const result = await setLabelsForHighlight(highlightId, [label], ctx)
expect(result).to.be.true
})
})
})

View file

@ -302,6 +302,7 @@ const saveArticleReadingProgressQuery = (
updatedArticle {
id
readingProgressPercent
readAt
}
}
... on SaveArticleReadingProgressError {
@ -845,6 +846,8 @@ describe('Article API', () => {
res.body.data.saveArticleReadingProgress.updatedArticle
.readingProgressPercent
).to.eq(progress)
expect(res.body.data.saveArticleReadingProgress.updatedArticle.readAt)
.not.null
})
it('should not allow setting the reading progress lower than current progress', async () => {

View file

@ -9,11 +9,16 @@ import { Label } from '../../src/entity/label'
import { expect } from 'chai'
import 'mocha'
import { User } from '../../src/entity/user'
import { Page, PageContext } from '../../src/elastic/types'
import { Highlight, Page, PageContext } from '../../src/elastic/types'
import { getRepository } from '../../src/entity/utils'
import { getPageById } from '../../src/elastic/pages'
import { addLabelInPage } from '../../src/elastic/labels'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import {
addHighlightToPage,
getHighlightById,
} from '../../src/elastic/highlights'
import { refreshIndex } from '../../src/elastic'
describe('Labels API', () => {
const username = 'fakeUser'
@ -215,27 +220,67 @@ describe('Labels API', () => {
context('when label exists', () => {
let toDeleteLabel: Label
before(async () => {
toDeleteLabel = await createTestLabel(user, 'label4', '#ffffff')
labelId = toDeleteLabel.id
})
context('when label is not used', () => {
before(async () => {
toDeleteLabel = await createTestLabel(
user,
'label not in use',
'#ffffff'
)
labelId = toDeleteLabel.id
})
it('should delete label', async () => {
await graphqlRequest(query, authToken).expect(200)
const label = await getRepository(Label).findOneBy({ id: labelId })
expect(label).to.not.exist
it('should delete label', async () => {
await graphqlRequest(query, authToken).expect(200)
const label = await getRepository(Label).findOneBy({ id: labelId })
expect(label).not.exist
})
})
context('when a page has this label', () => {
before(async () => {
toDeleteLabel = await createTestLabel(user, 'page label', '#ffffff')
labelId = toDeleteLabel.id
await addLabelInPage(page.id, toDeleteLabel, ctx)
})
it('should update page', async () => {
await graphqlRequest(query, authToken).expect(200)
const updatedPage = await getPageById(page.id)
await refreshIndex()
expect(updatedPage?.labels).not.to.include(toDeleteLabel)
const updatedPage = await getPageById(page.id)
expect(updatedPage?.labels).not.deep.include(toDeleteLabel)
})
})
context('when a highlight has this label', () => {
const highlightId = generateFakeUuid()
before(async () => {
toDeleteLabel = await createTestLabel(
user,
'highlight label',
'#ffffff'
)
labelId = toDeleteLabel.id
const highlight: Highlight = {
id: highlightId,
patch: 'test patch',
quote: 'test quote',
shortId: 'test shortId',
userId: user.id,
createdAt: new Date(),
labels: [toDeleteLabel],
}
await addHighlightToPage(page.id, highlight, ctx)
})
it('should update highlight', async () => {
await graphqlRequest(query, authToken).expect(200)
await refreshIndex()
const updatedHighlight = await getHighlightById(highlightId)
expect(updatedHighlight?.labels).not.deep.include(toDeleteLabel)
})
})
})
@ -440,4 +485,96 @@ describe('Labels API', () => {
})
})
})
describe('Set labels for highlight', () => {
let query: string
let highlightId: string
let labelIds: string[] = []
beforeEach(() => {
query = `
mutation {
setLabelsForHighlight(
input: {
highlightId: "${highlightId}",
labelIds: [
"${labelIds[0]}",
"${labelIds[1]}"
]
}
) {
... on SetLabelsSuccess {
labels {
id
name
}
}
... on SetLabelsError {
errorCodes
}
}
}
`
})
context('when labels exists', () => {
before(async () => {
highlightId = 'highlight-id'
const highlight: Highlight = {
createdAt: new Date(),
id: highlightId,
patch: 'test patch',
quote: 'test quote',
shortId: 'test shortId',
userId: user.id,
}
await addHighlightToPage(page.id, highlight, ctx)
labelIds = [labels[0].id, labels[1].id]
})
it('should set labels for highlight', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(
res.body.data.setLabelsForHighlight.labels.map((l: any) => l.id)
).to.eql(labelIds)
})
})
context('when labels not exist', () => {
before(async () => {
highlightId = 'highlight-id-2'
const highlight: Highlight = {
createdAt: new Date(),
id: highlightId,
patch: 'test patch',
quote: 'test quote',
shortId: 'test shortId',
userId: user.id,
}
await addHighlightToPage(page.id, highlight, ctx)
labelIds = [generateFakeUuid(), generateFakeUuid()]
})
it('should return error code NOT_FOUND', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.setLabelsForHighlight.errorCodes).to.eql([
'NOT_FOUND',
])
})
})
context('when highlight not exist', () => {
before(() => {
highlightId = generateFakeUuid()
labelIds = [labels[0].id, labels[1].id]
})
it('should return error code NOT_FOUND', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.setLabelsForHighlight.errorCodes).to.eql([
'NOT_FOUND',
])
})
})
})
})

View file

@ -0,0 +1,107 @@
import { createTestUser, deleteTestUser } from '../db'
import {
generateFakeUuid,
graphqlRequest,
request,
} from '../util'
import * as chai from 'chai'
import { expect } from 'chai'
import 'mocha'
import { User } from '../../src/entity/user'
import chaiString from 'chai-string'
import {
PageContext,
} from '../../src/elastic/types'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import {
deletePage,
getPageById,
} from '../../src/elastic/pages'
chai.use(chaiString)
// INPUT
// clientRequestId?: InputMaybe<Scalars['String']>;
// contentType: Scalars['String'];
// createPageEntry?: InputMaybe<Scalars['Boolean']>;
// url: Scalars['String'];
const uploadFileRequest = async (
authToken: string,
inputUrl: string,
clientRequestId: string,
createPageEntry = true
) => {
const query = `
mutation {
uploadFileRequest(
input: {
contentType: "application/pdf",
clientRequestId: "${clientRequestId}",
createPageEntry: ${createPageEntry},
url: "${inputUrl}"
}
) {
... on ArchiveLinkSuccess {
linkId
}
... on ArchiveLinkError {
errorCodes
}
}
}
`
return graphqlRequest(query, authToken).expect(200)
}
describe('uploadFileRequest API', () => {
const username = 'fakeUser'
let authToken: string
let user: User
let ctx: PageContext
before(async () => {
// create test user and login
user = await createTestUser(username)
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
authToken = res.body.authToken
ctx = {
pubsub: createPubSubClient(),
refresh: true,
uid: user.id,
}
})
after(async () => {
await deleteTestUser(username)
})
describe('UploadFileRequest', () => {
context('when create article is true', () => {
const clientRequestId = generateFakeUuid()
after(async () => {
await deletePage(clientRequestId, ctx)
})
it('should create an article if create article is true', async () => {
const res = await uploadFileRequest(authToken, 'https://www.google.com', clientRequestId, true)
expect(res.body.data.uploadFileRequest.createdPageId).to.eql(clientRequestId)
const page = await getPageById(clientRequestId)
expect(page).to.be
})
it('should not save a file:// URL', async () => {
const res = await uploadFileRequest(authToken, 'file://foo.bar', clientRequestId, true)
expect(res.body.data.uploadFileRequest.createdPageId).to.eql(clientRequestId)
const page = await getPageById(clientRequestId)
expect(page?.url).to.startWith("https://")
})
})
})
})

View file

@ -43,6 +43,7 @@ const App = () => {
fontSize={window.fontSize ?? 18}
fontFamily={window.fontFamily ?? 'inter'}
margin={window.margin}
maxWidthPercentage={window.maxWidthPercentage}
lineHeight={window.lineHeight}
articleMutations={{
createHighlightMutation: (input) =>

View file

@ -24,9 +24,12 @@ const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) Apple
const BOT_DESKTOP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4372.0 Safari/537.36'
const NON_BOT_DESKTOP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4372.0 Safari/537.36'
const NON_BOT_HOSTS = ['bloomberg.com', 'forbes.com']
const NON_SCRIPT_HOSTS= ['medium.com']
const ALLOWED_CONTENT_TYPES = ['text/html', 'application/octet-stream', 'text/plain', 'application/pdf'];
const { parseHTML } = require('linkedom');
// Add stealth plugin to hide puppeteer usage
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
@ -46,8 +49,38 @@ const userAgentForUrl = (url) => {
return DESKTOP_USER_AGENT
};
const fetchContentWithScrapingBee = async (url) => {
const response = await axios.get('https://app.scrapingbee.com/api/v1', {
params: {
'api_key': process.env.SCRAPINGBEE_API_KEY,
'url': url,
'render_js': 'false',
'premium_proxy': 'true',
'country_code':'us'
}
})
const dom = parseHTML(response.data).document;
return { title: dom.title, domContent: dom.documentElement.outerHTML, url: url }
}
const enableJavascriptForUrl = async (url) => {
try {
const u = new URL(url);
for (const host of NON_SCRIPT_HOSTS) {
if (u.hostname.endsWith(host)) {
return false;
}
}
} catch (e) {
console.log('error getting hostname for url', url, e)
}
return true
};
// launch Puppeteer
const getBrowserPromise = (async () => {
console.log("starting with proxy url", process.env.PROXY_URL)
return puppeteer.launch({
args: [
'--allow-running-insecure-content',
@ -71,8 +104,8 @@ const getBrowserPromise = (async () => {
'--no-sandbox',
'--no-zygote',
'--use-gl=swiftshader',
'--window-size=1920,1080', // https://source.chromium.org/search?q=lang:cpp+symbol:kWindowSize&ss=chromium
],
'--window-size=1920,1080',
].filter((item) => !!item),
defaultViewport: { height: 1080, width: 1920 },
executablePath: process.env.CHROMIUM_PATH ,
headless: true,
@ -162,7 +195,6 @@ const sendCreateArticleMutation = async (userId, input) => {
'Content-Type': 'application/json',
},
});
console.log('response', response);
return response.data.data.createArticle;
};
@ -288,8 +320,15 @@ async function fetchContent(req, res) {
} else {
if (!content || !title) {
const result = await retrieveHtml(page);
title = result.title;
content = result.domContent;
if (result.isBlocked) {
console.log("PAGE IS BLOCKED", url)
const sbResult = await fetchContentWithScrapingBee(url)
title = sbResult.title
content = sbResult.domContent
} else {
title = result.title;
content = result.domContent;
}
} else {
console.log('using prefetched content and title');
console.log(content);
@ -313,20 +352,6 @@ async function fetchContent(req, res) {
logRecord.totalTime = Date.now() - functionStartTime;
logRecord.result = apiResponse.createArticle;
console.log(`parse-page`, logRecord);
// return res.send({
// url: finalUrl,
// articleSavingRequestId,
// preparedDocument: {
// document: content,
// pageInfo: {
// title,
// canonicalUrl: finalUrl,
// },
// },
// skipParsing: !content,
// timeTaken: Date.now() - functionStartTime,
// })
}
} catch (e) {
console.log('error', e)
@ -405,7 +430,11 @@ async function retrievePage(url) {
logRecord.timing = { ...logRecord.timing, browserOpened: Date.now() - functionStartTime };
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
const page = await context.newPage()
if (!enableJavascriptForUrl(url)) {
page.setJavaScriptEnabled(false)
}
await page.setUserAgent(userAgentForUrl(url));
const client = await page.target().createCDPSession();
@ -542,7 +571,7 @@ async function retrieveHtml(page) {
}
})();
}),
page.waitForTimeout(1000), //5 second timeout
page.waitForTimeout(1000),
]);
logRecord.timing = { ...logRecord.timing, pageScrolled: Date.now() - pageScrollingStart };
@ -610,6 +639,11 @@ async function retrieveHtml(page) {
}
}
});
if (document.querySelector('[data-translate="managed_checking_msg"]')) {
return 'IS_BLOCKED'
}
return document.documentElement.outerHTML;
}, iframes);
logRecord.puppeteerSuccess = true;
@ -630,6 +664,11 @@ async function retrieveHtml(page) {
};
}
}
console.log('DOM CONTENT')
console.log(domContent)
if (domContent == 'IS_BLOCKED') {
return { isBlocked: true };
}
return { domContent, title };
}

View file

@ -22,7 +22,7 @@ exports.mediumHandler = {
try {
const res = new URL(url);
res.searchParams.delete('source');
return { url: res }
return { url: res.toString() }
} catch (error) {
console.error('error prehandling medium url', error)
throw error

View file

@ -7,7 +7,7 @@
},
"dependencies": {
"@testing-library/cypress": "^8.0.2",
"cypress": "^9.5.3"
"cypress": "^10.1.0"
},
"devDependencies": {},
"volta": {

View file

@ -90,6 +90,27 @@
},
"updatedAt": {
"type": "date"
},
"labels": {
"type": "nested",
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "keyword",
"normalizer": "lowercase_normalizer"
},
"color": {
"type": "keyword"
},
"description": {
"type": "text"
},
"createdAt": {
"type": "date"
}
}
}
}
},

View file

@ -2209,7 +2209,7 @@ Readability.prototype = {
for (const element of Array.from(e.getElementsByTagName('a'))) {
if (this.isEmbed(element)) {
continue;
return;
}
// Create tweets placeholders from links
@ -2223,7 +2223,7 @@ Readability.prototype = {
tweet.innerText = 'Tweet placeholder';
tweet.className = 'tweet-placeholder';
tweet.setAttribute('data-tweet-id', match[2]);
element.parentNode.replaceChild(tweet, element);
element.parentNode.replaceChildren(tweet);
// remove all containers the tweet is nested in (if they contain the tweet only)
let tweetParent = tweet.parentElement || tweet.parentNode;
@ -2754,7 +2754,8 @@ Readability.prototype = {
(!isList && li > p) ||
(input > Math.floor(p/3)) ||
(!isList && headingDensity < 0.9 && contentLength < 25 && (img === 0 || img > 2) && !this._hasAncestorTag(node, "figure")) ||
(!isList && weight < 25 && linkDensity > 0.2) ||
// ignores link density for the links inside the .post-body div (the main content)
(!isList && weight < 25 && linkDensity > 0.2 && !(node.parentElement.className.includes("post-body") && linkDensity === 1)) ||
// some website like https://substack.com might have their custom styling of tweets
// we should omit ignoring their particular case by checking against "tweet" classname
(weight >= 25 && linkDensity > 0.5 && !(node.className === "tweet" && linkDensity === 1)) ||

View file

@ -0,0 +1,12 @@
{
"title": "Nonsense on Stilts",
"byline": "Gary Marcus",
"dir": null,
"excerpt": "No, LaMDA is not sentient. Not even slightly.",
"siteName": "The Road to AI We Can Trust",
"siteIcon": "https://substackcdn.com/icons/substack/favicon.ico",
"previewImage": "https://substackcdn.com/image/fetch/w_1200,h_600,c_limit,f_jpg,q_auto:good,fl_progressive:steep/https%3A%2F%2Fpbs.substack.com%2Fmedia%2FFVEHQukUsAAOe5i.jpg",
"publishedDate": "2022-06-12T20:43:01.000Z",
"language": "English",
"readerable": true
}

View file

@ -0,0 +1,51 @@
<DIV class="page" id="readability-page-1">
<article>
<div>
<h3> No, LaMDA is not sentient. Not even slightly. </h3>
</div>
<div dir="auto">
<div data-tweet-id="1535944902433026048" class="tweet-placeholder"></div>
<div data-tweet-id="1535979040925958144" class="tweet-placeholder"></div>
<p> Blaise Aguera y Arcas, polymath, novelist, and Google VP, has a way with words. </p>
<p> When he found himself impressed with Googles recent AI system LaMDA, he didnt just say, “Cool, it creates really neat sentences that in some ways seem contextually relevant”, he said, rather lyrically, in an interview with The Economist on Thursday, </p>
<blockquote>
<p> “I felt the ground shift under my feet … increasingly felt like I was talking to something intelligent.” </p>
</blockquote>
<p> Nonsense. Neither LaMDA nor any of its cousins (GPT-3) are remotely intelligent.<a id="footnote-anchor-1" href="#footnote-1" rel="">1</a> All they do is match patterns, draw from massive statistical databases of human language. The patterns might be cool, but language these systems utter doesnt actually mean anything at all. And it sure as hell doesnt mean that these systems are sentient. </p>
<p> Which doesnt mean that human beings cant be taken in. In our book Rebooting AI, Ernie Davis and I called this human tendency to be suckered by <em>The Gullibility Gap</em> — a pernicious, modern version of <a href="https://en.wikipedia.org/wiki/Pareidolia" rel="">pareidolia</a>, the anthromorphic bias that allows humans to see Mother Theresa in an image of a cinnamon bun. </p>
<p> Indeed, someone well-known at Google, Blake LeMoine, originally charged with studying how “safe” the system is, appears to have fallen in love with LaMDA, as if it were a family member or a colleague. (Newsflash: its not; its a spreadsheet for words.) </p>
<p> To be sentient is to be aware of yourself in the world; LaMDA simply isnt. Its just an illusion, in the grand history of ELIZA a 1965 piece of software that pretended to be a therapist (managing to fool some humans into thinking it was human), and Eugene Goostman, a wise-cracking 13-year-old-boy impersonating chatbot that won a scaled-down version of the Turing Test. None of the software in either of those systems has survived in modern efforts at “artificial general intelligence”, and I am not sure that LaMDA and its cousins will play any important role in the future of AI, either. What these systems do, no more and no less, is to put together sequences of words, but without any coherent understanding of the world behind them, like foreign language Scrabble players who use English words as point-scoring tools, without any clue about what that mean. </p>
<p> I am <em>not</em> saying that no software ever could connects its digital bits to the world, a la one reading of John Searles infamous Chinese Room thought experiment. Turn-by-turn navigations systems, for example, connect their bits to the world just fine. </p>
<p> Software like LaMDA simply doesnt; it doesnt even <em>try</em> to connect to the world at large, it just tries to be the best version of autocomplete it can be, by predicting what words best fit a given context. Roger Moore made this point beautifully a couple weeks ago, critique systems like LaMDA that are known as “language models”, and making the point that they dont understand language in the sense of relating sentences to the world, but just sequences of words to one another: </p>
<div data-tweet-id="1530809220744073216" class="tweet-placeholder"></div>
<p> §&nbsp; </p>
<p> If the media is fretting over LaMDA being sentient (and leading the public to do the same), the AI community categorically isnt. </p>
<p> We in the AI community have our differences, but pretty much all of find the notion that LaMDA might be sentient completely ridiculous. Stanford economist Erik Brynjolfsson used this great analogy: </p>
<div data-tweet-id="1536016934868725760" class="tweet-placeholder"></div>
<div data-tweet-id="1536025005883916289" class="tweet-placeholder"></div>
<p> Paul Topping reminds us that all its doing is synthesizing human responses to similar questions: </p>
<div data-tweet-id="1536085857349664769" class="tweet-placeholder"></div>
<p> Abeba Birhane, quoted at the top, pointed out the immense gap right now between media hype and public skepticism. </p>
<p> § </p>
<p> When some started wondering whether the world was going to end, LaMDA might beat an overrated 72 year old benchmark called The Turing Test I pointed to an <a href="https://www.newyorker.com/tech/annals-of-technology/what-comes-after-the-turing-test" rel="">old New Yorker article that I had written the last time gullibility exploded and Turing Test mania hit</a>, in 2014, when a program called Eugene Goostman was briefly famous, good enough to fool a few foolish judges for a few minutes. At the time, I pointed out that the test isnt particular meaningful, and that it had not stood the test of time. The public knows the test of course, but the AI community wishes it would go away; we all know that beating that test isnt meaningful. </p>
<p> Machine learning prof Tom Dietterich, never slow to needle me when he thinks I have gone too far, chimed in with full solidarity: </p>
<div data-tweet-id="1536065868156637184" class="tweet-placeholder"></div>
<p> My old New Yorker article is still worth reading, for a bit of perspective, to see how things have and havent changed. Particularly amusing in hindsight is a quote from the Kevin Warwick, organizer of the 2014 Turing-ish competition, who predicted that, “[the program Eugene] Goostmans victory is a milestone [that] would go down in history as one of the most exciting” moments in the field of artificial intelligence. </p>
<p> I guess he felt the ground shift beneath his feet, too? </p>
<p> But 8 years later I doubt most people (even in AI) have ever even heard of his program, outside of my mentioning it here. It made <em>zero</em> lasting contribution to AI. </p>
<p> Fooling people into thinking a program is intelligent is just not the same as building programs that actually are intelligent. </p>
<p> § </p>
<p> Now heres the thing. In my view, we should be <em>happy</em> that LaMDA <em>isnt</em> sentient. Imagine how creepy would be if that a system that has no friends and family pretended to talk about them? </p>
<div data-tweet-id="1536068264270278656" class="tweet-placeholder"></div>
<p> In truth, literally <em>everything</em> that the system says is bullshit. The sooner we all realize that Lamdas utterances are bullshit—just games with predictive word tools, and no real meaning (no friends, no family, no making people sad or happy or anything else) —the better off well be. </p>
<p> There are a lot of serious questions in AI, like how to make it safe, how to make it reliable, and how to make it trustworthy. </p>
<p> But there is no absolutely no reason whatever for us to waste time wondering whether anything anyone in 2022 knows how to build is sentient. It is not. </p>
<p> The sooner we can take the whole thing with a grain of salt, and realize that there is nothing to see here whatsoever, the better. </p>
<p> Enjoy the rest of your weekend, and dont fret about this for another minute :) </p>
<p> Gary Marcus </p>
<p> Epilogue: </p>
<p> Last word to philosopher poet Jag Bhalla </p>
<div data-tweet-id="1536069788266549248" class="tweet-placeholder"></div>
</div>
</article>
</DIV>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
https://garymarcus.substack.com/p/nonsense-on-stilts?sd=pf&s=r

View file

@ -0,0 +1,12 @@
{
"title": "Theodore A. Postol: Nuclear Weapons, Missile Technology, and U.S. Diplomacy — Manifold #12",
"byline": "View my complete profile",
"dir": null,
"excerpt": "Theodore A. Postol is professor emeritus of Science, Technology, and International Security at the Massachusetts Institute of Technolog...",
"siteName": null,
"siteIcon": "https://infoproc.blogspot.com/favicon.ico",
"previewImage": "https://lh3.googleusercontent.com/blogger_img_proxy/ABLy4EwrOqWiqDKUTjOKgPfAJkAH9M0VlZ8ystq9wP0nHvpdfqFpWeootDAtR3o6ZhVbEHf76IEUuztQuEVauUdXxB_jbiK4qOsTNVH3qqz0TtrglbYoOw=w1200-h630-n-k-no-nu",
"publishedDate": "2022-05-18T16:00:00.000Z",
"language": "English",
"readerable": true
}

View file

@ -0,0 +1,59 @@
<DIV class="page" id="readability-page-1">
<div id="Blog1" data-version="1" itemprop="blogPost" itemscope="itemscope" itemtype="http://schema.org/BlogPosting">
<meta content="https://i.ytimg.com/vi/ivY8uak20Mc/hqdefault.jpg" itemprop="image_url">
<meta content="5880610" itemprop="blogId">
<meta content="6577916529543403529" itemprop="postId">
<div id="post-body-6577916529543403529" itemprop="description articleBody">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="allowfullscreen" frameborder="0" height="315" src="https://www.youtube.com/embed/ivY8uak20Mc" title="YouTube video player" width="560"></iframe>
<p>Theodore A. Postol is professor emeritus of Science, Technology, and International Security at the Massachusetts Institute of Technology. He is widely known as an expert on nuclear weapons and missile technology.</p>
<p> Educated in physics and nuclear engineering at MIT, he was a researcher at Argonne National Lab, worked at the Congressional Office of Technology Assessment, and was scientific advisor to the Chief of Naval Operations.&nbsp; </p>
<p> After leaving the Pentagon, Postol helped to build a program at Stanford University to train mid-career scientists to study weapons technology in relation to defense and arms control policy.&nbsp; </p>
<p> He has received numerous awards, including the Leo Szilard Prize from the American Physical Society for "incisive technical analysis of national security issues that [have] been vital for informing the public policy debate", the Norbert Wiener Award from Computer Professionals for Social Responsibility for "uncovering numerous and important false claims about missile defenses", and the Richard L. Garwin Award "that recognizes an individual who, through exceptional achievement in science and technology, has made an outstanding contribution toward the benefit of mankind."&nbsp; </p>
<p> Steve and Ted discuss:&nbsp; </p>
<p> 0:00 Introduction&nbsp; </p>
<p> 2:02 Early life in Brooklyn, education at MIT, work at the Pentagon&nbsp; </p>
<p> 20:27 Reagans “Star Wars” defense plan&nbsp; </p>
<p> 28:26 U.S. influence on Russia and Chinas second-strike capabilities&nbsp; </p>
<p> 54:41 Missile defense: vs nuclear weapons, scuds, anti-ship missiles (aircraft carriers), hypersonics&nbsp; </p>
<p> 1:11:42 Nuclear escalation and the status of mutually assured destruction&nbsp; </p>
<p> 1:32:24 Analysis of claims the Syrian government used chemical agents against their own people&nbsp; </p>
<p> 1:44:45 Media skepticism&nbsp; </p>
<p> Resources:&nbsp; </p>
<p> Theodore Postol at MIT&nbsp; </p>
<div>
<p><a href="https://sts-program.mit.edu/people/emeriti-faculty/theodore-postol/">https://sts-program.mit.edu/people/emeriti-faculty/theodore-postol/</a>&nbsp; </p>
</div>
<p> A Flawed and Dangerous US Missile Defense Plan, G. Lewis and T. Postol, Arms Control Today&nbsp; </p>
<div>
<p><a href="https://www.armscontrol.org/act/2010-05/flawed-dangerous-us-missile-defense-plan">https://www.armscontrol.org/act/2010-05/flawed-dangerous-us-missile-defense-plan</a>&nbsp; </p>
</div>
<p> Review Cites Flaws in US Antimissile Program, NY Times May 17 2010&nbsp; </p>
<div>
<p><a href="https://www.nytimes.com/2010/05/18/world/18missile.html">https://www.nytimes.com/2010/05/18/world/18missile.html</a>&nbsp; </p>
</div>
<p> Improving US Ballistic Missile Defense Policy, G. Lewis and F. von Hippel, Arms Control Today, May 2018&nbsp; </p>
<div>
<p><a href="https://sgs.princeton.edu/sites/default/files/2019-10/lewis-vonhippel-2018.pdf">https://sgs.princeton.edu/sites/default/files/2019-10/lewis-vonhippel-2018.pdf</a>&nbsp; </p>
</div>
<p> Whose Sarin? by Seymour Hersh (2013)&nbsp; </p>
<div>
<p><a href="https://www.lrb.co.uk/the-paper/v35/n24/seymour-m.-hersh/whose-sarin">https://www.lrb.co.uk/the-paper/v35/n24/seymour-m.-hersh/whose-sarin</a>
</p>
</div>
<p>Here is an excerpt from the transcript:</p>
<blockquote> Ted Postol: ... So, you've got to listen to Putin's voice dispassionately. And when you listen to him, he makes it clear numerous times, numerous times that he doesn't think American missile defense is a worth anything, but he also is worried about an American president who might believe otherwise, and who might take steps against Russia, that would then lead to an action-reaction cycle that would get us, get us all killed.&nbsp; </blockquote>
<blockquote> In other words, he's not just worried about the system, whether it can work, he's worried about American political leadership and what they think, or if they think, or if they know. And that was, you know, I was very receptive to understanding that because that's exactly what I went through, you know, 30 years earlier when I was at the Pentagon, looking at this dog of a missile defense.&nbsp; </blockquote>
<blockquote> And so, the Chinese look at this, they know the Americans are lying to them all the time. I could give you a good story about South Korea and the way we lied to the South Koreans and lied to the Chinese.&nbsp; </blockquote>
<blockquote> I was really furious with that. That was under Secretary of State Hillary Clinton. And my view is...&nbsp; </blockquote>
<blockquote> Steve: <a href="https://www.globalasia.org/v11no3/feature/illusion-of-missile-defense-why-thaad-will-not-protect-south-korea_theodore-a-postol-george-n-lewis">THAAD?</a>&nbsp; </blockquote>
<blockquote> Ted Postol: THAAD, right. <a href="https://thediplomat.com/2021/05/upgrading-south-korean-thaad/">THAAD in South Korea</a>.&nbsp; </blockquote>
<blockquote> And my view is if you're lying to an ally and you're lying, you know, I have very good friends. I'm very, very proud to say I have some very good friends who are high-level diplomats, and I've asked every one of them, would you lie in a negotiation? And every one of them has said, no. In other words, your credibility depends on your honesty. You might not say something that, you know, could be relevant to a negotiation relevant to your adversary's thinking, but you would never lie because your credibility will, you'll never be believed again. That's their view of this.&nbsp; </blockquote>
<blockquote> And here we were under Hillary Clinton lying to an ally and lying to the Chinese, who I knew through my personal contacts, understood that we were lying to them. I know from personal contacts with the Chinese.&nbsp;&nbsp; </blockquote>
<blockquote> So, how do you expect people to treat you when they know you're a liar? To me, it's just simple human relations. And, and I now understand that because I have friends who are both diplomats and soldiers, and I know, if you have to lie to make a point there's something wrong and you're, you're jeopardizing your credibility with other professionals if, if you do that.&nbsp; </blockquote>
<blockquote> So, we should not be surprised that the Chinese are increasing their forces.&nbsp; </blockquote>
<blockquote> And when Putin marched out this horrifying Poseidon underwater torpedo, could potentially carry a hundred megaton warhead. It's nuclear-powered. It can travel at some very high speed, 50, 60 knots or more, and then it can go quiet, sneak into a Harbor, know coastal Harbor and detonate underwater, and destroy out to 30 or 40 kilometers, a complete area, urban area. And he has this weapon. He made it obvious that he had it. He showed plans for it.&nbsp; </blockquote>
<blockquote> Ted Postol: Well, what he was doing is he was saying to an American president who knows nothing. All right, assuming that the president knows nothing, that your missile defenses will not do anything about this weapon. That's what he did it for. He was an insurance policy toward bad decision-making by American political leadership. That's why he built that weapon. That's why he ordered that weapon built.&nbsp; </blockquote>
<blockquote> So not because, I mean, he may be a monster. That's another issue, but it's not because he was a monster, it's because he made a strategic calculation that that kind of weapon would cause any person, even if they were totally without knowledge and thought of how missile defense could work, to understand that you will not escape retribution if you attack Russia. That's why that weapon was built. </blockquote>
</div>
</div>
</DIV>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
https://infoproc.blogspot.com/2022/05/theodore-postol-nuclear-weapons-missile.html

View file

@ -1,8 +1,8 @@
import { styled } from '../tokens/stitches.config'
import { useState } from 'react'
import Checkbox from './Checkbox'
import { Box, HStack, VStack } from './LayoutPrimitives'
import { StyledText } from './StyledText'
import { HStack, VStack } from './LayoutPrimitives'
import { Label } from '@radix-ui/react-dropdown-menu'
export interface FormInputProps {
name: string
@ -16,6 +16,7 @@ export interface FormInputProps {
required?: boolean
css?: any
options?: string[]
min?: any
}
export const FormInput = styled('input', {
@ -42,29 +43,42 @@ export function GeneralFormInput(props: FormInputProps): JSX.Element {
const [input, setInput] = useState<FormInputProps>(props)
if (props.type === 'checkbox') {
const StyledLabel = styled(Label, {
color: '$grayTextContrast',
fontSize: 13,
padding: '5px 10px',
cursor: 'default',
})
return (
<VStack>
{input.options?.map((label, index) => (
<HStack key={index}>
<StyledText>{label}</StyledText>
<Box css={{ padding: '10px 0 0 10px' }}>
<Checkbox
key={index}
checked={input.value[index]}
setChecked={(arg) => {
input.value[index] = arg
setInput(input)
props.onChange &&
props.onChange(
input.options?.filter((_, i) => input.value[i])
)
}}
></Checkbox>
</Box>
<HStack key={index} alignment='center'>
<Checkbox
key={index}
checked={input.value[index]}
setChecked={(arg) => {
input.value[index] = arg
setInput(input)
props.onChange &&
props.onChange(
input.options?.filter((_, i) => input.value[i])
)
}}
></Checkbox>
<StyledLabel>{label}</StyledLabel>
</HStack>
))}
</VStack>
)
} else if (props.type === 'select') {
return (
<select onChange={input.onChange}>
{input.options?.map((label, index) => (
<option key={index} value={label}>{label}</option>
))}
</select>
)
} else {
return (
<FormInput
@ -96,6 +110,7 @@ export function GeneralFormInput(props: FormInputProps): JSX.Element {
},
}}
name={input.name}
min={input.min}
/>
)
}

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