mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
commit
c2007f4337
251 changed files with 9710 additions and 2020 deletions
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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]()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ public enum UserDefaultKey: String {
|
|||
case preferredWebFont
|
||||
case preferredWebFontSize
|
||||
case preferredWebLineSpacing
|
||||
case preferredWebMargin
|
||||
case preferredWebMaxWidthPercentage
|
||||
case prefersHighContrastWebFont
|
||||
case userHasDeniedPushPrimer
|
||||
case firebasePushToken
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ public struct FeedCard: View {
|
|||
}
|
||||
}
|
||||
|
||||
if item.sortedLabels.count > 0 {
|
||||
if item.hasLabels {
|
||||
// Category Labels
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Lora-Bold.ttf
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Lora-Bold.ttf
Normal file
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Lora-Italic.ttf
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Lora-Italic.ttf
Normal file
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Lora-Regular.ttf
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Lora-Regular.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Roboto-Bold.ttf
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Resources/Fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
|
|
|
|||
50
apple/OmnivoreKit/Sources/Views/SyncingIcon.swift
Normal file
50
apple/OmnivoreKit/Sources/Views/SyncingIcon.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()})}}();
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
107
packages/api/test/resolvers/upload_file_request.test.ts
Normal file
107
packages/api/test/resolvers/upload_file_request.test.ts
Normal 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://")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@testing-library/cypress": "^8.0.2",
|
||||
"cypress": "^9.5.3"
|
||||
"cypress": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"volta": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)) ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 Google’s recent AI system LaMDA, he didn’t 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 doesn’t actually mean anything at all. And it sure as hell doesn’t mean that these systems are sentient. </p>
|
||||
<p> Which doesn’t mean that human beings can’t 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: it’s not; it’s a spreadsheet for words.) </p>
|
||||
<p> To be sentient is to be aware of yourself in the world; LaMDA simply isn’t. It’s 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 Searle’s 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 doesn’t; it doesn’t 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 don’t 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> § </p>
|
||||
<p> If the media is fretting over LaMDA being sentient (and leading the public to do the same), the AI community categorically isn’t. </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 it’s 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 isn’t 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 isn’t 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 haven’t 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] Goostman’s 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 here’s the thing. In my view, we should be <em>happy</em> that LaMDA <em>isn’t</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 Lamda’s 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 we’ll 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 don’t 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>
|
||||
1619
packages/readabilityjs/test/test-pages/garymarcus/source.html
Normal file
1619
packages/readabilityjs/test/test-pages/garymarcus/source.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
https://garymarcus.substack.com/p/nonsense-on-stilts?sd=pf&s=r
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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. </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. </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." </p>
|
||||
<p> Steve and Ted discuss: </p>
|
||||
<p> 0:00 Introduction </p>
|
||||
<p> 2:02 Early life in Brooklyn, education at MIT, work at the Pentagon </p>
|
||||
<p> 20:27 Reagan’s “Star Wars” defense plan </p>
|
||||
<p> 28:26 U.S. influence on Russia and China’s second-strike capabilities </p>
|
||||
<p> 54:41 Missile defense: vs nuclear weapons, scuds, anti-ship missiles (aircraft carriers), hypersonics </p>
|
||||
<p> 1:11:42 Nuclear escalation and the status of mutually assured destruction </p>
|
||||
<p> 1:32:24 Analysis of claims the Syrian government used chemical agents against their own people </p>
|
||||
<p> 1:44:45 Media skepticism </p>
|
||||
<p> Resources: </p>
|
||||
<p> Theodore Postol at MIT </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> </p>
|
||||
</div>
|
||||
<p> A Flawed and Dangerous US Missile Defense Plan, G. Lewis and T. Postol, Arms Control Today </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> </p>
|
||||
</div>
|
||||
<p> Review Cites Flaws in US Antimissile Program, NY Times May 17 2010 </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> </p>
|
||||
</div>
|
||||
<p> Improving US Ballistic Missile Defense Policy, G. Lewis and F. von Hippel, Arms Control Today, May 2018 </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> </p>
|
||||
</div>
|
||||
<p> Whose Sarin? by Seymour Hersh (2013) </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. </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. </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. </blockquote>
|
||||
<blockquote> I was really furious with that. That was under Secretary of State Hillary Clinton. And my view is... </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> </blockquote>
|
||||
<blockquote> Ted Postol: THAAD, right. <a href="https://thediplomat.com/2021/05/upgrading-south-korean-thaad/">THAAD in South Korea</a>. </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. </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. </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. </blockquote>
|
||||
<blockquote> So, we should not be surprised that the Chinese are increasing their forces. </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. </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. </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>
|
||||
3548
packages/readabilityjs/test/test-pages/infoproc/source.html
Normal file
3548
packages/readabilityjs/test/test-pages/infoproc/source.html
Normal file
File diff suppressed because one or more lines are too long
1
packages/readabilityjs/test/test-pages/infoproc/url.txt
Normal file
1
packages/readabilityjs/test/test-pages/infoproc/url.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
https://infoproc.blogspot.com/2022/05/theodore-postol-nuclear-weapons-missile.html
|
||||
|
|
@ -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
Loading…
Reference in a new issue