mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge commit '134264940f208a47dde8489932e6dd6831110f81' into OMN-134
This commit is contained in:
commit
1acaf40a4d
108 changed files with 9281 additions and 670 deletions
10
.github/ISSUE_TEMPLATE/blank-template.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/blank-template.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: Blank Template
|
||||
about: Should be used for most issues
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
28
.github/ISSUE_TEMPLATE/qa-run-template.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/qa-run-template.md
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
name: QA Run Template
|
||||
about: Use this template when doing QA
|
||||
title: ''
|
||||
labels: QA
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
There are three QA focus areas in Omnivore:
|
||||
|
||||
1. Saving
|
||||
2. Library Management
|
||||
3. Reader Controls
|
||||
|
||||
|
||||
## Saving
|
||||
|
||||
Omnivore has three APIs involved in saving that should all be tested. URLs, Files, and Pages (captured content from the browser or the mobile) app can all be saved
|
||||
|
||||
## Library Management
|
||||
|
||||
The main library in `/home` has functionality for searching and organizing content.
|
||||
|
||||
|
||||
## Reader functionality
|
||||
|
||||
The reader has functionality for highlighting, annotating, and saving reading progress
|
||||
4
Makefile
4
Makefile
|
|
@ -6,3 +6,7 @@ apple_graphql_gen:
|
|||
|
||||
apple_extension_gen:
|
||||
$(MAKE) -C apple extension_gen
|
||||
|
||||
apple_webview_gen:
|
||||
yarn workspace @omnivore/appreader build
|
||||
cp packages/appreader/build/bundle.js apple/OmnivoreKit/Sources/Views/Resources/bundle.js
|
||||
|
|
|
|||
|
|
@ -1463,7 +1463,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1495,7 +1495,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.4.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
|
@ -1534,7 +1534,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.4.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
|
|
@ -1696,7 +1696,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
|
||||
PRODUCT_NAME = ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
@ -1750,7 +1750,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1778,7 +1778,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
|
||||
PRODUCT_NAME = ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil
|
||||
annotation: nil,
|
||||
createdByMe: true
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -94,7 +95,8 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil
|
||||
annotation: nil,
|
||||
createdByMe: true
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,19 +14,21 @@ struct FeedCardNavigationLink: View {
|
|||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(
|
||||
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
|
||||
tag: item,
|
||||
selection: $selectedLinkItem
|
||||
) {
|
||||
EmptyView()
|
||||
ZStack {
|
||||
NavigationLink(
|
||||
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
|
||||
tag: item,
|
||||
selection: $selectedLinkItem
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
|
||||
}
|
||||
FeedCard(item: item)
|
||||
}
|
||||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
|
||||
}
|
||||
FeedCard(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ struct LinkItemDetailView: View {
|
|||
#if os(iOS)
|
||||
if viewModel.item.isPDF {
|
||||
fixedNavBarReader
|
||||
} else if FeatureFlag.useLocalWebView {
|
||||
WebReaderContainerView(item: viewModel.item, homeFeedViewModel: viewModel.homeFeedViewModel)
|
||||
} else {
|
||||
hidingNavBarReader
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ final class ProfileContainerViewModel: ObservableObject {
|
|||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
var appVersionString: String {
|
||||
if let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
|
||||
return "Omnivore Version \(appVersion)"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func loadProfileData(dataService: DataService) {
|
||||
dataService.viewerPublisher().sink(
|
||||
receiveCompletion: { _ in },
|
||||
|
|
@ -81,7 +89,7 @@ struct ProfileView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
Section {
|
||||
Section(footer: Text(viewModel.appVersionString)) {
|
||||
if FeatureFlag.showAccountDeletion {
|
||||
NavigationLink(
|
||||
destination: ManageAccountView(handleAccountDeletion: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
import WebKit
|
||||
|
||||
struct WebReader: UIViewRepresentable {
|
||||
let articleContent: ArticleContent
|
||||
let item: FeedItem
|
||||
let openLinkAction: (URL) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityRatioUpdater: (Double) -> Void
|
||||
|
||||
@Binding var increaseFontActionID: UUID?
|
||||
@Binding var decreaseFontActionID: UUID?
|
||||
@Binding var annotationSaveTransactionID: UUID?
|
||||
@Binding var annotation: String
|
||||
|
||||
func makeCoordinator() -> WebReaderCoordinator {
|
||||
WebReaderCoordinator()
|
||||
}
|
||||
|
||||
func fontSize() -> Int {
|
||||
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue)
|
||||
return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WebViewManager.shared()
|
||||
let contentController = WKUserContentController()
|
||||
|
||||
webView.loadHTMLString(
|
||||
WebReaderContent(
|
||||
articleContent: articleContent,
|
||||
item: item,
|
||||
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
|
||||
fontSize: fontSize()
|
||||
)
|
||||
.styledContent,
|
||||
baseURL: ViewsPackage.bundleURL
|
||||
)
|
||||
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.configuration.userContentController = contentController
|
||||
webView.scrollView.delegate = context.coordinator
|
||||
webView.scrollView.contentInset.top = readerViewNavBarHeight
|
||||
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
|
||||
|
||||
webView.configuration.userContentController.removeAllScriptMessageHandlers()
|
||||
|
||||
for action in WebViewAction.allCases {
|
||||
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
|
||||
}
|
||||
|
||||
webView.configuration.userContentController.add(webView, name: "viewerAction")
|
||||
|
||||
webView.configuration.userContentController.addScriptMessageHandler(context.coordinator, contentWorld: .page, name: "articleAction")
|
||||
|
||||
context.coordinator.linkHandler = openLinkAction
|
||||
context.coordinator.webViewActionHandler = webViewActionHandler
|
||||
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
|
||||
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
|
||||
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
|
||||
(webView as? WebView)?.saveAnnotation(annotation: annotation)
|
||||
}
|
||||
|
||||
if increaseFontActionID != context.coordinator.previousIncreaseFontActionID {
|
||||
context.coordinator.previousIncreaseFontActionID = increaseFontActionID
|
||||
(webView as? WebView)?.increaseFontSize()
|
||||
}
|
||||
|
||||
if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID {
|
||||
context.coordinator.previousDecreaseFontActionID = decreaseFontActionID
|
||||
(webView as? WebView)?.decreaseFontSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
import Combine
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
import WebKit
|
||||
|
||||
struct WebReaderContainerView: View {
|
||||
let item: FeedItem
|
||||
let homeFeedViewModel: HomeFeedViewModel
|
||||
|
||||
@State private var showFontSizePopover = false
|
||||
@State var showHighlightAnnotationModal = false
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var showOverlay = true
|
||||
@State var increaseFontActionID: UUID?
|
||||
@State var decreaseFontActionID: UUID?
|
||||
@State var annotationSaveTransactionID: UUID?
|
||||
@State var annotation = String()
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
@StateObject var viewModel = WebReaderViewModel()
|
||||
|
||||
var fontAdjustmentPopoverView: some View {
|
||||
FontSizeAdjustmentPopoverView(
|
||||
increaseFontAction: { increaseFontActionID = UUID() },
|
||||
decreaseFontAction: { decreaseFontActionID = UUID() }
|
||||
)
|
||||
}
|
||||
|
||||
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
|
||||
if message.name == WebViewAction.readingProgressUpdate.rawValue {
|
||||
let messageBody = message.body as? [String: Double]
|
||||
|
||||
if let messageBody = messageBody, let progress = messageBody["progress"] {
|
||||
homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress))
|
||||
}
|
||||
}
|
||||
|
||||
if let replyHandler = replyHandler {
|
||||
viewModel.webViewActionWithReplyHandler(
|
||||
message: message,
|
||||
replyHandler: replyHandler,
|
||||
dataService: dataService
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if message.name == WebViewAction.highlightAction.rawValue {
|
||||
handleHighlightAction(message: message)
|
||||
}
|
||||
|
||||
if message.name == WebViewAction.readingProgressUpdate.rawValue {
|
||||
guard let messageBody = message.body as? [String: Double] else { return }
|
||||
guard let progress = messageBody["progress"] else { return }
|
||||
homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
guard let messageBody = message.body as? [String: String] else { return }
|
||||
guard let actionID = messageBody["actionID"] else { return }
|
||||
|
||||
switch actionID {
|
||||
case "annotate":
|
||||
annotation = messageBody["annotation"] ?? ""
|
||||
showHighlightAnnotationModal = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var navBariOS14: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
action: { self.presentationMode.wrappedValue.dismiss() },
|
||||
label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.font(.appTitleTwo)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 12.0, *)
|
||||
@available(iOS 15.0, *)
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
action: { self.presentationMode.wrappedValue.dismiss() },
|
||||
label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.font(.appTitleTwo)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: {
|
||||
homeFeedViewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
linkId: item.id,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
},
|
||||
label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: { showDeleteConfirmation = true },
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image.profile
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id)
|
||||
}
|
||||
Button("Cancel", role: .cancel, action: {})
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let articleContent = viewModel.articleContent {
|
||||
WebReader(
|
||||
articleContent: articleContent,
|
||||
item: item,
|
||||
openLinkAction: {
|
||||
#if os(macOS)
|
||||
NSWorkspace.shared.open($0)
|
||||
#elseif os(iOS)
|
||||
safariWebLink = SafariWebLink(id: UUID(), url: $0)
|
||||
#endif
|
||||
},
|
||||
webViewActionHandler: webViewActionHandler,
|
||||
navBarVisibilityRatioUpdater: {
|
||||
if $0 < 1 {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
navBarVisibilityRatio = $0
|
||||
},
|
||||
increaseFontActionID: $increaseFontActionID,
|
||||
decreaseFontActionID: $decreaseFontActionID,
|
||||
annotationSaveTransactionID: $annotationSaveTransactionID,
|
||||
annotation: $annotation
|
||||
)
|
||||
.overlay(
|
||||
Group {
|
||||
if showOverlay {
|
||||
Color.systemBackground
|
||||
.transition(.opacity)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
|
||||
withAnimation(.linear(duration: 0.2)) {
|
||||
showOverlay = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.sheet(item: $safariWebLink) {
|
||||
SafariView(url: $0.url)
|
||||
}
|
||||
.sheet(isPresented: $showHighlightAnnotationModal) {
|
||||
HighlightAnnotationSheet(
|
||||
annotation: $annotation,
|
||||
onSave: {
|
||||
annotationSaveTransactionID = UUID()
|
||||
showHighlightAnnotationModal = false
|
||||
},
|
||||
onCancel: {
|
||||
showHighlightAnnotationModal = false
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if !viewModel.isLoading {
|
||||
viewModel.loadContent(dataService: dataService, slug: item.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
if showFontSizePopover {
|
||||
VStack {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.frame(height: LinkItemDetailView.navBarHeight)
|
||||
HStack {
|
||||
Spacer()
|
||||
fontAdjustmentPopoverView
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
.padding(.trailing, 44)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.background(
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if #available(iOS 15.0, *) {
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
navBariOS14
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
}.onDisappear {
|
||||
// Clear the shared webview content when exiting
|
||||
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
struct WebReaderContent {
|
||||
let textFontSize: Int
|
||||
let articleContent: ArticleContent
|
||||
let item: FeedItem
|
||||
let themeKey: String
|
||||
|
||||
init(
|
||||
articleContent: ArticleContent,
|
||||
item: FeedItem,
|
||||
isDark: Bool,
|
||||
fontSize: Int
|
||||
) {
|
||||
self.textFontSize = fontSize
|
||||
self.articleContent = articleContent
|
||||
self.item = item
|
||||
self.themeKey = isDark ? "Gray" : "LightGray"
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
var styledContent: String {
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
<style>
|
||||
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div id='_omnivore-htmlContent' style="display: none;">
|
||||
\(articleContent.htmlContent)
|
||||
</div>
|
||||
<div id='_omnivore-title' style="display: none;">
|
||||
\(item.title)
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
window.omnivoreEnv = {
|
||||
"NEXT_PUBLIC_APP_ENV": "prod",
|
||||
"NEXT_PUBLIC_BASE_URL": "unset",
|
||||
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
|
||||
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
|
||||
}
|
||||
|
||||
window.omnivoreArticle = {
|
||||
id: "\(item.id)",
|
||||
linkId: "\(item.id)",
|
||||
slug: "\(item.slug)",
|
||||
createdAt: new Date().toISOString(),
|
||||
savedAt: new Date().toISOString(),
|
||||
url: `\(item.pageURLString)`,
|
||||
title: document.getElementById('_omnivore-title').innerHTML,
|
||||
content: document.getElementById('_omnivore-htmlContent').innerHTML,
|
||||
originalArticleUrl: "\(item.pageURLString)",
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: \(item.readingProgress),
|
||||
readingProgressAnchorIndex: \(item.readingProgressAnchor),
|
||||
highlights: \(articleContent.highlightsJSONString),
|
||||
}
|
||||
|
||||
window.fontSize = \(textFontSize)
|
||||
window.localStorage.setItem("theme", "\(themeKey)")
|
||||
</script>
|
||||
<script src="bundle.js"></script>
|
||||
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
|
||||
<script src="mathjax.js" id="MathJax-script"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import Combine
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Utils
|
||||
import Views
|
||||
import WebKit
|
||||
|
||||
typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
|
||||
|
||||
final class WebReaderCoordinator: NSObject {
|
||||
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void = { _, _ in }
|
||||
var linkHandler: (URL) -> Void = { _ in }
|
||||
var needsReload = true
|
||||
var lastSavedAnnotationID: UUID?
|
||||
var previousIncreaseFontActionID: UUID?
|
||||
var previousDecreaseFontActionID: UUID?
|
||||
var updateNavBarVisibilityRatio: (Double) -> Void = { _ in }
|
||||
private var yOffsetAtStartOfDrag: Double?
|
||||
private var lastYOffset: Double = 0
|
||||
private var hasDragged = false
|
||||
private var isNavBarHidden = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
var navBarVisibilityRatio: Double = 1.0 {
|
||||
didSet {
|
||||
isNavBarHidden = navBarVisibilityRatio == 0
|
||||
updateNavBarVisibilityRatio(navBarVisibilityRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WebReaderCoordinator: WKScriptMessageHandler {
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
webViewActionHandler(message, nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension WebReaderCoordinator: WKScriptMessageHandlerWithReply {
|
||||
func userContentController(
|
||||
_: WKUserContentController,
|
||||
didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void
|
||||
) {
|
||||
webViewActionHandler(message, replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension WebReaderCoordinator: WKNavigationDelegate {
|
||||
// swiftlint:disable:next line_length
|
||||
func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
if let linkURL = navigationAction.request.url {
|
||||
linkHandler(linkURL)
|
||||
}
|
||||
decisionHandler(.cancel)
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
||||
#if os(iOS)
|
||||
webView.isOpaque = true
|
||||
webView.backgroundColor = .systemBackground
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension WebReaderCoordinator: UIScrollViewDelegate {
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
hasDragged = true
|
||||
yOffsetAtStartOfDrag = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard hasDragged else { return }
|
||||
|
||||
let yOffset = scrollView.contentOffset.y
|
||||
|
||||
if yOffset == 0 {
|
||||
scrollView.contentInset.top = readerViewNavBarHeight
|
||||
navBarVisibilityRatio = 1
|
||||
return
|
||||
}
|
||||
|
||||
if yOffset < 0 {
|
||||
navBarVisibilityRatio = 1
|
||||
scrollView.contentInset.top = readerViewNavBarHeight
|
||||
return
|
||||
}
|
||||
|
||||
if yOffset < readerViewNavBarHeight {
|
||||
let isScrollingUp = yOffsetAtStartOfDrag ?? 0 > yOffset
|
||||
navBarVisibilityRatio = isScrollingUp || yOffset < 0 ? 1 : min(1, 1 - (yOffset / readerViewNavBarHeight))
|
||||
scrollView.contentInset.top = navBarVisibilityRatio * readerViewNavBarHeight
|
||||
return
|
||||
}
|
||||
|
||||
guard let yOffsetAtStartOfDrag = yOffsetAtStartOfDrag else { return }
|
||||
|
||||
if yOffset > yOffsetAtStartOfDrag, !isNavBarHidden {
|
||||
let translation = yOffset - yOffsetAtStartOfDrag
|
||||
let ratio = translation < readerViewNavBarHeight ? 1 - (translation / readerViewNavBarHeight) : 0
|
||||
navBarVisibilityRatio = min(ratio, 1)
|
||||
scrollView.contentInset.top = navBarVisibilityRatio * readerViewNavBarHeight
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if decelerate, scrollView.contentOffset.y + scrollView.contentInset.top < (yOffsetAtStartOfDrag ?? 0) {
|
||||
scrollView.contentInset.top = readerViewNavBarHeight
|
||||
navBarVisibilityRatio = 1
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
scrollView.contentInset.top = readerViewNavBarHeight
|
||||
navBarVisibilityRatio = 1
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import Combine
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct SafariWebLink: Identifiable {
|
||||
let id: UUID
|
||||
let url: URL
|
||||
}
|
||||
|
||||
final class WebReaderViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var articleContent: ArticleContent?
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
func loadContent(dataService: DataService, slug: String) {
|
||||
isLoading = true
|
||||
|
||||
guard let viewer = dataService.currentViewer else { return }
|
||||
|
||||
dataService.articleContentPublisher(username: viewer.username, slug: slug).sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case .failure = completion else { return }
|
||||
self?.isLoading = false
|
||||
},
|
||||
receiveValue: { [weak self] articleContent in
|
||||
self?.articleContent = articleContent
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func createHighlight(
|
||||
messageBody: [String: Any],
|
||||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.createHighlightPublisher(
|
||||
shortId: messageBody["shortId"] as? String ?? "",
|
||||
highlightID: messageBody["id"] as? String ?? "",
|
||||
quote: messageBody["quote"] as? String ?? "",
|
||||
patch: messageBody["patch"] as? String ?? "",
|
||||
articleId: messageBody["articleId"] as? String ?? ""
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func deleteHighlight(
|
||||
messageBody: [String: Any],
|
||||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.deleteHighlightPublisher(
|
||||
highlightId: messageBody["highlightId"] as? String ?? ""
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func mergeHighlight(
|
||||
messageBody: [String: Any],
|
||||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.mergeHighlightPublisher(
|
||||
shortId: messageBody["shortId"] as? String ?? "",
|
||||
highlightID: messageBody["id"] as? String ?? "",
|
||||
quote: messageBody["quote"] as? String ?? "",
|
||||
patch: messageBody["patch"] as? String ?? "",
|
||||
articleId: messageBody["articleId"] as? String ?? "",
|
||||
overlapHighlightIdList: messageBody["overlapHighlightIdList"] as? [String] ?? []
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func updateHighlight(
|
||||
messageBody: [String: Any],
|
||||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.updateHighlightAttributesPublisher(
|
||||
highlightID: messageBody["highlightId"] as? String ?? "",
|
||||
annotation: messageBody["annotation"] as? String ?? "",
|
||||
sharedAt: nil
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func updateReadingProgress(
|
||||
messageBody: [String: Any],
|
||||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
let itemID = messageBody["id"] as? String
|
||||
let readingProgress = messageBody["readingProgressPercent"] as? Double
|
||||
let anchorIndex = messageBody["readingProgressAnchorIndex"] as? Int
|
||||
|
||||
guard let itemID = itemID, let readingProgress = readingProgress, let anchorIndex = anchorIndex else {
|
||||
replyHandler(["result": false], nil)
|
||||
return
|
||||
}
|
||||
|
||||
dataService.updateArticleReadingProgressPublisher(
|
||||
itemID: itemID,
|
||||
readingProgress: readingProgress,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func webViewActionWithReplyHandler(
|
||||
message: WKScriptMessage,
|
||||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
guard let messageBody = message.body as? [String: Any] else { return }
|
||||
guard let actionID = messageBody["actionID"] as? String else { return }
|
||||
|
||||
switch actionID {
|
||||
case "deleteHighlight":
|
||||
deleteHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
|
||||
case "createHighlight":
|
||||
createHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
|
||||
case "mergeHighlight":
|
||||
mergeHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
|
||||
case "updateHighlight":
|
||||
updateHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
|
||||
case "articleReadingProgress":
|
||||
updateReadingProgress(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
|
||||
default:
|
||||
replyHandler(nil, "Unknown actionID: \(actionID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,11 @@ private let prodBaseURL = "https://api-prod.omnivore.app"
|
|||
|
||||
private let devWebURL = "https://web-dev.omnivore.app"
|
||||
private let demoWebURL = "https://demo.omnivore.app"
|
||||
private let prodWebURL = "https://web-prod.omnivore.app"
|
||||
private let prodWebURL = "https://omnivore.app"
|
||||
|
||||
private let devHighlightsServerURL = "https://highlights-dev.omnivore.app"
|
||||
private let demoHighlightsServerURL = "https://highlights-demo.omnivore.app"
|
||||
private let prodHighlightsServerURL = "https://highlights.omnivore.app"
|
||||
|
||||
public extension AppEnvironment {
|
||||
var graphqlPath: String {
|
||||
|
|
@ -59,4 +63,17 @@ public extension AppEnvironment {
|
|||
return URL(string: "http://localhost:3000")!
|
||||
}
|
||||
}
|
||||
|
||||
var highlightsServerBaseURL: URL {
|
||||
switch self {
|
||||
case .dev:
|
||||
return URL(string: devHighlightsServerURL)!
|
||||
case .demo:
|
||||
return URL(string: demoHighlightsServerURL)!
|
||||
case .prod:
|
||||
return URL(string: prodHighlightsServerURL)!
|
||||
case .test, .local:
|
||||
return URL(string: "http://localhost:8080")!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
apple/OmnivoreKit/Sources/Models/ArticleContent.swift
Normal file
20
apple/OmnivoreKit/Sources/Models/ArticleContent.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import Foundation
|
||||
|
||||
public struct ArticleContent {
|
||||
public let htmlContent: String
|
||||
public let highlights: [Highlight]
|
||||
|
||||
public init(
|
||||
htmlContent: String,
|
||||
highlights: [Highlight]
|
||||
) {
|
||||
self.htmlContent = htmlContent
|
||||
self.highlights = highlights
|
||||
}
|
||||
|
||||
public var highlightsJSONString: String {
|
||||
let jsonData = try? JSONEncoder().encode(highlights)
|
||||
guard let jsonData = jsonData else { return "[]" }
|
||||
return String(data: jsonData, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
public struct Highlight: Identifiable, Hashable {
|
||||
public struct Highlight: Identifiable, Hashable, Codable {
|
||||
public let id: String
|
||||
public let shortId: String
|
||||
public let quote: String
|
||||
|
|
@ -8,6 +8,9 @@ public struct Highlight: Identifiable, Hashable {
|
|||
public let suffix: String?
|
||||
public let patch: String
|
||||
public let annotation: String?
|
||||
public let createdAt: Date?
|
||||
public let updatedAt: Date?
|
||||
public let createdByMe: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
|
|
@ -16,7 +19,10 @@ public struct Highlight: Identifiable, Hashable {
|
|||
prefix: String?,
|
||||
suffix: String?,
|
||||
patch: String,
|
||||
annotation: String?
|
||||
annotation: String?,
|
||||
createdByMe: Bool,
|
||||
createdAt: Date? = nil,
|
||||
updatedAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.shortId = shortId
|
||||
|
|
@ -25,5 +31,8 @@ public struct Highlight: Identifiable, Hashable {
|
|||
self.suffix = suffix
|
||||
self.patch = patch
|
||||
self.annotation = annotation
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.createdByMe = createdByMe
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ public extension DataService {
|
|||
highlightID: String,
|
||||
quote: String,
|
||||
patch: String,
|
||||
articleId: String
|
||||
articleId: String,
|
||||
annotation: String? = nil
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
|
|
@ -28,7 +29,12 @@ public extension DataService {
|
|||
let mutation = Selection.Mutation {
|
||||
try $0.createHighlight(
|
||||
input: InputObjects.CreateHighlightInput(
|
||||
id: highlightID, shortId: shortId, articleId: articleId, patch: patch, quote: quote
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
articleId: articleId,
|
||||
patch: patch,
|
||||
quote: quote,
|
||||
annotation: OptionalArgument(annotation)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
public extension DataService {
|
||||
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: ArticleContent)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let highlightSelection = Selection.Highlight {
|
||||
Highlight(
|
||||
id: try $0.id(),
|
||||
shortId: try $0.shortId(),
|
||||
quote: try $0.quote(),
|
||||
prefix: try $0.prefix(),
|
||||
suffix: try $0.suffix(),
|
||||
patch: try $0.patch(),
|
||||
annotation: try $0.annotation(),
|
||||
createdByMe: try $0.createdByMe()
|
||||
)
|
||||
}
|
||||
|
||||
let articleSelection = Selection.Article {
|
||||
ArticleContent(
|
||||
htmlContent: try $0.content(),
|
||||
highlights: try $0.highlights(selection: highlightSelection.list)
|
||||
)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleSelection))
|
||||
},
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.article(username: username, slug: slug, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(query, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
promise(.success(result))
|
||||
case .error:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@ public extension DataService {
|
|||
prefix: try $0.prefix(),
|
||||
suffix: try $0.suffix(),
|
||||
patch: try $0.patch(),
|
||||
annotation: try $0.annotation()
|
||||
annotation: try $0.annotation(),
|
||||
createdByMe: try $0.createdByMe()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,10 @@ public extension Bundle {
|
|||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience for locating package resources externally
|
||||
public enum UtilsPackage {
|
||||
public static var bundleURL: URL {
|
||||
Bundle.module.bundleURL
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ public enum FeatureFlag {
|
|||
public static let enableShareButton = false
|
||||
public static let enableSnooze = false
|
||||
public static let showFeedItemTags = false
|
||||
public static let useLocalWebView = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import Introspect
|
||||
import SwiftUI
|
||||
|
||||
struct HighlightAnnotationSheet: View {
|
||||
public struct HighlightAnnotationSheet: View {
|
||||
@Binding var annotation: String
|
||||
|
||||
let onSave: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
init(
|
||||
public init(
|
||||
annotation: Binding<String>,
|
||||
onSave: @escaping () -> Void,
|
||||
onCancel: @escaping () -> Void
|
||||
|
|
@ -17,7 +17,7 @@ struct HighlightAnnotationSheet: View {
|
|||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Button("Cancel", action: onCancel)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,27 @@ import WebKit
|
|||
|
||||
public let readerViewNavBarHeight = 50.0
|
||||
|
||||
enum WebViewConfigurationManager {
|
||||
private static let processPool = WKProcessPool()
|
||||
static func create() -> WKWebViewConfiguration {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.processPool = processPool
|
||||
// config.limitsNavigationsToAppBoundDomains = true
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
public enum WebViewManager {
|
||||
public static let sharedView = create()
|
||||
public static func shared() -> WebView {
|
||||
sharedView
|
||||
}
|
||||
|
||||
public static func create() -> WebView {
|
||||
WebView(frame: CGRect.zero, configuration: WebViewConfigurationManager.create())
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
struct WebAppView: UIViewRepresentable {
|
||||
let request: URLRequest
|
||||
|
|
@ -28,7 +49,7 @@ public let readerViewNavBarHeight = 50.0
|
|||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WebView(frame: CGRect.zero)
|
||||
let webView = WebViewManager.create()
|
||||
let contentController = WKUserContentController()
|
||||
|
||||
webView.navigationDelegate = context.coordinator
|
||||
|
|
|
|||
|
|
@ -114,21 +114,32 @@ public struct WebAppWrapperView: View {
|
|||
}
|
||||
|
||||
#if os(iOS)
|
||||
struct SafariView: UIViewControllerRepresentable {
|
||||
public struct SafariView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context _: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
|
||||
public init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
public func makeUIViewController(
|
||||
context _: UIViewControllerRepresentableContext<SafariView>
|
||||
) -> SFSafariViewController {
|
||||
SFSafariViewController(url: url)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext<SafariView>) {}
|
||||
public func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext<SafariView>) {}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
struct SafariView: View {
|
||||
public struct SafariView: View {
|
||||
let url: URL
|
||||
var body: some View {
|
||||
|
||||
public init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import WebKit
|
|||
|
||||
/// Describes actions that can be sent from the WebView back to native views.
|
||||
/// The names on the javascript side must match for an action to be handled.
|
||||
enum WebViewAction: String, CaseIterable {
|
||||
public enum WebViewAction: String, CaseIterable {
|
||||
case highlightAction
|
||||
case readingProgressUpdate
|
||||
}
|
||||
|
||||
final class WebView: WKWebView {
|
||||
public final class WebView: WKWebView {
|
||||
#if os(iOS)
|
||||
private var panGestureRecognizer: UIPanGestureRecognizer?
|
||||
private var tapGestureRecognizer: UITapGestureRecognizer?
|
||||
|
|
@ -26,11 +26,11 @@ final class WebView: WKWebView {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func increaseFontSize() {
|
||||
public func increaseFontSize() {
|
||||
dispatchEvent("increaseFontSize")
|
||||
}
|
||||
|
||||
func decreaseFontSize() {
|
||||
public func decreaseFontSize() {
|
||||
dispatchEvent("decreaseFontSize")
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ final class WebView: WKWebView {
|
|||
}
|
||||
|
||||
#if os(iOS)
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ final class WebView: WKWebView {
|
|||
setDefaultMenu()
|
||||
}
|
||||
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
public func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard let messageBody = message.body as? [String: Any] else { return }
|
||||
guard let actionID = messageBody["actionID"] as? String else { return }
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ final class WebView: WKWebView {
|
|||
UIMenuController.shared.menuItems = [remove, /* share, */ annotate]
|
||||
}
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
override public var canBecomeFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -123,11 +123,12 @@ final class WebView: WKWebView {
|
|||
setDefaultMenu()
|
||||
}
|
||||
|
||||
func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
|
||||
// swiftlint:disable:next line_length
|
||||
public func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender _: Any?) -> Bool {
|
||||
override public func canPerformAction(_ action: Selector, withSender _: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(annotateSelection): return true
|
||||
case #selector(highlightSelection): return true
|
||||
|
|
@ -163,7 +164,7 @@ final class WebView: WKWebView {
|
|||
hideMenu()
|
||||
}
|
||||
|
||||
@objc override func copy(_ sender: Any?) {
|
||||
@objc override public func copy(_ sender: Any?) {
|
||||
super.copy(sender)
|
||||
dispatchEvent("copyHighlight")
|
||||
hideMenu()
|
||||
|
|
@ -209,7 +210,7 @@ final class WebView: WKWebView {
|
|||
UIMenuController.shared.showMenu(from: self, rect: rect)
|
||||
}
|
||||
|
||||
func saveAnnotation(annotation: String) {
|
||||
public func saveAnnotation(annotation: String) {
|
||||
// swiftlint:disable:next line_length
|
||||
let dispatch = "var event = new Event('saveAnnotation');event.annotation = '\(annotation)';document.dispatchEvent(event);"
|
||||
evaluateJavaScript(dispatch) { obj, err in
|
||||
|
|
|
|||
8
apple/OmnivoreKit/Sources/Views/BundleFinder.swift
Normal file
8
apple/OmnivoreKit/Sources/Views/BundleFinder.swift
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
// Convenience for locating package resources externally
|
||||
public enum ViewsPackage {
|
||||
public static var bundleURL: URL {
|
||||
Bundle.module.bundleURL
|
||||
}
|
||||
}
|
||||
|
|
@ -3,15 +3,16 @@ import SwiftUI
|
|||
@discardableResult
|
||||
public func registerFonts() -> Bool {
|
||||
[
|
||||
registerFont(bundle: .module, fontName: "Inter-Black", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-ExtraBold", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Bold", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-SemiBold", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Medium", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Regular", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Light", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-ExtraLight", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Thin", fontExtension: "ttf")
|
||||
registerFont(bundle: .module, fontName: "Inter-Black-900", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-ExtraBold-800", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Bold-700", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-SemiBold-600", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Medium-500", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Regular-400", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Light-300", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-ExtraLight-200", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Inter-Thin", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "SFMonoRegular", fontExtension: "otf")
|
||||
]
|
||||
.allSatisfy { $0 }
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
2
apple/OmnivoreKit/Sources/Views/Resources/bundle.js
Normal file
2
apple/OmnivoreKit/Sources/Views/Resources/bundle.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#ddd;background:#303030}.hljs-keyword,.hljs-link,.hljs-literal,.hljs-section,.hljs-selector-tag{color:#fff}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#d88}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#979797}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
1
apple/OmnivoreKit/Sources/Views/Resources/highlight.css
Normal file
1
apple/OmnivoreKit/Sources/Views/Resources/highlight.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
MathJax = {
|
||||
tex: {
|
||||
inlineMath: [
|
||||
['$latex', '$'],
|
||||
['\\(', '\\)'],
|
||||
],
|
||||
},
|
||||
}
|
||||
1
apple/OmnivoreKit/Sources/Views/Resources/mathjax.js
Normal file
1
apple/OmnivoreKit/Sources/Views/Resources/mathjax.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -83,6 +83,7 @@ services:
|
|||
- PG_DB=omnivore
|
||||
- PG_PORT=5432
|
||||
- PG_POOL_MAX=20
|
||||
- ELASTIC_URL=http://elastic:9200
|
||||
- IMAGE_PROXY_URL=http://localhost:9999
|
||||
- IMAGE_PROXY_SECRET=some-secret
|
||||
- JWT_SECRET=some_secret
|
||||
|
|
|
|||
|
|
@ -26,3 +26,4 @@ TWITTER_BEARER_TOKEN=
|
|||
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
|
||||
SEGMENT_WRITE_KEY='test'
|
||||
REMINDER_TASK_HANDLER_URL=http://localhost:4000/svc/reminders/trigger
|
||||
PUBSUB_VERIFICATION_TOKEN='123456'
|
||||
|
|
|
|||
|
|
@ -16,6 +16,18 @@ All operations on the database must be wrapped in Knex transaction on a resolver
|
|||
|
||||
Because we make use of Row Level Security in the database, - all operations typically begin with assuming the role for which policies exist via `omnivore.set_claims` database function.
|
||||
|
||||
## ElasticSearch
|
||||
|
||||
We use ElasticSearch to store page data in a distributed manner. This is a great way to store data that is not easily searchable.
|
||||
All the page data is stored in a single index `pages`. This index is then queried by the app to display the data.
|
||||
You need to make sure you have an elasticsearch instance running locally (or just use docker-compose).
|
||||
ES url is specified by `ES_URL` environment variable (username `ES_USERNAME` and password `ES_PASSWORD` can be random strings in local environment).
|
||||
|
||||
When you're running elastic for the very first time, you need to create indices and ingest existing data. This can be done by running `python elastic_migrate.py`.
|
||||
This operation is idempotent, so you can always run `python elastic_migrate.py` again to re-ingest all the data.
|
||||
|
||||
You can run ElasticSearch separately by using `docker-compose -f docker-compose.yml up -d elastic`.
|
||||
|
||||
## Image Proxy (optional for local dev)
|
||||
|
||||
Backend API server returns article image links using image proxy
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/python
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import psycopg2
|
||||
|
|
@ -21,7 +22,7 @@ INDEX_SETTINGS = os.getenv('INDEX_SETTINGS', 'index_settings.json')
|
|||
DATETIME_FORMAT = 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
||||
QUERY = f'''
|
||||
SELECT
|
||||
p.id,
|
||||
l.id,
|
||||
title,
|
||||
description,
|
||||
to_char(l.created_at, '{DATETIME_FORMAT}') as "createdAt",
|
||||
|
|
@ -42,28 +43,63 @@ QUERY = f'''
|
|||
to_char(saved_at, '{DATETIME_FORMAT}') as "savedAt",
|
||||
slug,
|
||||
to_char(archived_at, '{DATETIME_FORMAT}') as "archivedAt"
|
||||
FROM omnivore.pages p
|
||||
LEFT JOIN omnivore.links l ON p.id = l.article_id
|
||||
WHERE p.updated_at > '{UPDATE_TIME}'
|
||||
FROM omnivore.links l
|
||||
INNER JOIN omnivore.pages p ON p.id = l.article_id
|
||||
WHERE l.updated_at > '{UPDATE_TIME}'
|
||||
'''
|
||||
|
||||
UPDATE_ARTICLE_SAVING_REQUEST_SQL = f'''
|
||||
UPDATE omnivore.article_saving_request
|
||||
SET elastic_page_id = article_id
|
||||
WHERE elastic_page_id is NULL
|
||||
AND article_id is NOT NULL
|
||||
AND updated_at > '{UPDATE_TIME}';
|
||||
UPDATE
|
||||
omnivore.article_saving_request a
|
||||
SET
|
||||
elastic_page_id = l.id
|
||||
FROM
|
||||
omnivore.links l
|
||||
WHERE
|
||||
a.article_id = l.article_id
|
||||
AND a.user_id = l.user_id
|
||||
AND a.updated_at > '{UPDATE_TIME}'
|
||||
'''
|
||||
|
||||
UPDATE_HIGHLIGHT_SQL = f'''
|
||||
UPDATE omnivore.highlight
|
||||
SET elastic_page_id = article_id
|
||||
WHERE elastic_page_id is NULL
|
||||
AND article_id is NOT NULL
|
||||
AND updated_at > '{UPDATE_TIME}';
|
||||
UPDATE
|
||||
omnivore.highlight h
|
||||
SET
|
||||
elastic_page_id = l.id
|
||||
FROM
|
||||
omnivore.links l
|
||||
WHERE
|
||||
h.article_id = l.article_id
|
||||
AND h.user_id = l.user_id
|
||||
AND h.updated_at > '{UPDATE_TIME}'
|
||||
'''
|
||||
|
||||
|
||||
def assertData(conn, client):
|
||||
# get all users from postgres
|
||||
try:
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cursor.execute('''SELECT id FROM omnivore.user''')
|
||||
result = cursor.fetchall()
|
||||
for row in result:
|
||||
userId = row['id']
|
||||
cursor.execute(
|
||||
f'SELECT COUNT(*) FROM omnivore.links WHERE user_id = \'{userId}\'''')
|
||||
countInPostgres = cursor.fetchone()['count']
|
||||
countInElastic = client.count(
|
||||
index='pages', body={'query': {'term': {'userId': userId}}})['count']
|
||||
|
||||
if countInPostgres == countInElastic:
|
||||
print(f'User {userId} OK')
|
||||
else:
|
||||
print(
|
||||
f'User {userId} ERROR: postgres: {countInPostgres}, elastic: {countInElastic}')
|
||||
cursor.close()
|
||||
except Exception as err:
|
||||
print('Assert data ERROR:', err)
|
||||
exit(1)
|
||||
|
||||
|
||||
def create_index(client):
|
||||
print('Creating index')
|
||||
try:
|
||||
|
|
@ -153,6 +189,7 @@ def import_data_to_es(client, docs) -> int:
|
|||
|
||||
doc_list = []
|
||||
for doc in docs:
|
||||
doc['publishedAt'] = validated_date(doc['publishedAt'])
|
||||
# convert the string to a dict object
|
||||
dict_doc = {
|
||||
'_index': 'pages',
|
||||
|
|
@ -166,6 +203,22 @@ def import_data_to_es(client, docs) -> int:
|
|||
return count
|
||||
|
||||
|
||||
def validated_date(date):
|
||||
try:
|
||||
if date is None:
|
||||
return None
|
||||
|
||||
datetime_object = datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.%fZ')
|
||||
# Make sure the date year is not greater than 9999
|
||||
if datetime_object.year > 9999:
|
||||
return None
|
||||
|
||||
return date
|
||||
except Exception as err:
|
||||
print('error validating date', err)
|
||||
return None
|
||||
|
||||
|
||||
print('Starting migration')
|
||||
|
||||
# test elastic client
|
||||
|
|
@ -193,6 +246,8 @@ update_postgres_data(conn, UPDATE_ARTICLE_SAVING_REQUEST_SQL,
|
|||
'article_saving_request')
|
||||
update_postgres_data(conn, UPDATE_HIGHLIGHT_SQL, 'highlight')
|
||||
|
||||
assertData(conn, client)
|
||||
|
||||
client.close()
|
||||
conn.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
"@opentelemetry/semantic-conventions": "^0.24.0",
|
||||
"@opentelemetry/tracing": "^0.24.0",
|
||||
"@sendgrid/mail": "^7.6.0",
|
||||
"@sentry/integrations": "^5.29.0",
|
||||
"@sentry/integrations": "^6.19.1",
|
||||
"@sentry/node": "^5.26.0",
|
||||
"@sentry/tracing": "^5.26.0",
|
||||
"@types/analytics-node": "^3.1.7",
|
||||
|
|
@ -75,6 +75,7 @@
|
|||
"graphql-middleware": "^6.0.10",
|
||||
"graphql-shield": "^7.5.0",
|
||||
"highlightjs": "^9.16.2",
|
||||
"html-entities": "^2.3.2",
|
||||
"intercom-client": "^3.1.4",
|
||||
"jsdom": "^16.4.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export const updatePage = async (
|
|||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
if (body.result !== 'updated') return false
|
||||
|
|
@ -237,6 +238,7 @@ export const addLabelInPage = async (
|
|||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
return body.result === 'updated'
|
||||
|
|
@ -350,7 +352,7 @@ export const getPageByParam = async <K extends keyof ParamSet>(
|
|||
id: body.hits.hits[0]._id,
|
||||
} as Page
|
||||
} catch (e) {
|
||||
console.log('failed to search pages in elastic', e)
|
||||
console.error('failed to search pages in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -472,6 +474,7 @@ export const searchPages = async (
|
|||
return [
|
||||
response.body.hits.hits.map((hit: { _source: Page; _id: string }) => ({
|
||||
...hit._source,
|
||||
content: '',
|
||||
id: hit._id,
|
||||
})),
|
||||
response.body.hits.total.value,
|
||||
|
|
|
|||
|
|
@ -349,7 +349,13 @@ export const createArticleResolver = authorized<
|
|||
articleSavingRequest
|
||||
)
|
||||
}
|
||||
log.info('page created in elastic', articleToSave)
|
||||
log.info(
|
||||
'page created in elastic',
|
||||
pageId,
|
||||
articleToSave.url,
|
||||
articleToSave.slug,
|
||||
articleToSave.title
|
||||
)
|
||||
articleToSave.id = pageId
|
||||
}
|
||||
|
||||
|
|
@ -408,12 +414,7 @@ export const getArticleResolver: ResolverFn<
|
|||
})
|
||||
await createIntercomEvent('get-article', claims.uid)
|
||||
|
||||
console.log('start to get article', Date.now())
|
||||
|
||||
const page = await getPageByParam({ userId: claims.uid, slug })
|
||||
|
||||
console.log('get article from elastic', Date.now())
|
||||
|
||||
if (!page) {
|
||||
return { errorCodes: [ArticleErrorCode.NotFound] }
|
||||
}
|
||||
|
|
@ -440,8 +441,6 @@ export const getArticlesResolver = authorized<
|
|||
const startCursor = params.after || ''
|
||||
const first = params.first || 10
|
||||
|
||||
console.log('getArticlesResolver starts', Date.now())
|
||||
|
||||
// Perform basic sanitization. Right now we just allow alphanumeric, space and quote
|
||||
// so queries can contain phrases like "human race";
|
||||
// We can also split out terms like "label:unread".
|
||||
|
|
@ -461,8 +460,6 @@ export const getArticlesResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
console.log('parsed search query', Date.now())
|
||||
|
||||
await createIntercomEvent('search', claims.uid)
|
||||
|
||||
const [pages, totalCount] = (await searchPages(
|
||||
|
|
@ -485,8 +482,6 @@ export const getArticlesResolver = authorized<
|
|||
const hasNextPage = pages.length > first
|
||||
const endCursor = String(start + pages.length - (hasNextPage ? 1 : 0))
|
||||
|
||||
console.log('get search result', Date.now())
|
||||
|
||||
console.log(
|
||||
'start',
|
||||
start,
|
||||
|
|
@ -707,9 +702,9 @@ export const saveArticleReadingProgressResolver = authorized<
|
|||
{ input: { id, readingProgressPercent, readingProgressAnchorIndex } },
|
||||
{ claims: { uid }, pubsub }
|
||||
) => {
|
||||
const userArticleRecord = await getPageByParam({ userId: uid, _id: id })
|
||||
const page = await getPageByParam({ userId: uid, _id: id })
|
||||
|
||||
if (!userArticleRecord) {
|
||||
if (!page) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
|
|
@ -725,26 +720,25 @@ export const saveArticleReadingProgressResolver = authorized<
|
|||
// be greater than the current reading progress.
|
||||
const shouldUpdate =
|
||||
readingProgressPercent === 0 ||
|
||||
(userArticleRecord.readingProgressPercent || 0) <
|
||||
readingProgressPercent ||
|
||||
(userArticleRecord.readingProgressAnchorIndex || 0) <
|
||||
readingProgressAnchorIndex
|
||||
page.readingProgressPercent < readingProgressPercent ||
|
||||
page.readingProgressAnchorIndex < readingProgressAnchorIndex
|
||||
|
||||
const updatedArticle = Object.assign(userArticleRecord, {
|
||||
const updatedPart = {
|
||||
readingProgressPercent: shouldUpdate
|
||||
? readingProgressPercent
|
||||
: userArticleRecord.readingProgressPercent,
|
||||
: page.readingProgressPercent,
|
||||
readingProgressAnchorIndex: shouldUpdate
|
||||
? readingProgressAnchorIndex
|
||||
: userArticleRecord.readingProgressAnchorIndex,
|
||||
})
|
||||
: page.readingProgressAnchorIndex,
|
||||
}
|
||||
|
||||
shouldUpdate && (await updatePage(id, updatedArticle, { pubsub, uid }))
|
||||
shouldUpdate && (await updatePage(id, updatedPart, { pubsub, uid }))
|
||||
|
||||
return {
|
||||
updatedArticle: {
|
||||
...updatedArticle,
|
||||
isArchived: !!updatedArticle.archivedAt,
|
||||
...page,
|
||||
...updatedPart,
|
||||
isArchived: !!page.archivedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export const mergeHighlightResolver = authorized<
|
|||
await models.highlight.deleteMany(overlapHighlightIdList, tx)
|
||||
return await models.highlight.create({
|
||||
...newHighlightInput,
|
||||
articleId: undefined,
|
||||
annotation: mergedAnnotation ? mergedAnnotation.join('\n') : null,
|
||||
userId: claims.uid,
|
||||
elasticPageId: newHighlightInput.articleId,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { generateUploadSignedUrl, uploadToSignedUrl } from '../../utils/uploads'
|
|||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { env } from '../../env'
|
||||
import { Page } from '../../elastic/types'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export function pageServiceRouter() {
|
||||
const router = express.Router()
|
||||
|
|
@ -36,19 +37,25 @@ export function pageServiceRouter() {
|
|||
|
||||
const contentType = 'application/json'
|
||||
const bucketName = env.fileUpload.gcsUploadPrivateBucket
|
||||
|
||||
console.log('generate upload url')
|
||||
|
||||
const uploadUrl = await generateUploadSignedUrl(
|
||||
`${req.params.folder}/${
|
||||
data.userId
|
||||
}/${new Date().toDateString()}/${uuidv4()}.json`,
|
||||
`${req.params.folder}/${data.userId}/${DateTime.now().toFormat(
|
||||
'yyyy-LL-dd'
|
||||
)}/${uuidv4()}.json`,
|
||||
contentType,
|
||||
bucketName
|
||||
)
|
||||
|
||||
console.log('start uploading', uploadUrl)
|
||||
|
||||
await uploadToSignedUrl(
|
||||
uploadUrl,
|
||||
Buffer.from(msgStr, 'utf8'),
|
||||
contentType
|
||||
)
|
||||
res.status(200)
|
||||
res.status(200).send('OK')
|
||||
} catch (err) {
|
||||
console.log('upload page data failed', err)
|
||||
res.status(500).send(err)
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export const createApp = (): {
|
|||
}
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
console.log('starting with log levels', config.syslog.levels)
|
||||
// If creating the DB entities fails, we want this to throw
|
||||
// so the container will be restarted and not come online
|
||||
// as healthy.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { AxiosHandler } from './axios-handler'
|
|||
import { BloombergHandler } from './bloomberg-handler'
|
||||
import { GolangHandler } from './golang-handler'
|
||||
import * as hljs from 'highlightjs'
|
||||
import { decode } from 'html-entities'
|
||||
|
||||
const logger = buildLogger('utils.parse')
|
||||
|
||||
|
|
@ -333,10 +334,10 @@ const getJSONLdLinkMetadata = async (
|
|||
const jsonLd =
|
||||
(await axios.get(jsonLdLink.href, { timeout: 5000 })).data || {}
|
||||
|
||||
result.byline = jsonLd['author_name']
|
||||
result.previewImage = jsonLd['thumbnail_url']
|
||||
result.siteName = jsonLd['provider_name']
|
||||
result.title = jsonLd['title']
|
||||
result.byline = decode(jsonLd['author_name'])
|
||||
result.previewImage = decode(jsonLd['thumbnail_url'])
|
||||
result.siteName = decode(jsonLd['provider_name'])
|
||||
result.title = decode(jsonLd['title'])
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { env } from '../env'
|
||||
import { GetSignedUrlConfig, Storage } from '@google-cloud/storage'
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
|
||||
/* On GAE/Prod, we shall rely on default app engine service account credentials.
|
||||
* Two changes needed: 1) add default service account to our uploads GCS Bucket
|
||||
|
|
@ -102,12 +102,17 @@ export const uploadToSignedUrl = async (
|
|||
uploadUrl: string,
|
||||
data: Buffer,
|
||||
contentType: string
|
||||
): Promise<AxiosResponse> => {
|
||||
return axios.put(uploadUrl, data, {
|
||||
): Promise<void> => {
|
||||
if (env.dev.isLocal) {
|
||||
return
|
||||
}
|
||||
|
||||
await axios.put(uploadUrl, data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
maxBodyLength: 1000000000,
|
||||
maxContentLength: 100000000,
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,43 @@ const createHighlightQuery = (
|
|||
`
|
||||
}
|
||||
|
||||
const mergeHighlightQuery = (
|
||||
pageId: string,
|
||||
highlightId: string,
|
||||
shortHighlightId: string,
|
||||
overlapHighlightIdList: string[],
|
||||
prefix = '_prefix',
|
||||
suffix = '_suffix',
|
||||
quote = '_quote',
|
||||
patch = '_patch'
|
||||
) => {
|
||||
return `
|
||||
mutation {
|
||||
mergeHighlight(
|
||||
input: {
|
||||
prefix: "${prefix}",
|
||||
suffix: "${suffix}",
|
||||
quote: "${quote}",
|
||||
id: "${highlightId}",
|
||||
shortId: "${shortHighlightId}",
|
||||
patch: "${patch}",
|
||||
articleId: "${pageId}",
|
||||
overlapHighlightIdList: "${overlapHighlightIdList}"
|
||||
}
|
||||
) {
|
||||
... on MergeHighlightSuccess {
|
||||
highlight {
|
||||
id
|
||||
}
|
||||
}
|
||||
... on MergeHighlightError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
describe('Highlights API', () => {
|
||||
const username = 'fakeUser'
|
||||
let authToken: string
|
||||
|
|
@ -93,4 +130,35 @@ describe('Highlights API', () => {
|
|||
expect(res.body.data.createHighlight.highlight.id).to.eq(highlightId)
|
||||
})
|
||||
})
|
||||
|
||||
context('mergeHighlightMutation', () => {
|
||||
let highlightId: string
|
||||
|
||||
before(async () => {
|
||||
// create test highlight
|
||||
highlightId = generateFakeUuid()
|
||||
const shortHighlightId = '_short_id'
|
||||
const query = createHighlightQuery(
|
||||
authToken,
|
||||
pageId,
|
||||
highlightId,
|
||||
shortHighlightId
|
||||
)
|
||||
await graphqlRequest(query, authToken).expect(200)
|
||||
})
|
||||
|
||||
it('should not fail', async () => {
|
||||
const newHighlightId = generateFakeUuid()
|
||||
const newShortHighlightId = '_short_id_1'
|
||||
const query = mergeHighlightQuery(
|
||||
pageId,
|
||||
newHighlightId,
|
||||
newShortHighlightId,
|
||||
[highlightId]
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.mergeHighlight.highlight.id).to.eq(newHighlightId)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
23
packages/api/test/routers/pages.test.ts
Normal file
23
packages/api/test/routers/pages.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { request } from '../util'
|
||||
import 'mocha'
|
||||
|
||||
describe('Pages Router', () => {
|
||||
const token = process.env.PUBSUB_VERIFICATION_TOKEN || ''
|
||||
|
||||
describe('upload', () => {
|
||||
it('upload data to GCS', async () => {
|
||||
const data = {
|
||||
message: {
|
||||
data: Buffer.from(JSON.stringify({ userId: 'userId' })).toString(
|
||||
'base64'
|
||||
),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
await request
|
||||
.post(`/svc/pubsub/pages/upload/createdPage?token=${token}`)
|
||||
.send(data)
|
||||
.expect(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import 'chai/register-should'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import fs from 'fs'
|
||||
import { findNewsletterUrl, isProbablyNewsletter, parsePageMetadata, parsePreparedContent } from '../../src/utils/parser'
|
||||
import nock from 'nock'
|
||||
|
||||
const load = (path: string): string => {
|
||||
return fs.readFileSync(path, 'utf8')
|
||||
|
|
@ -70,4 +70,31 @@ describe('parsePreparedContent', async () => {
|
|||
)
|
||||
expect(result.parsedContent?.publishedDate?.getTime()).to.equal(new Date('2016-04-05T15:27:51+00:00').getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePreparedContent', async () => {
|
||||
nock('https://oembeddata').get('/').reply(200, {
|
||||
"version":"1.0",
|
||||
"provider_name":"Hippocratic Adventures",
|
||||
"provider_url":"https:\/\/www.hippocraticadventures.com",
|
||||
"title":"The Ultimate Guide to Practicing Medicine in Singapore – Part 2"
|
||||
})
|
||||
|
||||
it('gets metadata from external JSONLD if available', async () => {
|
||||
const html = `<html>
|
||||
<head>
|
||||
<link rel="alternate" type="application/json+oembed" href="https://oembeddata">
|
||||
</link
|
||||
</head>
|
||||
<body>body</body>
|
||||
</html>`
|
||||
const result = await parsePreparedContent(
|
||||
'https://example.com/',
|
||||
{
|
||||
document: html,
|
||||
pageInfo: { }
|
||||
},
|
||||
)
|
||||
expect(result.parsedContent?.title).to.equal('The Ultimate Guide to Practicing Medicine in Singapore – Part 2')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
16
packages/appreader/.babelrc
Normal file
16
packages/appreader/.babelrc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"@babel/plugin-transform-runtime",
|
||||
{
|
||||
"regenerator": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
3
packages/appreader/.eslintignore
Normal file
3
packages/appreader/.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
**/node_modules/*
|
||||
**/out/*
|
||||
**/.next/*
|
||||
53
packages/appreader/index.html
Normal file
53
packages/appreader/index.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<script type="text/javascript">
|
||||
function loadEnv() {
|
||||
window.omnivoreEnv = {
|
||||
NEXT_PUBLIC_APP_ENV: 'local',
|
||||
NEXT_PUBLIC_LOCAL_BASE_URL: 'http://localhost:3000',
|
||||
NEXT_PUBLIC_LOCAL_SERVER_BASE_URL: 'http://localhost:4000',
|
||||
NEXT_PUBLIC_LOCAL_HIGHLIGHTS_BASE_URL: 'http://localhost:4000',
|
||||
}
|
||||
}
|
||||
|
||||
function loadArticle() {
|
||||
const CONTENT = `
|
||||
<div class="page" id="readability-page-1" data-omnivore-anchor-idx="1"><div data-omnivore-anchor-idx="2">
|
||||
<p data-omnivore-anchor-idx="3">I didn't have time to write a short email so I scheduled a long meeting.</p><p data-omnivore-anchor-idx="4">
|
||||
— <a href="https://twitter.com/jacksonh" data-omnivore-anchor-idx="5">jacksonh</a> Jackson Harper <a href="https://twitter.com/jacksonh/status/1069798258141159425" data-omnivore-anchor-idx="6">December 4, 2018, 3:39 AM UTC</a>
|
||||
</p></div></div>
|
||||
`
|
||||
|
||||
window.omnivoreArticle = {
|
||||
id: 'test',
|
||||
linkId: 'test',
|
||||
slug: 'test-slug',
|
||||
createdAt: new Date().toISOString(),
|
||||
savedAt: new Date().toISOString(),
|
||||
url: 'https://example.com',
|
||||
title: 'Test Article',
|
||||
content: CONTENT,
|
||||
originalArticleUrl: 'https://example.com',
|
||||
contentReader: 'WEB',
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
highlights: [],
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
loadArticle()
|
||||
</script>
|
||||
</div>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
packages/appreader/package.json
Normal file
37
packages/appreader/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@omnivore/appreader",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnivore/web": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^5.70.0",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"@babel/core": "^7.17.7",
|
||||
"@babel/plugin-transform-runtime": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-react": "^7.16.7",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@babel/runtime": "^7.17.7",
|
||||
"@types/string-replace-loader": "^2.3.2",
|
||||
"@types/webpack-bundle-analyzer": "^4.4.1",
|
||||
"@types/webpack-dev-server": "^4.7.2",
|
||||
"babel-loader": "^8.2.3",
|
||||
"css-loader": "^6.7.1",
|
||||
"eslint-config-next": "12.0.7",
|
||||
"eslint-plugin-functional": "^4.0.2",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"string-replace-loader": "^3.1.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-dev-server": "^4.7.4"
|
||||
},
|
||||
"volta": {
|
||||
"node": "14.18.0",
|
||||
"yarn": "1.22.10"
|
||||
}
|
||||
}
|
||||
58
packages/appreader/src/index.jsx
Normal file
58
packages/appreader/src/index.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Box, VStack } from '@omnivore/web/components/elements/LayoutPrimitives'
|
||||
import { ArticleContainer } from '@omnivore/web/components/templates/article/ArticleContainer'
|
||||
import { applyStoredTheme } from '@omnivore/web/lib/themeUpdater'
|
||||
import '@omnivore/web/styles/globals.css'
|
||||
import '@omnivore/web/styles/articleInnerStyling.css'
|
||||
|
||||
const mutation = async (name, input) => {
|
||||
const result = await window?.webkit?.messageHandlers.articleAction?.postMessage({
|
||||
actionID: name,
|
||||
...input
|
||||
})
|
||||
console.log('action result', result, result.result)
|
||||
return result.result
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
applyStoredTheme(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
css={{
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
}}
|
||||
>
|
||||
<VStack
|
||||
alignment="center"
|
||||
distribution="center"
|
||||
className="disable-webkit-callout"
|
||||
>
|
||||
<ArticleContainer
|
||||
viewerUsername="test"
|
||||
article={window.omnivoreArticle}
|
||||
scrollElementRef={React.createRef()}
|
||||
isAppleAppEmbed={true}
|
||||
highlightBarDisabled={true}
|
||||
highlightsBaseURL="https://example.com"
|
||||
fontSize={window.fontSize ?? 18}
|
||||
margin={0}
|
||||
articleMutations={{
|
||||
createHighlightMutation: (input) => mutation('createHighlight', input),
|
||||
deleteHighlightMutation: (highlightId) => mutation('deleteHighlight', { highlightId }),
|
||||
mergeHighlightMutation: (input) => mutation('mergeHighlight', input),
|
||||
updateHighlightMutation: (input) => mutation('updateHighlight', input),
|
||||
articleReadingProgressMutation: (input) => mutation('articleReadingProgress', input),
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'))
|
||||
70
packages/appreader/webpack.config.ts
Normal file
70
packages/appreader/webpack.config.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import path from 'path'
|
||||
import { DefinePlugin } from 'webpack'
|
||||
import { Configuration } from 'webpack'
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
|
||||
|
||||
const analyze = process.env.ANALYZE
|
||||
|
||||
const config: Configuration = {
|
||||
entry: {
|
||||
bundle: './src/index.jsx',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(ts|js)x?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
['@babel/preset-react', { runtime: 'automatic' }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: { url: false },
|
||||
},
|
||||
{
|
||||
// We want paths like `/static/fonts/Inter/Inter.woff2 to become
|
||||
// `Inter.woff2` which is how they will be bundled on iOS.
|
||||
loader: 'string-replace-loader',
|
||||
options: {
|
||||
search: "('/static/fonts/.*/(.*)')",
|
||||
replace(_s: string, _p: string, group: string) {
|
||||
return group
|
||||
},
|
||||
flags: 'g',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new DefinePlugin({
|
||||
'process.env': 'window.omnivoreEnv',
|
||||
}),
|
||||
new BundleAnalyzerPlugin({
|
||||
openAnalyzer: !!analyze,
|
||||
analyzerMode: 'static',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'build'),
|
||||
filename: '[name].js',
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fast-csv/parse": "^4.3.6",
|
||||
"@google-cloud/functions-framework": "1.9.0",
|
||||
"@google-cloud/functions-framework": "3.0.0",
|
||||
"@google-cloud/pubsub": "^2.16.3",
|
||||
"@google-cloud/storage": "^5.18.1",
|
||||
"@types/express": "^4.17.13",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"eslint-plugin-prettier": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/functions-framework": "1.9.0",
|
||||
"@google-cloud/functions-framework": "3.0.0",
|
||||
"@google-cloud/pubsub": "^2.18.4",
|
||||
"@sendgrid/client": "^7.6.0",
|
||||
"@sentry/serverless": "^6.16.1",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
"@types/node": "^14.11.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/functions-framework": "1.9.0",
|
||||
"@google-cloud/functions-framework": "3.0.0",
|
||||
"@google-cloud/pubsub": "^2.16.3",
|
||||
"@google-cloud/storage": "^5.18.1",
|
||||
"axios": "^0.26.0",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"winston": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google-cloud/functions-framework": "^1.7.1"
|
||||
"@google-cloud/functions-framework": "^3.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npx functions-framework --port=9090 --target=puppeteer",
|
||||
|
|
|
|||
|
|
@ -167,9 +167,9 @@ Readability.prototype = {
|
|||
lazyLoadingElements: /\S*loading\S*/i,
|
||||
// NOTE: These two regular expressions are duplicated in
|
||||
// Readability-readerable.js. Please keep both copies in sync.
|
||||
articleNegativeLookBehindCandidates: /breadcrumbs|breadcrumb|utils/i,
|
||||
articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_/i,
|
||||
unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder/i,
|
||||
articleNegativeLookBehindCandidates: /breadcrumbs|breadcrumb|utils|trilist/i,
|
||||
articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_|m_/i,
|
||||
unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder|back-to-top|show-up-next/i,
|
||||
// okMaybeItsACandidate: /and|article(?!-breadcrumb)|body|column|content|main|shadow|post-header/i,
|
||||
get okMaybeItsACandidate() {
|
||||
return new RegExp(`and|(?<!${this.articleNegativeLookAheadCandidates.source})article(?!-(${this.articleNegativeLookBehindCandidates.source}))|body|column|content|^(?!main-navigation)main|shadow|post-header|hfeed site|blog-posts hfeed`, 'i')
|
||||
|
|
@ -2785,7 +2785,7 @@ Readability.prototype = {
|
|||
_isProbablyVisible: function(node) {
|
||||
// Have to null-check node.style and node.className.indexOf to deal with SVG and MathML nodes.
|
||||
return (!node.style || node.style.display !== "none")
|
||||
&& node.style.visibility !== 'hidden'
|
||||
&& (node.style && node.style.visibility !== 'hidden')
|
||||
&& !node.hasAttribute("hidden")
|
||||
//check for "fallback-image" so that wikimedia math images are displayed
|
||||
&& (!node.hasAttribute("aria-hidden")
|
||||
|
|
|
|||
|
|
@ -207,26 +207,17 @@ function onResponseReceived(error, source, destRoot) {
|
|||
|
||||
function runReadability(source, destPath, metadataDestPath) {
|
||||
var uri = "http://fakehost/test/page.html";
|
||||
var doc = new JSDOM(source, {
|
||||
url: uri,
|
||||
}).window.document;
|
||||
var myReader, result, readerable;
|
||||
try {
|
||||
// We pass `caption` as a class to check that passing in extra classes works,
|
||||
// given that it appears in some of the test documents.
|
||||
myReader = new Readability(doc, { classesToPreserve: ["caption"] });
|
||||
result = myReader.parse();
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
ex.stack.forEach(console.log.bind(console));
|
||||
}
|
||||
// Use jsdom for isProbablyReaderable because it supports querySelectorAll
|
||||
try {
|
||||
var jsdomDoc = new JSDOM(source, {
|
||||
// Use jsdom for isProbablyReaderable because it supports querySelectorAll
|
||||
var jsdom = new JSDOM(source, {
|
||||
url: uri,
|
||||
}).window.document;
|
||||
myReader = new Readability(jsdomDoc);
|
||||
readerable = isProbablyReaderable(jsdomDoc);
|
||||
// We pass `caption` as a class to check that passing in extra classes works,
|
||||
// given that it appears in some of the test documents.
|
||||
myReader = new Readability(jsdom, { classesToPreserve: ["caption"]});
|
||||
result = myReader.parse();
|
||||
readerable = isProbablyReaderable(jsdom);
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
ex.stack.forEach(console.log.bind(console));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"title": "Will Western Sanctions Accelerate Russian Realignment? | City Journal",
|
||||
"byline": "Samo Burja is the president of Bismarck Analysis, a political risk consultancy. Twitter: @samoburja",
|
||||
"dir": null,
|
||||
"excerpt": "As the Russian economy falters, it may have to look east.",
|
||||
"siteName": "City Journal",
|
||||
"previewImage": "https://media4.manhattan-institute.org/sites/cj/files/will-western-sanctions-accelerate-russian-realignment.jpg",
|
||||
"publishedDate": "2022-03-17T15:14:12.000Z",
|
||||
"readerable": true
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<div id="readability-page-1" class="page">
|
||||
<div>
|
||||
<div>
|
||||
<h2> The Great Eurasian Economic Realignment </h2>
|
||||
</div>
|
||||
<div id="wallit-pop">
|
||||
<!-- Part of the l_ipage-container-module -->
|
||||
<!-- actual article chapter content -->
|
||||
<p> Since Vladimir Putin ordered the Russian Armed Forces to <a href="https://www.city-journal.org/putins-bet" target="_blank">launch a full-scale invasion</a> of Ukraine on February 24, Western countries and their close allies have hit Russia with a raft of sweeping and unprecedented <a href="https://www.city-journal.org/under-heavy-sanctions-time-is-not-on-russias-side" target="_blank">sanctions</a>. Several major Russian banks were cut off from the Belgium-based SWIFT international financial messaging system. Transactions with Russia’s central bank were banned by the United States, United Kingdom, and European Union, effectively preventing Russia from utilizing much of its $640 billion of foreign exchange reserves. Targeted asset freezes and seizures were carried out against high-level members of Russia’s political and economic elite, Russian planes were denied access to essentially all European and North American airspace, and an unofficial boycott of Russia has seen <a href="https://www.dw.com/en/which-companies-have-pulled-out-of-russia-over-the-ukraine-invasion/a-61078955" target="_blank">over 300</a> international businesses suspend operations in or with the country, including major brands such as Apple, Disney, Zara, Visa, IKEA, and Coca-Cola. The Russian ruble has <a href="https://www.nytimes.com/2022/03/09/business/russia-ruble-central-bank.html" target="_blank">lost</a> about 40 percent of its value against the U.S. dollar in a matter of weeks. </p>
|
||||
<p> This kind of economic warfare is designed to isolate Putin’s government and bring an end to the invasion. But Russia’s vast exports of oil, coal, and natural gas to Europe have been notably exempt from the sanctions so far. Europe heavily relies on Russia to meet its energy needs: Russia alone <a href="https://edition.cnn.com/2022/03/08/energy/gas-russia-europe/index.html" target="_blank">supplies</a> 40 percent of the European Union’s natural gas imports, 46 percent of EU coal imports, and 27 percent of EU oil imports. Russia is also the world’s largest <a href="https://www.aljazeera.com/news/2022/2/17/infographic-russia-ukraine-and-the-global-wheat-supply-interactive" target="_blank">wheat</a> exporter, a leading producer of fertilizer, and possesses vast mineral reserves. The sanctions and boycotts against Russia are thus perhaps better termed financial warfare. Despite the belief that financial pain for the Russian populace will translate into political change, the nature of the Russian government remains the same. And while such bureaucratic and accounting moves can certainly cause significant pain in the short and medium term, they cannot change the fact that Russia possesses valuable and useful natural resources. </p>
|
||||
<p> But as the <a href="https://www.forbes.com/sites/rrapier/2021/11/14/is-the-us-energy-independent/" target="_blank">energy-independent U.S.</a> pushes for energy sanctions, Germany in particular finds itself in an increasingly difficult position. For decades, Germany’s political and business elites have been <a href="https://brief.bismarckanalysis.com/p/the-german-retreat-from-nuclear-power" target="_blank">implementing a wide-ranging transition</a> from reliance on fossil fuels and nuclear energy to wind, solar, and biofuels. This energy transition, called the <i>Energiewende</i> in German, was always a precariously ambitious strategy for Europe’s premier industrial and manufacturing power. Heavy industry requires consistent and abundant energy—something that <a href="https://brief.bismarckanalysis.com/p/photovoltaics-give-credibility-to" target="_blank">photovoltaic solar panels could provide in theory</a> but not in dim and cloudy Germany. With its nuclear plants now almost entirely shuttered, Germany’s energy transition has instead resulted in a reliance on Russian energy significantly higher than the EU average, with <a href="https://www.bp.com/content/dam/bp/business-sites/en/global/corporate/pdfs/energy-economics/statistical-review/bp-stats-review-2021-natural-gas.pdf" target="_blank">55 percent of natural gas imports</a> coming from Russia in 2020. </p>
|
||||
<p> Will that change? In the wake of Russia’s invasion of Ukraine, the German government briefly floated the possibility of delaying shutdowns of its few remaining nuclear plants, before ultimately <a href="https://www.reuters.com/world/europe/german-ministries-say-cannot-recommend-extending-nuclear-plants-lifetime-2022-03-08/" target="_blank">deciding against it</a> and instead emphasizing plans to increase liquefied natural gas (LNG) imports and development of renewables. While the U.S. has announced plans to ban Russian energy imports immediately, new European <a href="https://edition.cnn.com/2022/03/08/energy/gas-russia-europe/index.html" target="_blank">plans</a> to reduce energy imports from Russia are less ambitious on paper but could prove harder to achieve. </p>
|
||||
<p> Political considerations may ultimately outweigh economic ones in Europe. All that is stopping European countries from eventually reducing energy imports from Russia to zero is their willingness to pay the costs of higher energy import prices, such as by importing LNG from the United States, the Middle East, or Africa, or by continuing to subsidize the construction and operation of renewable-energy installations. Much as Westerners overrate the effects of economic hardship on political outcomes in Russia, Iran, or North Korea, it would be a mistake for Russia to overrate the effects of economic hardship on political outcomes in Europe. For while Russia is Europe’s largest energy supplier, Europe is Russia’s largest trade partner. The complete loss of this trade relationship would have far harsher impacts on Russia’s economy and state coffers than even the current round of financial sanctions. </p>
|
||||
<p> What has not been widely considered, however, is the possibility that Russia welcomes this outcome. If Russia is betting on economic divorce from Europe, including in energy, then sanctions and boycotts counterintuitively support, rather than frustrate, Russian strategy. Indeed, Putin’s government is already planning to retaliate with economic warfare of its own: it has prepared its own list of “unfriendly” countries to which to <a href="https://www.bloomberg.com/news/articles/2022-03-08/russia-to-restrict-some-raw-material-exports-but-omits-details" target="_blank">ban exports</a> of natural resources, and has <a href="https://www.bloomberg.com/news/articles/2022-03-07/russia-threatens-to-cut-gas-flows-to-europe-via-nord-stream-1" target="_blank">threatened</a> to shut down gas to Germany via the Nord Stream 1 pipeline in retaliation for Germany halting the certification of Nord Stream 2. </p>
|
||||
<p> Perhaps these moves can be written off as symbolic reactions, but they may fit a broader strategy of replacing Europe with Asia as Russia’s key customer for natural resources. Russia has been heavily investing in development and infrastructure <a href="https://brief.bismarckanalysis.com/p/russias-long-term-bet-on-the-arctic" target="_blank">in its Arctic and subarctic regions</a>, which produce an overwhelming majority of its fossil fuels and a substantial portion of its valuable minerals. The Russian government has gone so far as to plan a “<a href="https://en.wikipedia.org/wiki/Northern_Sea_Route" target="_blank">Northern Sea Route</a>” to Asia across Russia’s vast Arctic coastline, normally considered far too icy and dangerous for regular maritime shipping. Such a route could prove geopolitically vital, even if uneconomical, in case Russian ships are denied access to Western ports, as the United Kingdom <a href="https://www.reuters.com/world/uk/uk-bans-russian-ships-docking-its-ports-2022-02-28/" target="_blank">has already done</a>. To this end, Russia has also revived its fleet of nuclear-powered icebreakers and built the world’s first floating nuclear power station to supply electricity to remote regions of Siberia, with more of each on the way. </p>
|
||||
<p> Russia already depends on natural resource exports. That won’t change. But over the coming decades, it will not be the stagnant economies of Europe that will have the greatest demand for energy and minerals. Rather, the maturing industrial economy of China, in addition to the still-industrializing economies of India and other Asian and African countries, will be the primary growth markets for Russia’s natural resources. Neither China, India, Indonesia, the Gulf States, nor any of the many countries in Africa and Latin America chose to <a href="https://en.wikipedia.org/wiki/International_sanctions_during_the_Russo-Ukrainian_War" target="_blank">sanction</a> Russia over the invasion of Ukraine. Today’s economic disassociation may well have unintended consequences for Europe. </p>
|
||||
<!-- Photo Attribution -->
|
||||
<p>
|
||||
<strong>Photo by ALEXEY DRUZHININ/SPUTNIK/AFP via Getty Images</strong>
|
||||
</p>
|
||||
<div>
|
||||
<p><span><em>City Journal</em> is a publication of the Manhattan Institute for Policy Research (MI), a leading free-market think tank. Are you interested in supporting the magazine? As a 501(c)(3) nonprofit, donations in support of MI and <em>City Journal</em> are fully tax-deductible as provided by law (EIN #13-2912529).</span> <a href="http://fakehost/donate?p=will-western-sanctions-accelerate-russian-realignment">DONATE</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1473
packages/readabilityjs/test/test-pages/city-journal/source.html
Normal file
1473
packages/readabilityjs/test/test-pages/city-journal/source.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
https://www.city-journal.org/will-western-sanctions-accelerate-russian-realignment?wallit_nosession=1
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"title": "Flow Network based Generative Models for Non-Iterative Diverse Candidate Generation",
|
||||
"byline": null,
|
||||
"dir": null,
|
||||
"excerpt": "What follows is a high-level overview of this work, for more details refer to our paper. Given a reward and a deterministic episodic environment where episodes end with a ``generate '' action, how do we generate diverse and high-reward s?\n We propose to use Flow Networks to model discrete from which we can sample sequentially (like episodic RL, rather than iteratively as MCMC methods would). We show that our method, GFlowNet, is very useful on a combinatorial domain, drug molecule synthesis, because unlike RL methods it generates diverse s by design.",
|
||||
"siteName": null,
|
||||
"publishedDate": null,
|
||||
"readerable": true
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<div id="readability-page-1" class="page">
|
||||
<div>
|
||||
<center>
|
||||
<a href="http://folinoid.com/">[Home]</a>
|
||||
</center>
|
||||
<center>
|
||||
<b><a href="https://folinoid.com/">Emmanuel Bengio</a>, <a href="https://mj10.github.io/">Moksh Jain</a>, <a href="https://scholar.google.com/citations?user=TpuvCSwAAAAJ&hl=en">Maksym Korablyov</a>, <a href="https://www.cs.mcgill.ca/~dprecup/">Doina Precup</a>, <a href="https://yoshuabengio.org/">Yoshua Bengio</a></b>
|
||||
</center><br>
|
||||
<center>
|
||||
<b><a href="https://arxiv.org/abs/2106.04399">arXiv preprint</a>, <a href="https://github.com/bengioe/gflownet">code</a></b><br> also see the <b><a href="https://arxiv.org/abs/2111.09266">GFlowNet Foundations</a></b> paper<br> and a more recent (and thorough) <a href="https://tinyurl.com/gflownet-tutorial">tutorial on the framework</a>.
|
||||
</center>
|
||||
<p><i>What follows is a high-level overview of this work, for more details refer to our paper.</i> Given a reward <span><span></span></span> and a deterministic episodic environment where episodes end with a ``generate <span><span></span></span>'' action, how do we generate diverse and high-reward <span><span></span></span>s?<br> We propose to use <i>Flow Networks</i> to model discrete <span><span></span></span> from which we can sample sequentially (like episodic RL, rather than iteratively as MCMC methods would). We show that our method, <b>GFlowNet</b>, is very useful on a combinatorial domain, drug molecule synthesis, because unlike RL methods it generates diverse <span><span></span></span>s by design.<br>
|
||||
<a name="s2" id="s2"></a>
|
||||
</p>
|
||||
<h3> Flow Networks </h3>
|
||||
<p>A flow network is a directed graph with <i>sources</i> and <i>sinks</i>, and edges carrying some amount of flow between them through intermediate nodes -- think of pipes of water. For our purposes, we define a flow network with a single source, the root or <span><span></span></span>; the sinks of the network correspond to the terminal states. We'll assign to each sink <span><span></span></span> an ``out-flow'' <span><span></span></span>.</p>
|
||||
<center>
|
||||
</center>
|
||||
<p>Given the graph structure and the out-flow of the sinks, we wish to calculate a valid <i>flow</i> between nodes, e.g. how much water each pipe is carrying. Generally there can be infinite solutions, but this is not a problem here -- any valid solution will do. For example above, there is almost no flow between <span><span></span></span> and <span><span></span></span> that goes through <span><span></span></span>, it all goes through <span><span></span></span>, but the reverse solution would also be a valid flow.<br> Why is this useful? Such a construction corresponds to a generative model. If we follow the flow, we'll end up in a terminal state, a sink, with probability <span><span></span></span>. On top of that, we'll have the property that the in-flow of <span><span></span></span>--the flow of the unique source--is <span><span></span></span>, the partition function. If we assign to each intermediate node a <i>state</i> and to each edge an <i>action</i>, we recover a useful MDP.<br> Let <span><span></span></span> be the flow between <span><span></span></span> and <span><span></span></span>, where <span><span></span></span>, i.e. <span><span></span></span> is the (deterministic) state transitioned to from state <span><span></span></span> and action <span><span></span></span>. Let <span><span><span></span></span></span> then following policy <span><span></span></span>, starting from <span><span></span></span>, leads to terminal state <span><span></span></span> with probability <span><span></span></span> (see the paper for proofs and more rigorous explanations).<br>
|
||||
<a name="s3" id="s3"></a>
|
||||
</p>
|
||||
<h3> Approximating Flow Networks </h3>
|
||||
<p>As you may suspect, there are only few scenarios in which we can build the above graph explicitly. For drug-like molecules, it would have around <span><span></span></span> nodes!<br> Instead, we resort to function approximation, just like deep RL resorts to it when computing the (action-)value functions of MDPs.<br> Our goal here is to approximate the flow <span><span></span></span>. Earlier we called a <i>valid</i> flow one that correctly routed all the flow from the source to the sinks through the intermediary nodes. Let's be more precise. For some node <span><span></span></span>, let the in-flow <span><span></span></span> be the sum of incoming flows: <span><span><span></span></span></span> Here the set <span><span></span></span> is the set of state-action pairs that lead to <span><span></span></span>. Now, let the out-flow be the sum of outgoing flows--or the reward if <span><span></span></span> is terminal: <span><span><span></span></span></span> Note that we reused <span><span></span></span>. This is because for a valid flow, the in-flow is equal to the out-flow, i.e. the flow through <span><span></span></span>, <span><span></span></span>. Here <span><span></span></span> is the set of valid actions in state <span><span></span></span>, which is the empty set when <span><span></span></span> is a sink. <span><span></span></span> is 0 unless <span><span></span></span> is a sink, in which case <span><span></span></span>.<br> We can thus call the set of these equalities for all states <span><span></span></span> the <i>flow consistency equations</i>: <span><span><span></span></span></span></p>
|
||||
<center>
|
||||
</center>
|
||||
<p>Here the set of parents <span><span></span></span> is <span><span></span></span>, and <span><span></span></span>.<br> By now our RL senses should be tingling. We've defined a value function recursively, with two quantities that need to match.<br>
|
||||
<a name="s4" id="s4"></a>
|
||||
</p>
|
||||
<h4> A TD-Like Objective </h4>
|
||||
<p>Just like one can cast the Bellman equations into TD objectives, so do we cast the flow consistency equations into an objective. We want <span><span></span></span> that minimizes the square difference between the two sides of the equations, but we add a few bells and whistles: <span><span><span></span></span></span> First, we match the <span><span></span></span> of each side, which is important since as intermediate nodes get closer to the root, their flow will become exponentially bigger (remember that <span><span></span></span>), but we care equally about all nodes. Second, we predict <span><span></span></span> for the same reasons. Finally, we add an <span><span></span></span> value inside the <span><span></span></span>; this doesn't change the minima of the objective, but gives more gradient weight to large values and less to small values.<br> We show in the paper that a minimizer of this objective achieves our desiderata, which is to have <span><span></span></span> when sampling from <span><span></span></span> as defined above.<br>
|
||||
<a name="s5" id="s5"></a>
|
||||
</p>
|
||||
<h3> GFlowNet as Amortized Sampling with an OOD Potential </h3>
|
||||
<p>It is interesting to compare GFlowNet with Monte-Carlo Markov Chain (MCMC) methods. MCMC methods can be used to sample from a distribution for which there is no analytical sampling formula but an energy function or unnormalized probability function is available. In our context, this unnormalized probability function is our reward function <span><span></span></span>.<br> Like MCMC methods, GFlowNet can turn a given energy function into samples but it does it in an amortized way, converting the cost a lot of very expensive MCMC trajectories (to obtain each sample) into the cost training a generative model (in our case a generative policy which sequentially builds up <span><span></span></span>). Sampling from the generative model is then very cheap (e.g. adding one component at a time to a molecule) compared to an MCMC. But the most important gain may not be just computational, but in terms of the ability to discover new modes of the reward function.<br> MCMC methods are iterative, making many small noisy steps, which can converge in the neighborhood of a mode, and with some probability jump from one mode to a nearby one. However, if two modes are far from each other, MCMC can require <i>exponential</i> time to mix between the two. If in addition the modes occupy a tiny volume of the state space, the chances of initializing a chain near one of the unknown modes is also tiny, and the MCMC approach becomes unsatisfactory. Whereas such a situation seems hopeless with MCMC, GFlowNet has the potential to discover modes and jump there directly, if there is structure that relates the modes that it already knows, and if its inductive biases and training procedure make it possible to generalize there.<br> GFlowNet does not need to perfectly know where the modes are: it is sufficient to make guesses which occasionally work well. Like for MCMC methods, once a point in the region of new mode is discovered, further training of GFlowNet will sculpt that mode and zoom in on its peak.<br> Note that we can put <span><span></span></span> to some power <span><span></span></span>, a coefficient which acts like a temperature, and <span><span></span></span>, making it possible to focus more or less on the highest modes (versus spreading probability mass more uniformly).<br>
|
||||
<a name="s6" id="s6"></a>
|
||||
</p>
|
||||
<h3> Generating molecule graphs </h3>
|
||||
<p>The motivation for this work is to be able to generate diverse molecules from a proxy reward <span><span></span></span> that is imprecise because it comes from biochemical simulations that have a high uncertainty. As such, we do not care about the maximizer as RL methods would, but rather about a set of ``good enough'' candidates to send to a true biochemical assay.<br> Another motivation is to have diversity: by fitting the distribution of rewards rather than trying to maximize the expected reward, we're likely to find more modes than if we were being greedy after having found a good enough mode, which again and again we've found RL methods such as PPO to do.<br> Here we generate molecule graphs via a sequence of additive edits, i.e. we progressively build the graph by adding new leaf nodes to it. We also create molecules block-by-block rather than atom-by-atom.<br> We find experimentally that we get both good molecules, and diverse ones. We compare ourselves to PPO and MARS (an MCMC-based method).<br> Figure 3 shows that we're fitting a distribution that makes sense. If we change the reward by exponentiating it as <span><span></span></span> with <span><span></span></span>, this shifts the reward distribution to the right.<br> Figure 4 shows the top-<span><span></span></span> found as a function of the number of episodes.</p>
|
||||
<center>
|
||||
<img src="http://fakehost/test/gfn_fig34.png" width="650px">
|
||||
</center>
|
||||
<p> Finally, Figure 5 shows that using a biochemical measure of diversity to estimate the number of distinct modes found, GFlowNet finds much more varied candidates.</p>
|
||||
<center>
|
||||
<img src="http://fakehost/test/gfn_fig5.png" width="650px">
|
||||
</center><br>
|
||||
<h4> Active Learning experiments </h4>
|
||||
<p>The above experiments assume access to a reward <span><span></span></span> that is cheap to evaluate. In fact it uses a neural network <i>proxy</i> trained from a large dataset of molecules. This setup isn't quite what we would get when interacting with biochemical assays, where we'd have access to much fewer data. To emulate such a setting, we consider our oracle to be a <i>docking simulation</i> (which is relatively expensive to run, ~30 cpu seconds).<br> In this setting, there is a limited budget for calls to the true oracle <span><span></span></span>. We use a proxy <span><span></span></span> initialized by training on a limited dataset of <span><span></span></span> pairs <span><span></span></span>, where <span><span></span></span> is the true reward from the oracle. The generative model (<span><span></span></span>) is then trained to fit <span><span></span></span> but as predicted by the proxy <span><span></span></span>. We then sample a batch <span><span></span></span> where <span><span></span></span>, which is evaluated with the oracle <span><span></span></span>. The proxy <span><span></span></span> is updated with this newly acquired and labeled batch, and the process is repeated for <span><span></span></span> iterations.<br> By doing this on the molecule setting we again find that we can generate better molecules. This showcases the importance of having these diverse candidates.</p>
|
||||
<center>
|
||||
<img src="http://fakehost/test/gfn_fig7.png" width="325px">
|
||||
</center>
|
||||
<p> For more figures, experiments and explanations, check out <a href="https://arxiv.org/abs/2106.04399">the paper</a>, or reach out to us!<br>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
3142
packages/readabilityjs/test/test-pages/gflownet/source.html
Normal file
3142
packages/readabilityjs/test/test-pages/gflownet/source.html
Normal file
File diff suppressed because one or more lines are too long
1
packages/readabilityjs/test/test-pages/gflownet/url.txt
Normal file
1
packages/readabilityjs/test/test-pages/gflownet/url.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
http://folinoid.com/w/gflownet/
|
||||
|
|
@ -29,12 +29,18 @@ const StyledAvatar = styled(Root, {
|
|||
verticalAlign: 'middle',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
border: '1px solid $grayBorder',
|
||||
})
|
||||
|
||||
const StyledImage = styled(Image, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: '48%',
|
||||
|
||||
'&:hover': {
|
||||
opacity: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledFallback = styled(Fallback, {
|
||||
|
|
@ -43,5 +49,8 @@ const StyledFallback = styled(Fallback, {
|
|||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'dodgerblue',
|
||||
fontSize: '$2',
|
||||
fontWeight: 700,
|
||||
backgroundColor: '$avatarBg',
|
||||
color: '$avatarFont',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function AvatarDropdown(props: AvatarDropdownProps): JSX.Element {
|
|||
<HStack alignment="center" css={{ gap: '6px' }}>
|
||||
<Avatar
|
||||
imageURL={props.profileImageURL}
|
||||
height='30px'
|
||||
height='32px'
|
||||
fallbackText={props.userInitials}
|
||||
/>
|
||||
</HStack>
|
||||
|
|
|
|||
|
|
@ -63,10 +63,17 @@ const StyledLabel = styled(Label, {
|
|||
cursor: 'default',
|
||||
})
|
||||
|
||||
export type DropdownAlignment =
|
||||
| 'start'
|
||||
| 'end'
|
||||
| 'center'
|
||||
|
||||
type DropdownProps = {
|
||||
labelText?: string
|
||||
triggerElement: React.ReactNode
|
||||
children: React.ReactNode
|
||||
styledArrow?: boolean
|
||||
align?: DropdownAlignment
|
||||
}
|
||||
|
||||
export const DropdownSeparator = styled(Separator, {
|
||||
|
|
@ -102,10 +109,11 @@ export function Dropdown(props: DropdownProps): JSX.Element {
|
|||
// remove focus from dropdown
|
||||
;(document.activeElement as HTMLElement).blur()
|
||||
}}
|
||||
align={props.align ? props.align : 'center'}
|
||||
>
|
||||
{props.labelText && <StyledLabel>{props.labelText}</StyledLabel>}
|
||||
{props.children}
|
||||
<StyledArrow offset={20} />
|
||||
{props.styledArrow && <StyledArrow offset={20} />}
|
||||
</DropdownContent>
|
||||
</Root>
|
||||
)
|
||||
|
|
|
|||
77
packages/web/components/elements/Tooltip.tsx
Normal file
77
packages/web/components/elements/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React, { FC } from 'react';
|
||||
import { styled, keyframes } from '@stitches/react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
const slideUpAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateY(2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
});
|
||||
|
||||
const slideRightAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateX(-2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateX(0)' },
|
||||
});
|
||||
|
||||
const slideDownAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateY(-2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
});
|
||||
|
||||
const slideLeftAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateX(2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateX(0)' },
|
||||
});
|
||||
|
||||
const StyledContent = styled(TooltipPrimitive.Content, {
|
||||
borderRadius: 4,
|
||||
padding: '8px 13px',
|
||||
fontSize: 12,
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: '#1C1C1E',
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
animationDuration: '400ms',
|
||||
animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
animationFillMode: 'forwards',
|
||||
willChange: 'transform, opacity',
|
||||
'&[data-state="delayed-open"]': {
|
||||
'&[data-side="top"]': { animationName: slideDownAndFade },
|
||||
'&[data-side="right"]': { animationName: slideLeftAndFade },
|
||||
'&[data-side="bottom"]': { animationName: slideUpAndFade },
|
||||
'&[data-side="left"]': { animationName: slideRightAndFade },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const StyledArrow = styled(TooltipPrimitive.Arrow, {
|
||||
fill: '#1C1C1E',
|
||||
});
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
export const TooltipContent = StyledContent;
|
||||
export const TooltipArrow = StyledArrow;
|
||||
|
||||
|
||||
type TooltipWrappedProps = {
|
||||
tooltipContent: string,
|
||||
tooltipSide ?: TooltipPrimitive.TooltipContentProps['side'],
|
||||
align?: TooltipPrimitive.TooltipContentProps['align'],
|
||||
alignOffset?: TooltipPrimitive.TooltipContentProps['alignOffset']
|
||||
arrowStyles?: TooltipPrimitive.TooltipArrowProps['style']
|
||||
style?: TooltipPrimitive.TooltipContentProps['style']
|
||||
}
|
||||
|
||||
export const TooltipWrapped: FC<TooltipWrappedProps> = ({children, tooltipContent, tooltipSide, ...props}) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={5} side={tooltipSide} {...props} >
|
||||
{tooltipContent}
|
||||
<TooltipArrow style={props.arrowStyles} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,22 @@
|
|||
import { Box } from './../../../components/elements/LayoutPrimitives'
|
||||
import { Box } from '../../elements/LayoutPrimitives'
|
||||
import { useReadingProgressAnchor } from '../../../lib/hooks/useReadingProgressAnchor'
|
||||
import {
|
||||
useScrollWatcher,
|
||||
ScrollOffsetChangeset,
|
||||
useScrollWatcher,
|
||||
} from '../../../lib/hooks/useScrollWatcher'
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
|
||||
import { Tweet } from 'react-twitter-widgets'
|
||||
import { render } from 'react-dom'
|
||||
import { isDarkTheme } from '../../../lib/themeUpdater'
|
||||
import useDebounce from '../../../lib/hooks/useDebounce'
|
||||
import { debounce } from 'lodash'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
|
||||
export type ArticleProps = {
|
||||
articleId: string
|
||||
|
|
@ -23,12 +24,15 @@ export type ArticleProps = {
|
|||
initialAnchorIndex: number
|
||||
initialReadingProgress?: number
|
||||
scrollElementRef: MutableRefObject<HTMLDivElement | null>
|
||||
articleMutations: ArticleMutations
|
||||
}
|
||||
|
||||
export function Article(props: ArticleProps): JSX.Element {
|
||||
const highlightTheme = isDarkTheme() ? 'dark' : 'default'
|
||||
|
||||
const [readingProgress, setReadingProgress] = useState(props.initialReadingProgress)
|
||||
const [readingProgress, setReadingProgress] = useState(
|
||||
props.initialReadingProgress
|
||||
)
|
||||
|
||||
const [readingAnchorIndex, setReadingAnchorIndex] = useState(
|
||||
props.initialAnchorIndex
|
||||
|
|
@ -41,14 +45,30 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
|
||||
useReadingProgressAnchor(articleContentRef, setReadingAnchorIndex)
|
||||
|
||||
const debouncedReadingProgress = useDebounce(readingProgress, 1000);
|
||||
const debouncedSetReadingProgress = useMemo(
|
||||
() =>
|
||||
debounce((readingProgress: number) => {
|
||||
console.log('setReadingProgress', readingProgress)
|
||||
setReadingProgress(readingProgress)
|
||||
}, 2000),
|
||||
[]
|
||||
)
|
||||
|
||||
// Stop the invocation of the debounced function
|
||||
// after unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSetReadingProgress.cancel()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
if (!debouncedReadingProgress) return
|
||||
await articleReadingProgressMutation({
|
||||
if (!readingProgress) return
|
||||
await props.articleMutations.articleReadingProgressMutation({
|
||||
id: props.articleId,
|
||||
readingProgressPercent: debouncedReadingProgress,
|
||||
// round reading progress to 100% if more than that
|
||||
readingProgressPercent: readingProgress > 100 ? 100 : readingProgress,
|
||||
readingProgressAnchorIndex: readingAnchorIndex,
|
||||
})
|
||||
})()
|
||||
|
|
@ -56,20 +76,16 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
// We don't react to changes to readingAnchorIndex we
|
||||
// only care about the progress (scroll position) changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.articleId,
|
||||
debouncedReadingProgress,
|
||||
readingAnchorIndex,
|
||||
])
|
||||
}, [props.articleId, readingProgress])
|
||||
|
||||
// Post message to webkit so apple app embeds get progress updates
|
||||
useEffect(() => {
|
||||
if (typeof window?.webkit != 'undefined') {
|
||||
window.webkit.messageHandlers.readingProgressUpdate?.postMessage({
|
||||
progress: debouncedReadingProgress,
|
||||
progress: readingProgress,
|
||||
})
|
||||
}
|
||||
}, [readingProgress, debouncedReadingProgress])
|
||||
}, [readingProgress])
|
||||
|
||||
const setScrollWatchedElement = useScrollWatcher(
|
||||
(changeset: ScrollOffsetChangeset) => {
|
||||
|
|
@ -79,13 +95,13 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
(changeset.current.y + scrollContainer.clientHeight) /
|
||||
scrollContainer.scrollHeight
|
||||
|
||||
setReadingProgress(newReadingProgress * 100)
|
||||
debouncedSetReadingProgress(newReadingProgress * 100)
|
||||
} else if (window && window.document.scrollingElement) {
|
||||
const newReadingProgress =
|
||||
window.scrollY / window.document.scrollingElement.scrollHeight
|
||||
const adjustedReadingProgress =
|
||||
newReadingProgress > 0.92 ? 1 : newReadingProgress
|
||||
setReadingProgress(adjustedReadingProgress * 100)
|
||||
debouncedSetReadingProgress(adjustedReadingProgress * 100)
|
||||
}
|
||||
},
|
||||
1000
|
||||
|
|
@ -152,9 +168,15 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
}
|
||||
|
||||
if (props.scrollElementRef.current) {
|
||||
props.scrollElementRef.current?.scroll(0, calculateOffset(anchorElement))
|
||||
props.scrollElementRef.current?.scroll(
|
||||
0,
|
||||
calculateOffset(anchorElement)
|
||||
)
|
||||
} else {
|
||||
window.document.documentElement.scroll(0, calculateOffset(anchorElement))
|
||||
window.document.documentElement.scroll(
|
||||
0,
|
||||
calculateOffset(anchorElement)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ import { updateThemeLocally } from '../../../lib/themeUpdater'
|
|||
import { EditLabelsModal } from './EditLabelsModal'
|
||||
import Script from 'next/script'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
|
||||
type ArticleContainerProps = {
|
||||
viewerUsername: string
|
||||
article: ArticleAttributes
|
||||
articleMutations: ArticleMutations
|
||||
scrollElementRef: MutableRefObject<HTMLDivElement | null>
|
||||
isAppleAppEmbed: boolean
|
||||
highlightBarDisabled: boolean
|
||||
|
|
@ -121,12 +123,16 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Script async src="/static/scripts/mathJaxConfiguration.js" />
|
||||
<Script
|
||||
async
|
||||
id="MathJax-script"
|
||||
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
|
||||
/>
|
||||
{!props.isAppleAppEmbed && (
|
||||
<>
|
||||
<Script async src="/static/scripts/mathJaxConfiguration.js" />
|
||||
<Script
|
||||
async
|
||||
id="MathJax-script"
|
||||
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Box
|
||||
id="article-container"
|
||||
css={{
|
||||
|
|
@ -187,6 +193,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
content={props.article.content}
|
||||
initialAnchorIndex={props.article.readingProgressAnchorIndex}
|
||||
scrollElementRef={props.scrollElementRef}
|
||||
articleMutations={props.articleMutations}
|
||||
/>
|
||||
<Button
|
||||
style="ghost"
|
||||
|
|
@ -216,6 +223,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
showNotesSidebar={showNotesSidebar}
|
||||
highlightsBaseURL={props.highlightsBaseURL}
|
||||
setShowNotesSidebar={setShowNotesSidebar}
|
||||
articleMutations={props.articleMutations}
|
||||
/>
|
||||
{showReportIssuesModal ? (
|
||||
<ReportIssuesModal
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { makeHighlightStartEndOffset } from '../../../lib/highlights/highlightGe
|
|||
import type { HighlightLocation } from '../../../lib/highlights/highlightGenerator'
|
||||
import { useSelection } from '../../../lib/highlights/useSelection'
|
||||
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
|
||||
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
|
||||
import { shareHighlightToFeedMutation } from '../../../lib/networking/mutations/shareHighlightToFeedMutation'
|
||||
import { shareHighlightCommentMutation } from '../../../lib/networking/mutations/updateShareHighlightCommentMutation'
|
||||
import {
|
||||
|
|
@ -18,9 +17,9 @@ import { HighlightNoteModal } from './HighlightNoteModal'
|
|||
import { ShareHighlightModal } from './ShareHighlightModal'
|
||||
import { HighlightPostToFeedModal } from './HighlightPostToFeedModal'
|
||||
import { HighlightsModal } from './HighlightsModal'
|
||||
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
|
||||
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
|
||||
type HighlightsLayerProps = {
|
||||
viewerUsername: string
|
||||
|
|
@ -33,6 +32,7 @@ type HighlightsLayerProps = {
|
|||
showNotesSidebar: boolean
|
||||
highlightsBaseURL: string
|
||||
setShowNotesSidebar: React.Dispatch<React.SetStateAction<boolean>>
|
||||
articleMutations: ArticleMutations
|
||||
}
|
||||
|
||||
type HighlightModalAction = 'none' | 'addComment' | 'postToFeed' | 'share'
|
||||
|
|
@ -88,7 +88,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
const highlightId = id || focusedHighlight?.id
|
||||
if (!highlightId) return
|
||||
|
||||
const didDeleteHighlight = await deleteHighlightMutation(highlightId)
|
||||
const didDeleteHighlight = await props.articleMutations.deleteHighlightMutation(highlightId)
|
||||
|
||||
if (didDeleteHighlight) {
|
||||
removeHighlights(
|
||||
|
|
@ -191,7 +191,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
existingHighlights: highlights,
|
||||
highlightStartEndOffsets: highlightLocations,
|
||||
annotation: note,
|
||||
})
|
||||
}, props.articleMutations)
|
||||
|
||||
if (!result.highlights || result.highlights.length == 0) {
|
||||
// TODO: show an error message
|
||||
|
|
@ -407,7 +407,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
if (focusedHighlight) {
|
||||
const annotation = event.annotation ?? ''
|
||||
|
||||
const result = await updateHighlightMutation({
|
||||
const result = await props.articleMutations.updateHighlightMutation({
|
||||
highlightId: focusedHighlight.id,
|
||||
annotation: event.annotation ?? '',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export default function PdfArticleContainer(
|
|||
instance.setSelectedAnnotation(null)
|
||||
},
|
||||
}
|
||||
return [copy, remove, share]
|
||||
return [copy, remove]
|
||||
}
|
||||
|
||||
instance = await PSPDFKit.load({
|
||||
|
|
|
|||
|
|
@ -207,6 +207,12 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
|||
if (activeCardId && !alreadyScrolled.current) {
|
||||
scrollToActiveCard(activeCardId)
|
||||
alreadyScrolled.current = true
|
||||
|
||||
if (activeItem) {
|
||||
console.log('refreshing')
|
||||
// refresh items on home feed
|
||||
performActionOnItem('refresh', activeItem)
|
||||
}
|
||||
}
|
||||
}, [activeCardId, scrollToActiveCard])
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,10 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
readerFontTransparent: 'rgba(61,61,61,0.65)',
|
||||
readerHeader: '3D3D3D',
|
||||
readerTableHeader: '#FFFFFF',
|
||||
|
||||
// Avatar Fallback color
|
||||
avatarBg: '#FFFFFF',
|
||||
avatarFont: '#0A0806',
|
||||
},
|
||||
},
|
||||
media: {
|
||||
|
|
@ -168,10 +172,10 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
md: '(min-width: 768px)',
|
||||
lg: '(min-width: 992px)',
|
||||
xl: '(min-width: 1200px)',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const darkTheme = createTheme(ThemeId.Dark, {
|
||||
const darkThemeSpec = {
|
||||
colors: {
|
||||
...yellowDark, // Brand
|
||||
...orangeDark, //Accent
|
||||
|
|
@ -204,91 +208,26 @@ export const darkTheme = createTheme(ThemeId.Dark, {
|
|||
readerFontTransparent: 'rgba(185,185,185,0.65)',
|
||||
readerHeader: '#b9b9b9',
|
||||
readerTableHeader: '#FFFFFF',
|
||||
|
||||
// Avatar Fallback color
|
||||
avatarBg: '#000000',
|
||||
avatarFont: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const darkerTheme = createTheme(ThemeId.Darker, {
|
||||
colors: {
|
||||
...yellowDark, // Brand
|
||||
...orangeDark, //Accent
|
||||
// Dark and Darker theme now match each other.
|
||||
// Use the darkThemeSpec object to make updates.
|
||||
export const darkTheme = createTheme(ThemeId.Dark, darkThemeSpec)
|
||||
export const darkerTheme = createTheme(ThemeId.Darker, darkThemeSpec)
|
||||
|
||||
// Grayscale
|
||||
grayBase: grayDark.gray1,
|
||||
grayBgSubtle: grayDark.gray2,
|
||||
grayBg: grayDark.gray3,
|
||||
grayBgHover: grayDark.gray4,
|
||||
grayBgActive: grayDark.gray5,
|
||||
grayLine: grayDark.gray6,
|
||||
grayBorder: grayDark.gray7,
|
||||
grayBorderHover: grayDark.gray8,
|
||||
graySolid: grayDark.gray9,
|
||||
graySolidHover: grayDark.gray10,
|
||||
grayText: grayDark.gray11,
|
||||
grayTextContrast: grayDark.gray12,
|
||||
|
||||
// Semantic Colors
|
||||
overlay: blackA.blackA9,
|
||||
highlightBackground: 'rgb(255,234,159)',
|
||||
highlight: '#FFD234',
|
||||
highlightText: 'rgba(255, 210, 52)',
|
||||
error: '#FA5E4A',
|
||||
|
||||
// Brand Colors
|
||||
loginBg: '#FFD492',
|
||||
|
||||
// Reader Colors
|
||||
readerBg: '#000000',
|
||||
readerFont: '#888888',
|
||||
readerFontTransparent: 'rgba(136,136,136,0.65)',
|
||||
readerHeader: '#888888',
|
||||
readerTableHeader: '',
|
||||
},
|
||||
})
|
||||
|
||||
export const lighterTheme = createTheme(ThemeId.Lighter, {
|
||||
colors: {
|
||||
...yellow, // Brand
|
||||
...orange, //Accent
|
||||
|
||||
// Grayscale
|
||||
grayBase: '#F8F8F8',
|
||||
grayBgActive: '#FFFFFF',
|
||||
grayBorder: 'rgba(0, 0, 0, 0.06)',
|
||||
grayTextContrast: '#3A3939',
|
||||
|
||||
// Grayscale
|
||||
grayBgSubtle: gray.gray2,
|
||||
grayBg: gray.gray3,
|
||||
grayBgHover: gray.gray4,
|
||||
grayLine: gray.gray6,
|
||||
grayBorderHover: gray.gray8,
|
||||
graySolid: gray.gray9,
|
||||
graySolidHover: gray.gray10,
|
||||
grayText: gray.gray11,
|
||||
|
||||
// Semantic Colors
|
||||
overlay: blackA.blackA9,
|
||||
highlightBackground: 'rgb(255,234,159)',
|
||||
highlight: '#FFD234',
|
||||
error: '#FA5E4A',
|
||||
|
||||
// Brand Colors
|
||||
loginBg: '#FFD492',
|
||||
|
||||
// Reader Colors
|
||||
readerBg: '#E5DDD5',
|
||||
readerFont: '#3D3D3D',
|
||||
readerFontTransparent: 'rgba(61,61,61,0.65)',
|
||||
readerHeader: '',
|
||||
readerTableHeader: '',
|
||||
},
|
||||
})
|
||||
// Lighter theme now matches the default theme.
|
||||
// This only exists for users that might still have a lighter theme set
|
||||
export const lighterTheme = createTheme(ThemeId.Lighter, {})
|
||||
|
||||
// Apply global styles in here
|
||||
export const globalStyles = globalCss({
|
||||
'*': {
|
||||
'&:focus': {
|
||||
|
||||
outline: 'none',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
|
|
@ -299,6 +238,6 @@ export const globalStyles = globalCss({
|
|||
'.article-inner-css': {
|
||||
'::selection': {
|
||||
background: '$highlightBackground',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
14
packages/web/lib/articleActions.tsx
Normal file
14
packages/web/lib/articleActions.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Highlight } from "./networking/fragments/highlightFragment"
|
||||
import { ArticleReadingProgressMutationInput } from "./networking/mutations/articleReadingProgressMutation"
|
||||
import { CreateHighlightInput } from "./networking/mutations/createHighlightMutation"
|
||||
import { MergeHighlightInput, MergeHighlightOutput } from "./networking/mutations/mergeHighlightMutation"
|
||||
import { UpdateHighlightInput } from "./networking/mutations/updateHighlightMutation"
|
||||
|
||||
|
||||
export type ArticleMutations = {
|
||||
createHighlightMutation: (input: CreateHighlightInput) => Promise<Highlight | undefined>
|
||||
deleteHighlightMutation: (highlightId: string) => Promise<boolean>
|
||||
mergeHighlightMutation: (input: MergeHighlightInput) => Promise<MergeHighlightOutput | undefined>
|
||||
updateHighlightMutation: (input: UpdateHighlightInput) => Promise<string | undefined>
|
||||
articleReadingProgressMutation: (input: ArticleReadingProgressMutationInput) => Promise<boolean>
|
||||
}
|
||||
|
|
@ -9,9 +9,8 @@ import {
|
|||
import type { HighlightLocation } from './highlightGenerator'
|
||||
import { extendRangeToWordBoundaries } from './normalizeHighlightRange'
|
||||
import type { Highlight } from '../networking/fragments/highlightFragment'
|
||||
import { createHighlightMutation } from '../networking/mutations/createHighlightMutation'
|
||||
import { removeHighlights } from './deleteHighlight'
|
||||
import { mergeHighlightMutation } from '../networking/mutations/mergeHighlightMutation'
|
||||
import { ArticleMutations } from '../articleActions'
|
||||
|
||||
type CreateHighlightInput = {
|
||||
selection: SelectionAttributes
|
||||
|
|
@ -28,7 +27,8 @@ type CreateHighlightOutput = {
|
|||
}
|
||||
|
||||
export async function createHighlight(
|
||||
input: CreateHighlightInput
|
||||
input: CreateHighlightInput,
|
||||
articleMutations: ArticleMutations
|
||||
): Promise<CreateHighlightOutput> {
|
||||
|
||||
if (!input.selection.selection) {
|
||||
|
|
@ -89,7 +89,7 @@ export async function createHighlight(
|
|||
let keptHighlights = input.existingHighlights
|
||||
|
||||
if (shouldMerge) {
|
||||
const result = await mergeHighlightMutation({
|
||||
const result = await articleMutations.mergeHighlightMutation({
|
||||
...newHighlightAttributes,
|
||||
overlapHighlightIdList: input.selection.overlapHighlights,
|
||||
})
|
||||
|
|
@ -99,7 +99,7 @@ export async function createHighlight(
|
|||
($0) => !input.selection.overlapHighlights.includes($0.id)
|
||||
)
|
||||
} else {
|
||||
highlight = await createHighlightMutation(newHighlightAttributes)
|
||||
highlight = await articleMutations.createHighlightMutation(newHighlightAttributes)
|
||||
}
|
||||
|
||||
if (highlight) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch'
|
|||
import { RefObject } from 'react'
|
||||
import type { Highlight } from '../networking/fragments/highlightFragment'
|
||||
import { interpolationSearch } from './interpolationSearch'
|
||||
import { highlightIdAttribute, highlightNoteIdAttribute } from './highlightHelpers'
|
||||
import {
|
||||
highlightIdAttribute,
|
||||
highlightNoteIdAttribute,
|
||||
noteImage,
|
||||
} from './highlightHelpers'
|
||||
|
||||
const highlightTag = 'omnivore_highlight'
|
||||
const highlightClassname = 'highlight'
|
||||
|
|
@ -10,7 +14,8 @@ const highlightWithNoteClassName = 'highlight_with_note'
|
|||
const articleContainerId = 'article-container'
|
||||
export const maxHighlightLength = 2000
|
||||
|
||||
const nonParagraphTagsRegEx = /^(a|b|basefont|bdo|big|em|font|i|s|small|span|strike|strong|su[bp]|tt|u|code|mark)$/i
|
||||
const nonParagraphTagsRegEx =
|
||||
/^(a|b|basefont|bdo|big|em|font|i|s|small|span|strike|strong|su[bp]|tt|u|code|mark)$/i
|
||||
const highlightContentRegex = new RegExp(
|
||||
`<${highlightTag}>([\\s\\S]*)<\\/${highlightTag}>`,
|
||||
'i'
|
||||
|
|
@ -47,10 +52,8 @@ export type HighlightNodeAttributes = {
|
|||
export function makeHighlightStartEndOffset(
|
||||
highlight: Highlight
|
||||
): HighlightLocation {
|
||||
const {
|
||||
startLocation: highlightTextStart,
|
||||
endLocation: highlightTextEnd,
|
||||
} = nodeAttributesFromHighlight(highlight)
|
||||
const { startLocation: highlightTextStart, endLocation: highlightTextEnd } =
|
||||
nodeAttributesFromHighlight(highlight)
|
||||
return {
|
||||
id: highlight.id,
|
||||
start: highlightTextStart,
|
||||
|
|
@ -129,7 +132,9 @@ export function makeHighlightNodeAttributes(
|
|||
}
|
||||
|
||||
const newHighlightSpan = document.createElement('span')
|
||||
newHighlightSpan.className = withNote ? highlightWithNoteClassName : highlightClassname
|
||||
newHighlightSpan.className = withNote
|
||||
? highlightWithNoteClassName
|
||||
: highlightClassname
|
||||
newHighlightSpan.setAttribute(highlightIdAttribute, id)
|
||||
customColor &&
|
||||
newHighlightSpan.setAttribute(
|
||||
|
|
@ -146,13 +151,18 @@ export function makeHighlightNodeAttributes(
|
|||
}
|
||||
if (withNote && lastElement) {
|
||||
lastElement.classList.add('last_element')
|
||||
const button = document.createElement('img')
|
||||
button.className = 'highlight_note_button'
|
||||
button.src = '/static/icons/highlight-note-icon.svg'
|
||||
button.alt = 'Add note'
|
||||
button.setAttribute(highlightNoteIdAttribute, id)
|
||||
|
||||
lastElement.appendChild(button)
|
||||
const svg = noteImage()
|
||||
svg.setAttribute(highlightNoteIdAttribute, id)
|
||||
|
||||
const ctr = document.createElement('div')
|
||||
ctr.className = 'highlight_note_button'
|
||||
ctr.appendChild(svg)
|
||||
ctr.setAttribute(highlightNoteIdAttribute, id)
|
||||
ctr.setAttribute('width', '14px')
|
||||
ctr.setAttribute('height', '14px')
|
||||
|
||||
lastElement.appendChild(ctr)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -199,9 +209,8 @@ export function generateDiffPatch(range: Range): string {
|
|||
|
||||
export function wrapHighlightTagAroundRange(range: Range): [number, number] {
|
||||
const patch = generateDiffPatch(range)
|
||||
const { highlightTextStart, highlightTextEnd } = selectionOffsetsFromPatch(
|
||||
patch
|
||||
)
|
||||
const { highlightTextStart, highlightTextEnd } =
|
||||
selectionOffsetsFromPatch(patch)
|
||||
return [highlightTextStart, highlightTextEnd]
|
||||
}
|
||||
|
||||
|
|
@ -290,11 +299,7 @@ const selectionOffsetsFromPatch = (
|
|||
}
|
||||
}
|
||||
|
||||
export function getPrefixAndSuffix({
|
||||
patch,
|
||||
}: {
|
||||
patch: string
|
||||
}): {
|
||||
export function getPrefixAndSuffix({ patch }: { patch: string }): {
|
||||
prefix: string
|
||||
suffix: string
|
||||
highlightTextStart: number
|
||||
|
|
@ -305,9 +310,8 @@ export function getPrefixAndSuffix({
|
|||
if (!patch) throw new Error('Invalid patch')
|
||||
const { textNodes } = getArticleTextNodes()
|
||||
|
||||
const { highlightTextStart, highlightTextEnd } = selectionOffsetsFromPatch(
|
||||
patch
|
||||
)
|
||||
const { highlightTextStart, highlightTextEnd } =
|
||||
selectionOffsetsFromPatch(patch)
|
||||
// Searching for the starting text node using interpolation search algorithm
|
||||
const textNodeIndex = interpolationSearch(
|
||||
textNodes.map(({ startIndex: startIndex }) => startIndex),
|
||||
|
|
@ -360,9 +364,11 @@ const fillHighlight = ({
|
|||
highlightTextStart: number
|
||||
highlightTextEnd: number
|
||||
}): FillNodeResponse => {
|
||||
const { node, startIndex: startIndex, startsParagraph } = textNodes[
|
||||
startingTextNodeIndex
|
||||
]
|
||||
const {
|
||||
node,
|
||||
startIndex: startIndex,
|
||||
startsParagraph,
|
||||
} = textNodes[startingTextNodeIndex]
|
||||
const text = node.nodeValue || ''
|
||||
|
||||
const textBeforeHighlightLenght = highlightTextStart - startIndex
|
||||
|
|
|
|||
|
|
@ -24,3 +24,23 @@ export function getHighlightNoteButton(highlightId: string): Element[] {
|
|||
document.querySelectorAll(`[${highlightNoteIdAttribute}='${highlightId}']`)
|
||||
)
|
||||
}
|
||||
|
||||
export function noteImage(): SVGSVGElement {
|
||||
const svgURI = 'http://www.w3.org/2000/svg'
|
||||
const svg = document.createElementNS(svgURI, 'svg')
|
||||
svg.setAttribute('viewBox', '0 0 14 14')
|
||||
svg.setAttribute('width', '14')
|
||||
svg.setAttribute('height', '14')
|
||||
svg.setAttribute('fill', 'none')
|
||||
|
||||
const path = document.createElementNS(svgURI, 'path')
|
||||
path.setAttribute(
|
||||
'd',
|
||||
'M1 5.66602C1 3.7804 1 2.83759 1.58579 2.2518C2.17157 1.66602 3.11438 1.66602 5 1.66602H9C10.8856 1.66602 11.8284 1.66602 12.4142 2.2518C13 2.83759 13 3.7804 13 5.66602V7.66601C13 9.55163 13 10.4944 12.4142 11.0802C11.8284 11.666 10.8856 11.666 9 11.666H4.63014C4.49742 11.666 4.43106 11.666 4.36715 11.6701C3.92582 11.6984 3.50632 11.8722 3.17425 12.1642C3.12616 12.2065 3.07924 12.2534 2.98539 12.3473V12.3473C2.75446 12.5782 2.639 12.6937 2.55914 12.7475C1.96522 13.1481 1.15512 12.8125 1.01838 12.1093C1 12.0148 1 11.8515 1 11.5249V5.66602Z'
|
||||
)
|
||||
path.setAttribute('stroke', 'rgba(255, 210, 52, 0.8)')
|
||||
path.setAttribute('stroke-width', '1.8')
|
||||
path.setAttribute('stroke-linejoin', 'round')
|
||||
svg.appendChild(path)
|
||||
return svg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export function useSelection(
|
|||
document.removeEventListener('touchend', handleFinishTouch)
|
||||
document.removeEventListener('contextmenu', handleFinishTouch)
|
||||
}
|
||||
}, [JSON.stringify(highlightLocations), handleFinishTouch, disabled])
|
||||
}, [highlightLocations, handleFinishTouch, disabled])
|
||||
|
||||
return [selectionAttributes, setSelectionAttributes]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
type ArticleReadingProgressMutationInput = {
|
||||
export type ArticleReadingProgressMutationInput = {
|
||||
id: string
|
||||
readingProgressPercent: number
|
||||
readingProgressAnchorIndex: number
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { gql } from 'graphql-request'
|
|||
import { gqlFetcher } from '../networkHelpers'
|
||||
import { Highlight } from './../fragments/highlightFragment'
|
||||
|
||||
type CreateHighlightInput = {
|
||||
export type CreateHighlightInput = {
|
||||
prefix: string
|
||||
suffix: string
|
||||
quote: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
export async function deleteNewsletterEmailMutation(
|
||||
newsletterEmailId: string
|
||||
): Promise<string | undefined> {
|
||||
const mutation = gql`
|
||||
mutation DeleteNewsletterEmailMutation($newsletterEmailId: ID!) {
|
||||
deleteNewsletterEmail(newsletterEmailId: $newsletterEmailId) {
|
||||
... on DeleteNewsletterEmailSuccess {
|
||||
newsletterEmail {
|
||||
id
|
||||
address
|
||||
}
|
||||
}
|
||||
... on DeleteNewsletterEmailError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation, { newsletterEmailId })
|
||||
console.log('delete email', data)
|
||||
return 'data'
|
||||
} catch (error) {
|
||||
console.log('deleteNewsletterEmailMutation error', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { gql } from 'graphql-request'
|
|||
import { gqlFetcher } from '../networkHelpers'
|
||||
import { Highlight } from './../fragments/highlightFragment'
|
||||
|
||||
type MergeHighlightInput = {
|
||||
export type MergeHighlightInput = {
|
||||
id: string
|
||||
shortId: string
|
||||
articleId: string
|
||||
|
|
@ -14,7 +14,7 @@ type MergeHighlightInput = {
|
|||
overlapHighlightIdList: string[]
|
||||
}
|
||||
|
||||
type MergeHighlightOutput = {
|
||||
export type MergeHighlightOutput = {
|
||||
mergeHighlight: InnerMergeHighlightOutput
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
type UpdateHighlightInput = {
|
||||
export type UpdateHighlightInput = {
|
||||
highlightId: string
|
||||
annotation?: string
|
||||
sharedAt?: string
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { articleFragment } from '../fragments/articleFragment'
|
|||
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
|
||||
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
|
||||
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
|
||||
import { labelFragment } from '../fragments/labelFragment'
|
||||
import { Label } from './useGetLabelsQuery'
|
||||
|
||||
export type LibraryItemsQueryInput = {
|
||||
|
|
@ -34,6 +33,7 @@ type LibraryItemAction =
|
|||
| 'delete'
|
||||
| 'mark-read'
|
||||
| 'mark-unread'
|
||||
| 'refresh'
|
||||
|
||||
export type LibraryItemsData = {
|
||||
articles: LibraryItems
|
||||
|
|
@ -259,6 +259,8 @@ export function useGetLibraryItemsQuery({
|
|||
readingProgressAnchorIndex: 0,
|
||||
})
|
||||
break
|
||||
case 'refresh':
|
||||
await mutate()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ const moduleExports = {
|
|||
'https://apps.apple.com/us/app/omnivore-read-highlight-share/id1564031042',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/install/apple',
|
||||
destination:
|
||||
'https://apps.apple.com/us/app/omnivore-read-highlight-share/id1564031042',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"@radix-ui/react-id": "^0.1.1",
|
||||
"@radix-ui/react-popover": "^0.1.1",
|
||||
"@radix-ui/react-separator": "^0.1.0",
|
||||
"@radix-ui/react-tooltip": "^0.1.7",
|
||||
"@segment/analytics-next": "^1.33.5",
|
||||
"@sentry/nextjs": "^6.16.1",
|
||||
"@stitches/react": "^1.2.5",
|
||||
|
|
@ -50,6 +51,7 @@
|
|||
"@types/cookie": "^0.4.1",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/react": "17.0.2",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/segment-analytics": "^0.0.34",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import dynamic from 'next/dynamic'
|
|||
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
|
||||
import { webBaseURL } from '../../../lib/appConfig'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
|
||||
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
|
||||
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
|
||||
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
|
||||
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
|
||||
|
||||
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
|
||||
() => import('./../../../components/templates/article/PdfArticleContainer'),
|
||||
|
|
@ -70,6 +75,13 @@ export default function Home(): JSX.Element {
|
|||
viewerUsername={viewerData.me?.profile?.username}
|
||||
highlightsBaseURL={`${webBaseURL}/${viewerData.me?.profile?.username}/${slug}/highlights`}
|
||||
fontSize={preferencesData?.fontSize}
|
||||
articleMutations={{
|
||||
createHighlightMutation,
|
||||
deleteHighlightMutation,
|
||||
mergeHighlightMutation,
|
||||
updateHighlightMutation,
|
||||
articleReadingProgressMutation,
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue