Merge commit '6e36bb271f595d6dfe02ddee320980d433917d35' into OMN-189
This commit is contained in:
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
|
||||
@ -8,7 +8,7 @@ struct WebReader: UIViewRepresentable {
|
||||
let articleContent: ArticleContent
|
||||
let item: FeedItem
|
||||
let openLinkAction: (URL) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityRatioUpdater: (Double) -> Void
|
||||
let authToken: String
|
||||
let appEnv: AppEnvironment
|
||||
@ -60,6 +60,8 @@ struct WebReader: UIViewRepresentable {
|
||||
|
||||
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
|
||||
|
||||
@ -5,36 +5,6 @@ import SwiftUI
|
||||
import Views
|
||||
import WebKit
|
||||
|
||||
struct SafariWebLink: Identifiable {
|
||||
let id: UUID
|
||||
let url: URL
|
||||
}
|
||||
|
||||
// TODO: load highlights
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct WebReaderContainerView: View {
|
||||
let item: FeedItem
|
||||
let homeFeedViewModel: HomeFeedViewModel
|
||||
@ -62,7 +32,24 @@ struct WebReaderContainerView: View {
|
||||
)
|
||||
}
|
||||
|
||||
func webViewActionHandler(message: WKScriptMessage) {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -46,8 +46,8 @@ struct WebReaderContent {
|
||||
}
|
||||
|
||||
window.omnivoreArticle = {
|
||||
id: "test",
|
||||
linkId: "test",
|
||||
id: "\(item.id)",
|
||||
linkId: "\(item.id)",
|
||||
slug: "test-slug",
|
||||
createdAt: new Date().toISOString(),
|
||||
savedAt: new Date().toISOString(),
|
||||
|
||||
@ -7,8 +7,10 @@ import Utils
|
||||
import Views
|
||||
import WebKit
|
||||
|
||||
typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
|
||||
|
||||
final class WebReaderCoordinator: NSObject {
|
||||
var webViewActionHandler: (WKScriptMessage) -> Void = { _ in }
|
||||
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void = { _, _ in }
|
||||
var linkHandler: (URL) -> Void = { _ in }
|
||||
var needsReload = true
|
||||
var lastSavedAnnotationID: UUID?
|
||||
@ -34,7 +36,17 @@ final class WebReaderCoordinator: NSObject {
|
||||
|
||||
extension WebReaderCoordinator: WKScriptMessageHandler {
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
webViewActionHandler(message)
|
||||
webViewActionHandler(message, nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension WebReaderCoordinator: WKScriptMessageHandlerWithReply {
|
||||
func userContentController(
|
||||
_: WKUserContentController,
|
||||
didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void
|
||||
) {
|
||||
webViewActionHandler(message, replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,6 @@ public struct ArticleContent {
|
||||
) {
|
||||
self.htmlContent = htmlContent
|
||||
self.highlights = highlights
|
||||
|
||||
print(highlightsJSONString)
|
||||
}
|
||||
|
||||
public var highlightsJSONString: String {
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -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()
|
||||
|
||||
|
||||
@ -6,8 +6,17 @@ 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) // false to skip serevr sync
|
||||
applyStoredTheme(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -32,6 +41,13 @@ const App = () => {
|
||||
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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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/
|
||||
@ -12,11 +12,11 @@ import {
|
||||
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 { debounce } from 'lodash'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
|
||||
export type ArticleProps = {
|
||||
articleId: string
|
||||
@ -24,6 +24,7 @@ export type ArticleProps = {
|
||||
initialAnchorIndex: number
|
||||
initialReadingProgress?: number
|
||||
scrollElementRef: MutableRefObject<HTMLDivElement | null>
|
||||
articleMutations: ArticleMutations
|
||||
}
|
||||
|
||||
export function Article(props: ArticleProps): JSX.Element {
|
||||
@ -64,7 +65,7 @@ export function Article(props: ArticleProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
if (!readingProgress) return
|
||||
await articleReadingProgressMutation({
|
||||
await props.articleMutations.articleReadingProgressMutation({
|
||||
id: props.articleId,
|
||||
// round reading progress to 100% if more than that
|
||||
readingProgressPercent: readingProgress > 100 ? 100 : readingProgress,
|
||||
|
||||
@ -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
|
||||
@ -187,6 +189,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 +219,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 ?? '',
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -7,6 +7,11 @@ import { webBaseURL } from '../../../../lib/appConfig'
|
||||
import { LoadingView } from '../../../../components/patterns/LoadingView'
|
||||
import { cookieValue } from '../../../../lib/cookieHelpers'
|
||||
import { applyStoredTheme } from '../../../../lib/themeUpdater'
|
||||
import { createHighlightMutation } from '../../../../lib/networking/mutations/createHighlightMutation'
|
||||
import { deleteHighlightMutation } from '../../../../lib/networking/mutations/deleteHighlightMutation'
|
||||
import { mergeHighlightMutation } from '../../../../lib/networking/mutations/mergeHighlightMutation'
|
||||
import { updateHighlightMutation } from '../../../../lib/networking/mutations/updateHighlightMutation'
|
||||
import { articleReadingProgressMutation } from '../../../../lib/networking/mutations/articleReadingProgressMutation'
|
||||
|
||||
type AppArticleEmbedContentProps = {
|
||||
slug: string
|
||||
@ -88,6 +93,13 @@ function AppArticleEmbedContent(
|
||||
fontSize={props.fontSize}
|
||||
margin={props.margin}
|
||||
fontFamily={props.fontFamily}
|
||||
articleMutations={{
|
||||
createHighlightMutation,
|
||||
deleteHighlightMutation,
|
||||
mergeHighlightMutation,
|
||||
updateHighlightMutation,
|
||||
articleReadingProgressMutation,
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user