Merge branch 'main' into fix/android-library-flickering

This commit is contained in:
Stefano Sansone 2024-04-05 01:59:13 +02:00
commit b0c02bebff
69 changed files with 1136 additions and 775 deletions

View file

@ -1,5 +1,3 @@
node_modules/
dist/
readabilityjs/
src/generated/
test/resolvers/

View file

@ -5,5 +5,13 @@
},
"rules": {
"@typescript-eslint/no-unsafe-argument": 0
}
},
"overrides": [
{
"files": ["test/**/*.ts"],
"rules": {
"@typescript-eslint/no-unsafe-member-access": 0
}
}
]
}

View file

@ -2,5 +2,6 @@
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"reporter": "mocha-unfunk-reporter",
"require": ["test/babel-register.js", "test/global-setup.ts", "test/global-teardown.ts"]
"require": ["test/global-setup.ts", "test/global-teardown.ts"],
"timeout": 10000
}

View file

@ -3,16 +3,15 @@
"version": "1.0.0",
"license": "UNLICENSED",
"scripts": {
"build": "tsc && yarn copy-files",
"dev": "ts-node-dev --files src/server.ts",
"dev_qp": "ts-node-dev --files src/queue-processor.ts",
"start": "node dist/server.js",
"start_queue_processor": "node dist/queue-processor.js",
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"dev_qp": "ts-node-dev --respawn --transpile-only src/queue-processor.ts",
"start": "node dist/src/server.js",
"start_queue_processor": "node dist/src/queue-processor.js",
"lint": "eslint src --ext ts,js,tsx,jsx",
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
"test:typecheck": "tsc --noEmit",
"test": "nyc mocha -r ts-node/register --config mocha-config.json --timeout 10000",
"copy-files": "copyfiles -u 1 src/**/*.html dist/"
"test": "nyc mocha -r ts-node/register --config mocha-config.json"
},
"dependencies": {
"@bmatei/apollo-prometheus-exporter": "^3.0.0",
@ -117,7 +116,6 @@
"youtubei": "1.3.7"
},
"devDependencies": {
"@babel/register": "^7.14.5",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@types/addressparser": "^1.0.1",
"@types/analytics-node": "^3.1.7",
@ -154,7 +152,6 @@
"chai-as-promised": "^7.1.1",
"chai-string": "^1.5.0",
"circular-dependency-plugin": "^5.2.0",
"copyfiles": "^2.4.1",
"mocha": "^9.0.1",
"mocha-unfunk-reporter": "^0.4.0",
"nock": "^13.2.4",

View file

@ -15,11 +15,16 @@ export enum RuleActionType {
Delete = 'DELETE',
MarkAsRead = 'MARK_AS_READ',
SendNotification = 'SEND_NOTIFICATION',
Webhook = 'WEBHOOK',
Export = 'EXPORT',
}
export enum RuleEventType {
PageCreated = 'PAGE_CREATED',
PageUpdated = 'PAGE_UPDATED',
LabelCreated = 'LABEL_CREATED',
HighlightCreated = 'HIGHLIGHT_CREATED',
HighlightUpdated = 'HIGHLIGHT_UPDATED',
}
export interface RuleAction {

View file

@ -2501,11 +2501,16 @@ export enum RuleActionType {
AddLabel = 'ADD_LABEL',
Archive = 'ARCHIVE',
Delete = 'DELETE',
Export = 'EXPORT',
MarkAsRead = 'MARK_AS_READ',
SendNotification = 'SEND_NOTIFICATION'
SendNotification = 'SEND_NOTIFICATION',
Webhook = 'WEBHOOK'
}
export enum RuleEventType {
HighlightCreated = 'HIGHLIGHT_CREATED',
HighlightUpdated = 'HIGHLIGHT_UPDATED',
LabelCreated = 'LABEL_CREATED',
PageCreated = 'PAGE_CREATED',
PageUpdated = 'PAGE_UPDATED'
}

View file

@ -1877,11 +1877,16 @@ enum RuleActionType {
ADD_LABEL
ARCHIVE
DELETE
EXPORT
MARK_AS_READ
SEND_NOTIFICATION
WEBHOOK
}
enum RuleEventType {
HIGHLIGHT_CREATED
HIGHLIGHT_UPDATED
LABEL_CREATED
PAGE_CREATED
PAGE_UPDATED
}

View file

@ -161,7 +161,9 @@ export const findThumbnail = async (data: Data) => {
{
thumbnail,
},
userId
userId,
undefined,
true
)
logger.info(`thumbnail updated: ${thumbnail}`)
}

View file

@ -1,7 +1,15 @@
import { LiqeQuery } from '@omnivore/liqe'
import axios from 'axios'
import { Any } from 'typeorm'
import { ReadingProgressDataSource } from '../datasources/reading_progress_data_source'
import { IntegrationType } from '../entity/integration'
import { LibraryItem, LibraryItemState } from '../entity/library_item'
import { Rule, RuleAction, RuleActionType, RuleEventType } from '../entity/rule'
import {
findIntegrations,
getIntegrationClient,
updateIntegration,
} from '../services/integrations'
import { addLabelsToLibraryItem } from '../services/labels'
import {
filterItemEvents,
@ -17,17 +25,16 @@ import { logger } from '../utils/logger'
import { parseSearchQuery } from '../utils/search'
export interface TriggerRuleJobData {
libraryItemId: string
userId: string
ruleEventType: RuleEventType
data: ItemEvent
}
interface RuleActionObj {
libraryItemId: string
userId: string
action: RuleAction
data: ItemEvent | LibraryItem
ruleEventType: RuleEventType
}
type RuleActionFunc = (obj: RuleActionObj) => Promise<unknown>
@ -37,21 +44,16 @@ const readingProgressDataSource = new ReadingProgressDataSource()
const addLabels = async (obj: RuleActionObj) => {
const labelIds = obj.action.params
return addLabelsToLibraryItem(
labelIds,
obj.libraryItemId,
obj.userId,
'system'
)
return addLabelsToLibraryItem(labelIds, obj.data.id, obj.userId, 'system')
}
const deleteLibraryItem = async (obj: RuleActionObj) => {
return softDeleteLibraryItem(obj.libraryItemId, obj.userId)
return softDeleteLibraryItem(obj.data.id, obj.userId)
}
const archivePage = async (obj: RuleActionObj) => {
return updateLibraryItem(
obj.libraryItemId,
obj.data.id,
{ archivedAt: new Date(), state: LibraryItemState.Archived },
obj.userId,
undefined,
@ -62,7 +64,7 @@ const archivePage = async (obj: RuleActionObj) => {
const markPageAsRead = async (obj: RuleActionObj) => {
return readingProgressDataSource.updateReadingProgress(
obj.userId,
obj.libraryItemId,
obj.data.id,
{
readingProgressPercent: 100,
readingProgressTopPercent: 100,
@ -80,13 +82,119 @@ const sendNotification = async (obj: RuleActionObj) => {
}
const data = {
folder: item.folder?.toString() || 'inbox',
libraryItemId: obj.libraryItemId,
libraryItemId: obj.data.id,
}
return sendPushNotifications(obj.userId, message, 'rule', data)
}
const getRuleAction = (actionType: RuleActionType): RuleActionFunc => {
const sendToWebhook = async (obj: RuleActionObj) => {
const [url] = obj.action.params
const [type, action] = obj.ruleEventType.toString().toLowerCase().split('_')
// use old event format for the compatibility with the old webhooks
let event
if (type === 'page') {
event = obj.data
} else if (type === 'label') {
event = {
labels: obj.data.labels,
pageId: obj.data.id,
}
} else {
if (!obj.data.highlights) {
return
}
event = {
...obj.data.highlights[0],
pageId: obj.data.id,
}
}
const data = {
action,
userId: obj.userId,
[type]: event,
}
logger.info(`triggering webhook: ${url}`)
return axios.post(url, data, {
headers: {
'Content-Type': 'application/json',
},
timeout: 5000, // 5s
})
}
const exportItem = async (obj: RuleActionObj) => {
const userId = obj.userId
const integrationNames = obj.action.params
const integrations = await findIntegrations(userId, {
name: Any(integrationNames.map((param) => param.toUpperCase())),
enabled: true,
type: IntegrationType.Export,
})
if (integrations.length <= 0) {
return
}
await Promise.all(
integrations.map(async (integration) => {
const logObject = {
userId,
integrationId: integration.id,
name: integration.name,
}
logger.info('exporting item...', logObject)
try {
const client = getIntegrationClient(
integration.name,
integration.token,
integration
)
const synced = await client.export([obj.data])
if (!synced) {
logger.error('failed to export item', logObject)
return false
}
const syncedAt = new Date()
logger.info('updating integration...', {
...logObject,
syncedAt,
})
// update integration syncedAt if successful
const updated = await updateIntegration(
integration.id,
{
syncedAt,
},
userId
)
logger.info('integration updated', {
...logObject,
updated,
})
} catch (error) {
logger.error('failed to export item', {
...logObject,
error,
})
}
})
)
}
const getRuleAction = (
actionType: RuleActionType
): RuleActionFunc | undefined => {
switch (actionType) {
case RuleActionType.AddLabel:
return addLabels
@ -98,14 +206,21 @@ const getRuleAction = (actionType: RuleActionType): RuleActionFunc => {
return markPageAsRead
case RuleActionType.SendNotification:
return sendNotification
case RuleActionType.Webhook:
return sendToWebhook
case RuleActionType.Export:
return exportItem
default:
logger.error('Unknown rule action type', actionType)
return undefined
}
}
const triggerActions = async (
libraryItemId: string,
userId: string,
rules: Rule[],
data: ItemEvent
data: ItemEvent,
ruleEventType: RuleEventType
) => {
const actionPromises: Promise<unknown>[] = []
@ -130,7 +245,7 @@ const triggerActions = async (
logger.info('Failed to filter items by metadata, running search query')
const searchResult = await searchLibraryItems(
{
query: `includes:${libraryItemId} AND (${rule.filter})`,
query: `includes:${data.id} AND (${rule.filter})`,
size: 1,
},
userId
@ -150,11 +265,16 @@ const triggerActions = async (
for (const action of rule.actions) {
const actionFunc = getRuleAction(action.type)
if (!actionFunc) {
logger.error('No action function found for action', action.type)
continue
}
const actionObj: RuleActionObj = {
libraryItemId,
userId,
action,
data: results[0],
ruleEventType,
}
actionPromises.push(actionFunc(actionObj))
@ -169,7 +289,7 @@ const triggerActions = async (
}
export const triggerRule = async (jobData: TriggerRuleJobData) => {
const { userId, ruleEventType, data, libraryItemId } = jobData
const { userId, ruleEventType, data } = jobData
// get rules by calling api
const rules = await findEnabledRules(userId, ruleEventType)
@ -178,7 +298,7 @@ export const triggerRule = async (jobData: TriggerRuleJobData) => {
return false
}
await triggerActions(libraryItemId, userId, rules, data)
await triggerActions(userId, rules, data, ruleEventType)
return true
}

View file

@ -4,14 +4,14 @@ import { RuleEventType } from './entity/rule'
import { env } from './env'
import { ReportType } from './generated/graphql'
import {
enqueueExportItem,
enqueueProcessYouTubeVideo,
enqueueTriggerRuleJob,
enqueueWebhookJob,
} from './utils/createTask'
import { buildLogger } from './utils/logger'
import { isYouTubeVideoURL } from './utils/youtube'
export type EntityEvent = { id: string }
const logger = buildLogger('pubsub')
const client = new PubSub()
@ -46,35 +46,19 @@ export const createPubSubClient = (): PubsubClient => {
Buffer.from(JSON.stringify({ userId, email, name, username }))
)
},
entityCreated: async <T extends Record<string, any>>(
entityCreated: async <T extends EntityEvent>(
type: EntityType,
data: T,
userId: string,
libraryItemId: string
userId: string
): Promise<void> => {
// queue trigger rule job
if (type === EntityType.PAGE) {
await enqueueTriggerRuleJob({
userId,
ruleEventType: RuleEventType.PageCreated,
libraryItemId,
data,
})
}
// queue export item job
await enqueueExportItem({
userId,
libraryItemIds: [libraryItemId],
})
await enqueueWebhookJob({
userId,
type,
action: 'created',
await enqueueTriggerRuleJob({
ruleEventType: `${type.toUpperCase()}_CREATED` as RuleEventType,
data,
userId,
})
if (type === EntityType.PAGE) {
if (type === EntityType.ITEM) {
// if (await findGrantedFeatureByName(FeatureName.AISummaries, userId)) {
// await enqueueAISummarizeJob({
// userId,
@ -89,36 +73,20 @@ export const createPubSubClient = (): PubsubClient => {
if (isItemWithURL(data) && isYouTubeVideoURL(data['originalUrl'])) {
await enqueueProcessYouTubeVideo({
userId,
libraryItemId,
libraryItemId: data.id,
})
}
}
},
entityUpdated: async <T extends Record<string, any>>(
entityUpdated: async <T extends EntityEvent>(
type: EntityType,
data: T,
userId: string,
libraryItemId: string
userId: string
): Promise<void> => {
// queue trigger rule job
if (type === EntityType.PAGE) {
await enqueueTriggerRuleJob({
userId,
ruleEventType: RuleEventType.PageUpdated,
libraryItemId,
data,
})
}
// queue export item job
await enqueueExportItem({
await enqueueTriggerRuleJob({
userId,
libraryItemIds: [libraryItemId],
})
await enqueueWebhookJob({
userId,
type,
action: 'updated',
ruleEventType: `${type.toUpperCase()}_UPDATED` as RuleEventType,
data,
})
},
@ -147,10 +115,10 @@ export const createPubSubClient = (): PubsubClient => {
}
export enum EntityType {
PAGE = 'page',
HIGHLIGHT = 'highlight',
LABEL = 'label',
RSS_FEED = 'feed',
ITEM = 'PAGE',
HIGHLIGHT = 'HIGHLIGHT',
LABEL = 'LABEL',
RSS_FEED = 'FEED',
}
export interface PubsubClient {
@ -160,17 +128,15 @@ export interface PubsubClient {
name: string,
username: string
) => Promise<void>
entityCreated: <T extends Record<string, any>>(
entityCreated: <T extends EntityEvent>(
type: EntityType,
data: T,
userId: string,
libraryItemId: string
userId: string
) => Promise<void>
entityUpdated: <T extends Record<string, any>>(
entityUpdated: <T extends EntityEvent>(
type: EntityType,
data: T,
userId: string,
libraryItemId: string
userId: string
) => Promise<void>
entityDeleted: (type: EntityType, id: string, userId: string) => Promise<void>
reportSubmitted(

View file

@ -40,6 +40,7 @@ import {
saveIntegration,
updateIntegration,
} from '../../services/integrations'
import { NotionClient } from '../../services/integrations/notion'
import { analytics } from '../../utils/analytics'
import {
deleteTask,
@ -57,15 +58,14 @@ export const setIntegrationResolver = authorized<
...input,
user: { id: uid },
id: input.id || undefined,
type: input.type || IntegrationType.Export,
type: input.type || undefined,
syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined,
importItemState:
input.type === IntegrationType.Import
? input.importItemState || ImportItemState.Unarchived // default to unarchived
: undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
settings: input.settings,
}
if (input.id) {
// Update
const existingIntegration = await findIntegration({ id: input.id }, uid)
@ -96,6 +96,22 @@ export const setIntegrationResolver = authorized<
if (integration.name.toLowerCase() === 'readwise') {
// create a task to export all the items for readwise temporarily
await enqueueExportToIntegration(integration.id, uid)
} else if (
integration.name.toLowerCase() === 'notion' &&
integration.settings
) {
const settings = integration.settings as { parentDatabaseId?: string }
if (settings.parentDatabaseId) {
// update notion database properties
const notion = new NotionClient(integration.token, integration)
try {
await notion.updateDatabase(settings.parentDatabaseId)
} catch (error) {
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
}
}
}
analytics.capture({

View file

@ -7,7 +7,6 @@ import {
MutationSetRuleArgs,
QueryRulesArgs,
RulesError,
RulesErrorCode,
RulesSuccess,
SetRuleError,
SetRuleErrorCode,
@ -21,53 +20,43 @@ export const setRuleResolver = authorized<
SetRuleSuccess,
SetRuleError,
MutationSetRuleArgs
>(async (_, { input }, { authTrx, uid, log }) => {
>(async (_, { input }, { authTrx, uid }) => {
try {
// validate filter
parseSearchQuery(input.filter)
const rule = await authTrx((t) =>
t.getRepository(Rule).save({
...input,
id: input.id || undefined,
user: { id: uid },
})
)
return {
rule,
}
} catch (error) {
log.error('Error setting rules', error)
return {
errorCodes: [SetRuleErrorCode.BadRequest],
}
}
const rule = await authTrx((t) =>
t.getRepository(Rule).save({
...input,
id: input.id || undefined,
user: { id: uid },
})
)
return {
rule,
}
})
export const rulesResolver = authorized<
RulesSuccess,
RulesError,
QueryRulesArgs
>(async (_, { enabled }, { authTrx, log, uid }) => {
try {
const rules = await authTrx((t) =>
t.getRepository(Rule).findBy({
user: { id: uid },
enabled: enabled === null ? undefined : enabled,
})
)
>(async (_, { enabled }, { authTrx, uid }) => {
const rules = await authTrx((t) =>
t.getRepository(Rule).findBy({
user: { id: uid },
enabled: enabled === null ? undefined : enabled,
})
)
return {
rules,
}
} catch (error) {
log.error('Error getting rules', error)
return {
errorCodes: [RulesErrorCode.BadRequest],
}
return {
rules,
}
})

View file

@ -2167,6 +2167,8 @@ const schema = gql`
DELETE
MARK_AS_READ
SEND_NOTIFICATION
WEBHOOK
EXPORT
}
type RulesError {
@ -2181,6 +2183,9 @@ const schema = gql`
enum RuleEventType {
PAGE_CREATED
PAGE_UPDATED
LABEL_CREATED
HIGHLIGHT_CREATED
HIGHLIGHT_UPDATED
}
input SetRuleInput {

View file

@ -5,14 +5,20 @@ import { EntityLabel } from '../entity/entity_label'
import { Highlight } from '../entity/highlight'
import { Label } from '../entity/label'
import { homePageURL } from '../env'
import { createPubSubClient, EntityType } from '../pubsub'
import { createPubSubClient, EntityEvent, EntityType } from '../pubsub'
import { authTrx } from '../repository'
import { highlightRepository } from '../repository/highlight'
import { Merge } from '../util'
import { enqueueUpdateHighlight } from '../utils/createTask'
import { deepDelete } from '../utils/helpers'
import { ItemEvent } from './library_item'
type HighlightEvent = { id: string; pageId: string }
type CreateHighlightEvent = DeepPartial<Highlight> & HighlightEvent
type UpdateHighlightEvent = QueryDeepPartialEntity<Highlight> & HighlightEvent
const columnsToDelete = ['user', 'sharedAt', 'libraryItem'] as const
type ColumnsToDeleteType = typeof columnsToDelete[number]
export type HighlightEvent = Merge<
Omit<DeepPartial<Highlight>, ColumnsToDeleteType>,
EntityEvent
>
export const getHighlightLocation = (patch: string): number | undefined => {
const dmp = new diff_match_patch()
@ -49,6 +55,7 @@ export const createHighlight = async (
where: { id: newHighlight.id },
relations: {
user: true,
libraryItem: true,
},
})
},
@ -56,11 +63,19 @@ export const createHighlight = async (
userId
)
await pubsub.entityCreated<CreateHighlightEvent>(
const data = deepDelete(newHighlight, columnsToDelete)
await pubsub.entityCreated<ItemEvent>(
EntityType.HIGHLIGHT,
{ ...newHighlight, pageId: libraryItemId },
userId,
libraryItemId
{
id: libraryItemId,
highlights: [data],
// for Readwise
originalUrl: newHighlight.libraryItem.originalUrl,
title: newHighlight.libraryItem.title,
author: newHighlight.libraryItem.author,
thumbnail: newHighlight.libraryItem.thumbnail,
},
userId
)
await enqueueUpdateHighlight({
@ -100,15 +115,22 @@ export const mergeHighlights = async (
where: { id: newHighlight.id },
relations: {
user: true,
libraryItem: true,
},
})
})
await pubsub.entityCreated<CreateHighlightEvent>(
await pubsub.entityCreated<ItemEvent>(
EntityType.HIGHLIGHT,
{ ...newHighlight, pageId: libraryItemId },
userId,
libraryItemId
{
id: libraryItemId,
originalUrl: newHighlight.libraryItem.originalUrl,
title: newHighlight.libraryItem.title,
author: newHighlight.libraryItem.author,
thumbnail: newHighlight.libraryItem.thumbnail,
highlights: [newHighlight],
},
userId
)
await enqueueUpdateHighlight({
@ -139,11 +161,25 @@ export const updateHighlight = async (
})
const libraryItemId = updatedHighlight.libraryItem.id
await pubsub.entityUpdated<UpdateHighlightEvent>(
await pubsub.entityUpdated<ItemEvent>(
EntityType.HIGHLIGHT,
{ ...highlight, id: highlightId, pageId: libraryItemId },
userId,
libraryItemId
{
id: libraryItemId,
originalUrl: updatedHighlight.libraryItem.originalUrl,
title: updatedHighlight.libraryItem.title,
author: updatedHighlight.libraryItem.author,
thumbnail: updatedHighlight.libraryItem.thumbnail,
highlights: [
{
...highlight,
id: highlightId,
updatedAt: new Date(),
quote: updatedHighlight.quote,
highlightType: updatedHighlight.highlightType,
},
],
} as ItemEvent,
userId
)
await enqueueUpdateHighlight({

View file

@ -1,4 +1,5 @@
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { LibraryItemState } from '../../entity/library_item'
import { ItemEvent } from '../library_item'
export interface RetrievedData {
url: string
@ -26,5 +27,5 @@ export interface IntegrationClient {
auth(state: string): Promise<string>
export(items: LibraryItem[]): Promise<boolean>
export(items: ItemEvent[]): Promise<boolean>
}

View file

@ -1,12 +1,12 @@
import { Client } from '@notionhq/client'
import axios from 'axios'
import { updateIntegration } from '.'
import { HighlightType } from '../../entity/highlight'
import { Integration } from '../../entity/integration'
import { LibraryItem } from '../../entity/library_item'
import { env } from '../../env'
import { Merge } from '../../util'
import { logger } from '../../utils/logger'
import { getHighlightUrl } from '../highlights'
import { getItemUrl, ItemEvent } from '../library_item'
import { IntegrationClient } from './integration'
type AnnotationColor =
@ -45,7 +45,7 @@ interface NotionPage {
}
}
properties: {
Title: {
Title?: {
title: [
{
text: {
@ -54,25 +54,25 @@ interface NotionPage {
}
]
}
Author: {
Author?: {
rich_text: Array<{
text: {
content: string
}
}>
}
'Original URL': {
'Original URL'?: {
url: string
}
'Omnivore URL': {
url: string
}
'Saved At': {
'Saved At'?: {
date: {
start: string
}
}
'Last Updated': {
'Last Updated'?: {
date: {
start: string
}
@ -111,7 +111,7 @@ type Property = 'highlights'
interface Settings {
parentPageId: string
parentDatabaseId: string
properties: Property[]
properties?: Property[]
}
export class NotionClient implements IntegrationClient {
@ -178,7 +178,7 @@ export class NotionClient implements IntegrationClient {
}
private itemToNotionPage = (
item: LibraryItem,
item: ItemEvent,
settings: Settings,
lastSync?: Date | null
): NotionPage => {
@ -201,88 +201,103 @@ export class NotionClient implements IntegrationClient {
}
: undefined,
properties: {
Title: {
title: [
{
text: {
content: item.title,
},
},
],
},
Author: {
rich_text: [
{
text: {
content: item.author || 'unknown',
},
},
],
},
'Original URL': {
url: item.originalUrl,
},
Title: item.title
? {
title: [
{
text: {
content: item.title,
},
},
],
}
: undefined,
Author: item.author
? {
rich_text: [
{
text: {
content: item.author,
},
},
],
}
: undefined,
'Original URL': item.originalUrl
? {
url: item.originalUrl,
}
: undefined,
'Omnivore URL': {
url: `${env.client.url}/me/${item.slug}`,
},
'Saved At': {
date: {
start: item.createdAt.toISOString(),
},
},
'Last Updated': {
date: {
start: item.updatedAt.toISOString(),
},
url: getItemUrl(item.id),
},
'Saved At': item.savedAt
? {
date: {
start: item.savedAt as string,
},
}
: undefined,
'Last Updated': item.updatedAt
? {
date: {
start: item.updatedAt as string,
},
}
: undefined,
Tags: item.labels
? {
multi_select: item.labels.map((label) => ({
name: label.name,
name: label.name as string,
})),
}
: undefined,
},
children:
settings.properties.includes('highlights') && item.highlights
? item.highlights
.filter(
(highlight) => !lastSync || highlight.updatedAt > lastSync // only new highlights
)
.map((highlight) => ({
paragraph: {
rich_text: [
{
text: {
content: highlight.quote || '',
link: {
url: getHighlightUrl(item.slug, highlight.id),
},
},
annotations: {
code: true,
color: highlight.color as AnnotationColor,
children: item.highlights
? item.highlights
.filter(
(highlight) =>
highlight.highlightType === HighlightType.Highlight &&
(!lastSync ||
new Date(highlight.updatedAt as string) > lastSync) // only new highlights
)
.map((highlight) => ({
paragraph: {
rich_text: [
{
text: {
content: highlight.quote || '',
link: {
url: getHighlightUrl(
item.slug || item.id,
highlight.id
),
},
},
],
children: highlight.annotation
? [
{
paragraph: {
rich_text: [
{
text: {
content: highlight.annotation || '',
},
annotations: {
code: true,
color: highlight.color as AnnotationColor,
},
},
],
children: highlight.annotation
? [
{
paragraph: {
rich_text: [
{
text: {
content: highlight.annotation || '',
},
],
},
},
],
},
]
: undefined,
},
}))
: undefined,
},
]
: undefined,
},
}))
: undefined,
}
}
@ -308,110 +323,99 @@ export class NotionClient implements IntegrationClient {
return null
}
export = async (items: LibraryItem[]): Promise<boolean> => {
export = async (items: ItemEvent[]): Promise<boolean> => {
const settings = this.integrationData?.settings
if (!this.integrationData || !settings) {
logger.error('Notion integration data not found')
return false
}
const pageId = settings.parentPageId
if (!pageId) {
logger.error('Notion parent page id not found')
return false
}
let databaseId = settings.parentDatabaseId
const databaseId = settings.parentDatabaseId
if (!databaseId) {
// create a database for the items
const database = await this.client.databases.create({
parent: {
page_id: pageId,
},
title: [
{
text: {
content: 'Library',
},
},
],
description: [
{
text: {
content: 'Library of saved items from Omnivore',
},
},
],
properties: {
Title: {
title: {},
},
Author: {
rich_text: {},
},
'Original URL': {
url: {},
},
'Omnivore URL': {
url: {},
},
'Saved At': {
date: {},
},
'Last Updated': {
date: {},
},
Tags: {
multi_select: {},
},
},
})
// save the database id
databaseId = database.id
settings.parentDatabaseId = databaseId
await updateIntegration(
this.integrationData.id,
{
settings,
},
this.integrationData.user.id
)
logger.error('Notion database id not found')
return false
}
await Promise.all(
items.map(async (item) => {
const notionPage = this.itemToNotionPage(
item,
settings,
this.integrationData?.syncedAt
)
const url = notionPage.properties['Omnivore URL'].url
try {
const notionPage = this.itemToNotionPage(
item,
settings,
this.integrationData?.syncedAt
)
const url = notionPage.properties['Omnivore URL'].url
const existingPage = await this.findPage(url, databaseId)
if (existingPage) {
// update the page
await this.client.pages.update({
page_id: existingPage.id,
properties: notionPage.properties,
})
// append the children incrementally
if (notionPage.children && notionPage.children.length > 0) {
await this.client.blocks.children.append({
block_id: existingPage.id,
children: notionPage.children,
const existingPage = await this.findPage(url, databaseId)
if (existingPage) {
// update the page
await this.client.pages.update({
page_id: existingPage.id,
properties: notionPage.properties,
})
// append the children incrementally
if (notionPage.children && notionPage.children.length > 0) {
await this.client.blocks.children.append({
block_id: existingPage.id,
children: notionPage.children,
})
}
return
}
return
// create the page
return await this.createPage(notionPage)
} catch (error) {
logger.error(error)
return false
}
// create the page
return this.createPage(notionPage)
})
)
return true
}
private findDatabase = async (databaseId: string) => {
return this.client.databases.retrieve({
database_id: databaseId,
})
}
updateDatabase = async (databaseId: string) => {
const database = await this.findDatabase(databaseId)
// find the title property and update it
const titleProperty = Object.entries(database.properties).find(
([, property]) => property.type === 'title'
)
const title = titleProperty ? titleProperty[0] : 'Name'
await this.client.databases.update({
database_id: database.id,
properties: {
[title]: {
name: 'Title',
},
Author: {
rich_text: {},
},
'Original URL': {
url: {},
},
'Omnivore URL': {
url: {},
},
'Saved At': {
date: {},
},
'Last Updated': {
date: {},
},
Tags: {
multi_select: {},
},
},
})
}
}

View file

@ -1,7 +1,8 @@
import axios from 'axios'
import { LibraryItem } from '../../entity/library_item'
import { HighlightType } from '../../entity/highlight'
import { logger } from '../../utils/logger'
import { getHighlightUrl } from '../highlights'
import { getItemUrl, ItemEvent } from '../library_item'
import { IntegrationClient } from './integration'
interface ReadwiseHighlight {
@ -66,7 +67,7 @@ export class ReadwiseClient implements IntegrationClient {
}
}
export = async (items: LibraryItem[]): Promise<boolean> => {
export = async (items: ItemEvent[]): Promise<boolean> => {
let result = true
const highlights = items.flatMap(this._itemToReadwiseHighlight)
@ -83,32 +84,33 @@ export class ReadwiseClient implements IntegrationClient {
throw new Error('Method not implemented.')
}
private _itemToReadwiseHighlight = (
item: LibraryItem
): ReadwiseHighlight[] => {
private _itemToReadwiseHighlight = (item: ItemEvent): ReadwiseHighlight[] => {
const category = item.siteName === 'Twitter' ? 'tweets' : 'articles'
return item.highlights
?.map((highlight) => {
// filter out highlights that are not of type highlight or have no quote
if (highlight.highlightType !== 'HIGHLIGHT' || !highlight.quote) {
return undefined
}
return {
text: highlight.quote,
title: item.title,
author: item.author || undefined,
highlight_url: getHighlightUrl(item.slug, highlight.id),
highlighted_at: new Date(highlight.createdAt).toISOString(),
category,
image_url: item.thumbnail || undefined,
location_type: 'order',
note: highlight.annotation || undefined,
source_type: 'omnivore',
source_url: item.originalUrl,
}
})
.filter((highlight) => highlight !== undefined) as ReadwiseHighlight[]
return item.highlights
? item.highlights
// filter out highlights that are not of type highlight or have no quote
.filter(
(highlight) => highlight.highlightType === HighlightType.Highlight
)
.map((highlight) => {
return {
text: highlight.quote || '',
title: item.title,
author: item.author || undefined,
highlight_url: getHighlightUrl(item.id, highlight.id),
highlighted_at: highlight.createdAt
? new Date(highlight.createdAt as string).toISOString()
: undefined,
category,
image_url: item.thumbnail || undefined,
location_type: 'order',
note: highlight.annotation || undefined,
source_type: 'omnivore',
source_url: getItemUrl(item.id),
}
})
: []
}
private _syncWithReadwise = async (

View file

@ -2,23 +2,25 @@ import { DeepPartial, FindOptionsWhere, In } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { EntityLabel, LabelSource } from '../entity/entity_label'
import { Label } from '../entity/label'
import { createPubSubClient, EntityType, PubsubClient } from '../pubsub'
import {
createPubSubClient,
EntityEvent,
EntityType,
PubsubClient,
} from '../pubsub'
import { authTrx } from '../repository'
import { CreateLabelInput, labelRepository } from '../repository/label'
import { Merge } from '../util'
import { bulkEnqueueUpdateLabels } from '../utils/createTask'
import { logger } from '../utils/logger'
import { findHighlightById } from './highlights'
import { findLibraryItemIdsByLabelId } from './library_item'
import { deepDelete } from '../utils/helpers'
import { findLibraryItemIdsByLabelId, ItemEvent } from './library_item'
type AddLabelsToLibraryItemEvent = {
pageId: string
labels: DeepPartial<Label>[]
source?: LabelSource
}
type AddLabelsToHighlightEvent = {
highlightId: string
labels: DeepPartial<Label>[]
}
const columnsToDelete = ['description', 'createdAt'] as const
type ColumnsToDeleteType = typeof columnsToDelete[number]
export type LabelEvent = Merge<
Omit<DeepPartial<Label>, ColumnsToDeleteType>,
EntityEvent
>
// const batchGetLabelsFromLinkIds = async (
// linkIds: readonly string[]
@ -144,11 +146,13 @@ export const saveLabelsInLibraryItem = async (
if (source === 'user') {
// create pubsub event
await pubsub.entityCreated<AddLabelsToLibraryItemEvent>(
await pubsub.entityCreated<ItemEvent>(
EntityType.LABEL,
{ pageId: libraryItemId, labels, source },
userId,
libraryItemId
{
id: libraryItemId,
labels: labels.map((l) => deepDelete(l, columnsToDelete)),
},
userId
)
}
@ -207,23 +211,30 @@ export const saveLabelsInHighlight = async (
)
})
const highlight = await findHighlightById(highlightId, userId)
if (!highlight) {
logger.error('Highlight not found', { highlightId, userId })
return
}
// const highlight = await findHighlightById(highlightId, userId)
// if (!highlight) {
// logger.error('Highlight not found', { highlightId, userId })
// return
// }
const libraryItemId = highlight.libraryItemId
// create pubsub event
await pubsub.entityCreated<AddLabelsToHighlightEvent>(
EntityType.LABEL,
{ highlightId, labels },
userId,
libraryItemId
)
// const libraryItemId = highlight.libraryItemId
// // create pubsub event
// await pubsub.entityCreated<ItemEvent>(
// EntityType.LABEL,
// {
// id: libraryItemId,
// highlights: [
// {
// id: highlightId,
// labels: labels.map((l) => deepDelete(l, columnToDelete)),
// },
// ],
// },
// userId
// )
// update labels in library item
await bulkEnqueueUpdateLabels([{ libraryItemId, userId }])
// // update labels in library item
// await bulkEnqueueUpdateLabels([{ libraryItemId, userId }])
}
export const findLabelsByIds = async (

View file

@ -14,8 +14,9 @@ import { EntityLabel } from '../entity/entity_label'
import { Highlight } from '../entity/highlight'
import { Label } from '../entity/label'
import { LibraryItem, LibraryItemState } from '../entity/library_item'
import { env } from '../env'
import { BulkActionType, InputMaybe, SortParams } from '../generated/graphql'
import { createPubSubClient, EntityType } from '../pubsub'
import { createPubSubClient, EntityEvent, EntityType } from '../pubsub'
import { redisDataSource } from '../redis_data_source'
import {
authTrx,
@ -24,24 +25,32 @@ import {
queryBuilderToRawSql,
} from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { Merge } from '../util'
import { setRecentlySavedItemInRedis } from '../utils/helpers'
import { Merge, PickTuple } from '../util'
import { deepDelete, setRecentlySavedItemInRedis } from '../utils/helpers'
import { logger } from '../utils/logger'
import { parseSearchQuery } from '../utils/search'
import { addLabelsToLibraryItem } from './labels'
import { HighlightEvent } from './highlights'
import { addLabelsToLibraryItem, LabelEvent } from './labels'
type IgnoredFields =
| 'user'
| 'uploadFile'
| 'previewContentType'
| 'links'
| 'textContentHash'
export type ItemEvent = CreateItemEvent | UpdateItemEvent
export type CreateItemEvent = Omit<DeepPartial<LibraryItem>, IgnoredFields>
export type UpdateItemEvent = Omit<
QueryDeepPartialEntity<LibraryItem>,
IgnoredFields
const columnsToDelete = [
'user',
'uploadFile',
'previewContentType',
'links',
'textContentHash',
'readableContent',
'originalContent',
'feedContent',
] as const
type ColumnsToDeleteType = typeof columnsToDelete[number]
type ItemBaseEvent = Merge<
Omit<DeepPartial<LibraryItem>, ColumnsToDeleteType>,
{
labels?: LabelEvent[]
highlights?: HighlightEvent[]
}
>
export type ItemEvent = Merge<ItemBaseEvent, EntityEvent>
export class RequiresSearchQueryError extends Error {
constructor() {
@ -133,6 +142,8 @@ interface Select {
const readingProgressDataSource = new ReadingProgressDataSource()
export const getItemUrl = (id: string) => `${env.client.url}/me/${id}`
const markItemAsRead = async (libraryItemId: string, userId: string) => {
return await readingProgressDataSource.updateReadingProgress(
userId,
@ -733,7 +744,7 @@ export const findRecentLibraryItems = async (
'library_item.user_id = :userId AND library_item.state = :state',
{ userId, state: LibraryItemState.Succeeded }
)
.orderBy('library_item.saved_at', 'DESC', 'NULLS LAST')
.orderBy('library_item.savedAt', 'DESC', 'NULLS LAST')
.take(limit)
.skip(offset)
.getMany(),
@ -841,7 +852,7 @@ export const softDeleteLibraryItem = async (
userId
)
await pubsub.entityDeleted(EntityType.PAGE, id, userId)
await pubsub.entityDeleted(EntityType.ITEM, id, userId)
return deletedLibraryItem
}
@ -881,32 +892,21 @@ export const updateLibraryItem = async (
}
if (libraryItem.state === LibraryItemState.Succeeded) {
const data = deepDelete(updatedLibraryItem, columnsToDelete)
// send create event if the item was created
await pubsub.entityCreated<CreateItemEvent>(
EntityType.PAGE,
{
...updatedLibraryItem,
originalContent: undefined,
readableContent: undefined,
feedContent: undefined,
},
userId,
id
)
await pubsub.entityCreated<ItemEvent>(EntityType.ITEM, data, userId)
return updatedLibraryItem
}
await pubsub.entityUpdated<UpdateItemEvent>(
EntityType.PAGE,
const data = deepDelete(libraryItem, columnsToDelete)
await pubsub.entityUpdated<ItemEvent>(
EntityType.ITEM,
{
...libraryItem,
originalContent: undefined,
readableContent: undefined,
feedContent: undefined,
},
userId,
id
...data,
id,
} as ItemEvent,
userId
)
return updatedLibraryItem
@ -965,12 +965,7 @@ export const updateLibraryItemReadingProgress = async (
}
const updatedItem = result[0][0]
await pubsub.entityUpdated<UpdateItemEvent>(
EntityType.PAGE,
updatedItem,
userId,
id
)
await pubsub.entityUpdated<ItemEvent>(EntityType.ITEM, updatedItem, userId)
return updatedItem
}
@ -1066,17 +1061,8 @@ export const createOrUpdateLibraryItem = async (
return newLibraryItem
}
await pubsub.entityCreated<CreateItemEvent>(
EntityType.PAGE,
{
...newLibraryItem,
originalContent: undefined,
readableContent: undefined,
feedContent: undefined,
},
userId,
newLibraryItem.id
)
const data = deepDelete(newLibraryItem, columnsToDelete)
await pubsub.entityCreated<ItemEvent>(EntityType.ITEM, data, userId)
return newLibraryItem
}
@ -1358,7 +1344,8 @@ export const filterItemEvents = (
subscription: /^subscription(s)?$/i,
}
const matchingKeyword = Object.keys(keywordRegexMap).find((keyword) =>
const keys = Object.keys(keywordRegexMap)
const matchingKeyword = keys.find((keyword) =>
value.match(keywordRegexMap[keyword])
)
@ -1366,7 +1353,9 @@ export const filterItemEvents = (
throw new Error(`Unexpected keyword: ${value}`)
}
const eventValue = event[matchingKeyword as keyof ItemEvent]
const eventValue = (event as PickTuple<ItemEvent, typeof keys>)[
matchingKeyword
]
return !eventValue || (Array.isArray(eventValue) && eventValue.length === 0)
}
@ -1434,7 +1423,7 @@ export const filterItemEvents = (
}
}
case 'type': {
return event.itemType?.toString()?.toLowerCase() === lowercasedValue
return event.itemType?.toLowerCase() === lowercasedValue
}
case 'label': {
const labels = event.labelNames as string[] | undefined
@ -1496,8 +1485,10 @@ export const filterItemEvents = (
const start = startDate ?? new Date(0)
const end = endDate ?? new Date()
const key = `${field.name.toLowerCase()}At` as keyof ItemEvent
const eventValue = event[key] as Date
const key = `${field.name.toLowerCase()}At`
const eventValue = event[
key as 'readAt' | 'updatedAt' | 'publishedAt'
] as Date
return eventValue >= start && eventValue <= end
}
@ -1509,7 +1500,7 @@ export const filterItemEvents = (
// get camel case column name
const key = camelCase(columnName) as 'subscription' | 'itemLanguage'
return event[key]?.toString()?.toLowerCase() === lowercasedValue
return event[key]?.toLowerCase() === lowercasedValue
}
// match filters
case 'note':
@ -1525,7 +1516,7 @@ export const filterItemEvents = (
const keys = ['siteName', 'originalUrl'] as const
return keys.some((key) => {
return event[key]?.toString()?.toLowerCase().includes(lowercasedValue)
return event[key]?.toLowerCase().includes(lowercasedValue)
})
}
case 'includes': {
@ -1534,7 +1525,7 @@ export const filterItemEvents = (
throw new Error('Expected ids')
}
return event.id && ids.includes(event.id.toString())
return event.id && ids.includes(event.id)
}
case 'recommendedby': {
if (!event.recommenderNames) {
@ -1588,7 +1579,7 @@ export const filterItemEvents = (
}
}
default:
throw new Error(`Unexpected field: ${field.name}`)
throw new RequiresSearchQueryError()
}
}

View file

@ -82,6 +82,10 @@ export const sendVerificationEmail = async (user: {
link,
}
if (process.env.USE_MAILJET) {
return sendWithMailJet(user.email, link)
}
return sendEmail({
from: env.sender.message,
to: user.email,
@ -104,6 +108,10 @@ export const sendPasswordResetEmail = async (user: {
link,
}
if (process.env.USE_MAILJET) {
return sendWithMailJet(user.email, link)
}
return sendEmail({
from: env.sender.message,
to: user.email,

View file

@ -64,7 +64,9 @@ export const findUsersById = async (ids: string[]): Promise<User[]> => {
return userRepository.findBy({ id: In(ids) })
}
export const deleteUsers = async (criteria: FindOptionsWhere<User>) => {
export const deleteUsers = async (
criteria: FindOptionsWhere<User> | string[]
) => {
return authTrx(
async (t) => t.getRepository(User).delete(criteria),
undefined,

View file

@ -367,7 +367,10 @@ export const cleanUrl = (url: string) => {
})
}
export const deepDelete = <T, K extends keyof T>(obj: T, keys: K[]) => {
export const deepDelete = <T, K extends keyof T>(
obj: T,
keys: readonly K[]
) => {
// make a copy of the object
const copy = { ...obj }

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -5,8 +5,12 @@ import sinon, { SinonFakeTimers } from 'sinon'
import { User } from '../../src/entity/user'
import { env } from '../../src/env'
import { userRepository } from '../../src/repository/user'
import { createFeature, createFeatures, deleteFeature } from '../../src/services/features'
import { deleteUser } from '../../src/services/user'
import {
createFeature,
createFeatures,
deleteFeature,
} from '../../src/services/features'
import { deleteUser, deleteUsers } from '../../src/services/user'
import { createTestUser } from '../db'
import { graphqlRequest, request } from '../util'
@ -21,7 +25,7 @@ describe('features resolvers', () => {
.post('/local/debug/fake-user-login')
.send({ fakeEmail: loginUser.email })
authToken = res.body.authToken
authToken = res.body.authToken as string
})
after(async () => {
@ -124,7 +128,7 @@ describe('features resolvers', () => {
after(async () => {
// reset opt-in users
Promise.all(users.map((user) => deleteUser(user.id)))
await deleteUsers(users.map((user) => user.id))
// reset feature
await deleteFeature({ name: featureName })
})

View file

@ -0,0 +1,151 @@
import { expect } from 'chai'
import 'mocha'
import { filterItemEvents } from '../../src/services/library_item'
import { parseSearchQuery } from '../../src/utils/search'
describe('filterItemEvents', () => {
it('returns events if there are quotation marks in the subscription name', () => {
const query = 'subscription:"Best \\"Omnivore\\""'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
subscription: 'Best "Omnivore"',
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if subscription name equals ignore case', () => {
const query = 'subscription:substack'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
subscription: 'Substack',
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if site name equals ignore case', () => {
const query = 'site:youtube'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
siteName: 'YouTube',
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if site name contains the search query', () => {
const query = 'site:standard'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
siteName: 'Der Standard',
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if domain name contains the search query', () => {
const query = 'site:stackoverflow.com'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
siteName: 'Stack Overflow',
originalUrl: 'https://stackoverflow.com/questions/123',
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if top level domain matches', () => {
const query = 'site:".com"'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
siteName: 'Stack Overflow',
originalUrl: 'https://stackoverflow.com/questions/123',
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if labels match the search query', () => {
const query = 'label:foo'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
labelNames: ['foo'],
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if labels contain quotation marks', () => {
const query = 'label:"foo \\"bar\\""'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
labelNames: ['foo "bar"'],
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if labels contain space', () => {
const query = 'label:"foo bar"'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
labelNames: ['foo bar'],
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if labels match the search query ignore case', () => {
const query = 'label:Foo'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
labelNames: ['foo'],
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
it('returns events if labels match the search query with multiple labels', () => {
const query = 'label:foo,bar'
const ast = parseSearchQuery(query)
const events = [
{
id: '1',
labelNames: ['foo', 'bar'],
},
]
const result = filterItemEvents(ast, events)
expect(result).to.eql(events)
})
})

View file

@ -6,6 +6,6 @@
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"],
"exclude": ["./src/generated", "./test"]
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["./src/generated"]
}

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -0,0 +1,9 @@
-- Type: DO
-- Name: drop_unique_key_on_rules
-- Description: Drop unique constraint on rules table
BEGIN;
ALTER TABLE omnivore.rules DROP CONSTRAINT rules_user_id_filter_key;
COMMIT;

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: drop_unique_key_on_rules
-- Description: Drop unique constraint on rules table
BEGIN;
ALTER TABLE omnivore.rules ADD CONSTRAINT rules_user_id_filter_key UNIQUE (user_id, filter);
COMMIT;

View file

@ -0,0 +1,18 @@
-- Type: DO
-- Name: migrate_webhooks_and_exporter
-- Description: Migrate data from webhooks and exporters to rules
BEGIN;
-- Migrate webhooks to rules
INSERT INTO omnivore.rules (user_id, name, filter, actions, enabled, created_at, updated_at, event_types)
SELECT user_id, 'webhook', 'in:all', jsonb_build_array(jsonb_build_object('type', 'WEBHOOK', 'params', jsonb_build_array(url))), enabled, created_at, updated_at, event_types
FROM omnivore.webhooks;
-- Migrate exporters to rules
INSERT INTO omnivore.rules (user_id, name, filter, actions, enabled, created_at, updated_at, event_types)
SELECT user_id, 'export', 'in:all', jsonb_build_array(jsonb_build_object('type', 'EXPORT', 'params', jsonb_build_array(name))), enabled, created_at, updated_at, '{PAGE_CREATED,PAGE_UPDATED,HIGHLIGHT_CREATED,HIGHLIGHT_UPDATED,LABEL_CREATED}'
FROM omnivore.integrations
WHERE type = 'EXPORT';
COMMIT;

View file

@ -0,0 +1,7 @@
-- Type: UNDO
-- Name: migrate_webhooks_and_exporter
-- Description: Migrate data from webhooks and exporters to rules
BEGIN;
COMMIT;

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default;
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] });

View file

@ -12,8 +12,8 @@ chai.use(chaiString)
describe('Test csv importer', () => {
let stub: ImportContext
beforeEach(async () => {
stub = await stubImportCtx()
beforeEach(() => {
stub = stubImportCtx()
})
afterEach(async () => {

View file

@ -17,7 +17,7 @@ describe('Load a simple _matter_history file', () => {
it('should find the URL of each row', async () => {
const urls: URL[] = []
const stream = fs.createReadStream('./test/matter/data/_matter_history.csv')
const stub = await stubImportCtx()
const stub = stubImportCtx()
stub.urlHandler = (ctx: ImportContext, url): Promise<void> => {
urls.push(url)
return Promise.resolve()
@ -38,7 +38,7 @@ describe('Load archive file', () => {
it('should find the URL of each row', async () => {
const urls: URL[] = []
const stream = fs.createReadStream('./test/matter/data/Archive.zip')
const stub = await stubImportCtx()
const stub = stubImportCtx()
stub.contentHandler = (
ctx: ImportContext,
url: URL,

View file

@ -2,8 +2,8 @@ import { Readability } from '@omnivore/readability'
import { ArticleSavingRequestStatus, ImportContext } from '../src'
import { createRedisClient } from '../src/redis'
export const stubImportCtx = async (): Promise<ImportContext> => {
const redisClient = await createRedisClient(process.env.REDIS_URL)
export const stubImportCtx = (): ImportContext => {
const redisClient = createRedisClient(process.env.REDIS_URL)
return {
userId: '',

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default;
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] });

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default;
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] });

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -1,5 +1,4 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}
"spec": "test/**/*.test.ts"
}

View file

@ -1,3 +0,0 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View file

@ -20,6 +20,7 @@ import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
import Link from 'next/link'
import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon'
import { NavMenuFooter } from './Footer'
import { escapeQuotes } from '../../../utils/helper'
export const LIBRARY_LEFT_MENU_WIDTH = '275px'
@ -255,7 +256,7 @@ function Subscriptions(
name: name,
keywords: '*' + name,
perform: () => {
props.applySearchQuery(`subscription:\"${name}\"`)
props.applySearchQuery(`subscription:\"${escapeQuotes(name)}\"`)
},
}
}),
@ -291,7 +292,9 @@ function Subscriptions(
return (
<FilterButton
key={item.id}
filterTerm={`in:inbox subscription:\"${item.name}\"`}
filterTerm={`in:inbox subscription:\"${escapeQuotes(
item.name
)}\"`}
text={item.name}
{...props}
/>
@ -507,7 +510,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
const checkboxRef = useRef<HTMLInputElement | null>(null)
const state = useMemo(() => {
const term = props.searchTerm ?? ''
if (term.indexOf(`label:\"${props.label.name}\"`) >= 0) {
if (term.indexOf(`label:\"${escapeQuotes(props.label.name)}\"`) >= 0) {
return 'on'
}
return 'off'
@ -557,7 +560,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
props.applySearchQuery(query.trim())
} else {
props.applySearchQuery(
`${query.trim()} label:\"${props.label.name}\"`
`${query.trim()} label:\"${escapeQuotes(props.label.name)}\"`
)
}
}}
@ -576,16 +579,14 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
type="checkbox"
checked={state === 'on'}
onChange={(e) => {
const escapedName = escapeQuotes(props.label.name)
if (e.target.checked) {
props.applySearchQuery(
`${props.searchTerm ?? ''} label:\"${props.label.name}\"`
`${props.searchTerm ?? ''} label:\"${escapedName}\"`
)
} else {
const query =
props.searchTerm?.replace(
`label:\"${props.label.name}\"`,
''
) ?? ''
props.searchTerm?.replace(`label:\"${escapedName}\"`, '') ?? ''
props.applySearchQuery(query)
}
}}

View file

@ -31,6 +31,7 @@ import { NewsletterIcon } from '../../elements/icons/NewsletterIcon'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { useRouter } from 'next/router'
import { DiscoverIcon } from "../../elements/icons/DiscoverIcon"
import { escapeQuotes } from "../../../utils/helper"
export const LIBRARY_LEFT_MENU_WIDTH = '275px'
@ -221,7 +222,7 @@ const LibraryNav = (props: LibraryFilterMenuProps): JSX.Element => {
<NavRedirectButton
{...props}
text="Discover"
redirectLocation={"/discover"}
redirectLocation={'/discover'}
icon={<DiscoverIcon color={theme.colors.discover.toString()} />}
/>
</VStack>
@ -545,7 +546,7 @@ function Subscriptions(
name: name,
keywords: '*' + name,
perform: () => {
props.applySearchQuery(`subscription:\"${name}\"`)
props.applySearchQuery(`subscription:\"${escapeQuotes(name)}\"`)
},
}
}),
@ -581,7 +582,9 @@ function Subscriptions(
return (
<FilterButton
key={item.id}
filterTerm={`in:inbox subscription:\"${item.name}\"`}
filterTerm={`in:inbox subscription:\"${escapeQuotes(
item.name
)}\"`}
text={item.name}
{...props}
/>
@ -735,7 +738,7 @@ type NavButtonRedirectProps = {
}
function NavRedirectButton(props: NavButtonRedirectProps): JSX.Element {
const [selected, setSelected] = useState(false);
const [selected, setSelected] = useState(false)
const router = useRouter()
useEffect(() => {
@ -932,7 +935,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
const checkboxRef = useRef<HTMLInputElement | null>(null)
const state = useMemo(() => {
const term = props.searchTerm ?? ''
if (term.indexOf(`label:\"${props.label.name}\"`) >= 0) {
if (term.indexOf(`label:\"${escapeQuotes(props.label.name)}\"`) >= 0) {
return 'on'
}
return 'off'
@ -982,7 +985,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
props.applySearchQuery(query.trim())
} else {
props.applySearchQuery(
`${query.trim()} label:\"${props.label.name}\"`
`${query.trim()} label:\"${escapeQuotes(props.label.name)}\"`
)
}
}}
@ -1001,14 +1004,15 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
type="checkbox"
checked={state === 'on'}
onChange={(e) => {
const escapedLabelName = escapeQuotes(props.label.name)
if (e.target.checked) {
props.applySearchQuery(
`${props.searchTerm ?? ''} label:\"${props.label.name}\"`
`${props.searchTerm ?? ''} label:\"${escapedLabelName}\"`
)
} else {
const query =
props.searchTerm?.replace(
`label:\"${props.label.name}\"`,
`label:\"${escapedLabelName}\"`,
''
) ?? ''
props.applySearchQuery(query)

View file

@ -20,12 +20,12 @@ export type SetIntegrationInput = {
}
type SetIntegrationResult = {
setIntegration?: SetIntegrationData
setIntegration: SetIntegrationData
}
type SetIntegrationData = {
integration: Integration
errorCodes?: unknown[]
errorCodes?: string[]
}
type Integration = {
@ -40,7 +40,7 @@ type Integration = {
export async function setIntegrationMutation(
input: SetIntegrationInput
): Promise<Integration | undefined> {
): Promise<Integration> {
const mutation = gql`
mutation SetIntegration($input: SetIntegrationInput!) {
setIntegration(input: $input) {
@ -64,11 +64,10 @@ export async function setIntegrationMutation(
`
const data = (await gqlFetcher(mutation, { input })) as SetIntegrationResult
const output = data as any
const error = data.setIntegration?.errorCodes?.find(() => true)
const error = data.setIntegration.errorCodes?.find(() => true)
if (error) {
if (error === 'INVALID_TOKEN') throw 'Your token is invalid.'
throw error
}
return output.setIntegration?.integration
return data.setIntegration.integration
}

View file

@ -13,11 +13,16 @@ export enum RuleActionType {
MarkAsRead = 'MARK_AS_READ',
Delete = 'DELETE',
SendNotification = 'SEND_NOTIFICATION',
Webhook = 'WEBHOOK',
Export = 'EXPORT',
}
export enum RuleEventType {
PAGE_CREATED = 'PAGE_CREATED',
PAGE_UPDATED = 'PAGE_UPDATED',
LABEL_CREATED = 'LABEL_CREATED',
HIGHLIGHT_CREATED = 'HIGHLIGHT_CREATED',
HIGHLIGHT_UPDATED = 'HIGHLIGHT_UPDATED',
}
export interface Rule {
@ -43,7 +48,8 @@ interface RulesQueryResponseData {
}
interface RulesData {
rules: unknown
rules: Rule[]
errorCodes?: string[]
}
export function useGetRulesQuery(): RulesQueryResponse {
@ -74,27 +80,27 @@ export function useGetRulesQuery(): RulesQueryResponse {
`
const { data, mutate, isValidating } = useSWR(query, publicGqlFetcher)
try {
if (data) {
const result = data as RulesQueryResponseData
const rules = result.rules.rules as Rule[]
return {
isValidating,
rules: rules ?? [],
revalidate: () => {
mutate()
},
}
if (!data) {
return {
isValidating: false,
rules: [],
revalidate: () => {
mutate()
},
}
} catch (error) {
console.log('error', error)
}
const result = data as RulesQueryResponseData
const error = result.rules.errorCodes?.find(() => true)
if (error) {
throw error
}
return {
isValidating: false,
rules: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
revalidate: () => {},
isValidating,
rules: result.rules.rules,
revalidate: () => {
mutate()
},
}
}

View file

@ -1,19 +1,19 @@
import { styled } from '@stitches/react'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { DownloadSimple, Eye, Link, Spinner } from 'phosphor-react'
import { useEffect, useMemo, useState } from 'react'
import { DownloadSimple, Link, Spinner } from 'phosphor-react'
import { useCallback, useEffect, useState } from 'react'
import { Toaster } from 'react-hot-toast'
import { Button } from '../../components/elements/Button'
import {
Dropdown,
DropdownOption,
DropdownOption
} from '../../components/elements/DropdownElements'
import {
Box,
HStack,
SpanBox,
VStack,
VStack
} from '../../components/elements/LayoutPrimitives'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { fetchEndpoint } from '../../lib/appConfig'
@ -22,14 +22,13 @@ import { deleteIntegrationMutation } from '../../lib/networking/mutations/delete
import { importFromIntegrationMutation } from '../../lib/networking/mutations/importFromIntegrationMutation'
import {
ImportItemState,
setIntegrationMutation,
setIntegrationMutation
} from '../../lib/networking/mutations/setIntegrationMutation'
import {
Integration,
useGetIntegrationsQuery,
useGetIntegrationsQuery
} from '../../lib/networking/queries/useGetIntegrationsQuery'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { useGetWebhooksQuery } from '../../lib/networking/queries/useGetWebhooksQuery'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
// Styles
const Header = styled(Box, {
@ -77,25 +76,23 @@ type integrationsCard = {
export default function Integrations(): JSX.Element {
const { viewerData } = useGetViewerQuery()
const { integrations, revalidate } = useGetIntegrationsQuery()
const { webhooks } = useGetWebhooksQuery()
// const { webhooks } = useGetWebhooksQuery()
const [integrationsArray, setIntegrationsArray] = useState(
Array<integrationsCard>()
)
const router = useRouter()
const readwiseConnected = useMemo(() => {
return integrations.find((i) => i.name == 'READWISE' && i.type == 'EXPORT')
}, [integrations])
const pocketConnected = useMemo(() => {
return integrations.find((i) => i.name == 'POCKET' && i.type == 'IMPORT')
}, [integrations])
const isConnected = (name: string) => {
return integrations.find((i) => i.name == name)?.enabled
}
const getIntegration = useCallback(
(name: string) => {
return integrations.find((i) => i.name === name)
},
[integrations]
)
const deleteIntegration = async (id: string) => {
const deleteIntegration = useCallback(async (id: string) => {
try {
await deleteIntegrationMutation(id)
revalidate()
@ -103,7 +100,7 @@ export default function Integrations(): JSX.Element {
} catch (err) {
showErrorToast('Error: ' + err)
}
}
}, [])
const importFromIntegration = async (id: string) => {
try {
@ -115,7 +112,7 @@ export default function Integrations(): JSX.Element {
}
}
const redirectToIntegration = (
const redirectToIntegration = useCallback((
name: string,
importItemState?: ImportItemState
) => {
@ -133,18 +130,20 @@ export default function Integrations(): JSX.Element {
document.body.appendChild(form)
form.submit()
}
}, [])
const isImporting = (integration: Integration | undefined) => {
return !!integration && !!integration.taskName
}
useEffect(() => {
const connectToPocket = async () => {
const connectToPocket = async (
token: string,
importItemState: ImportItemState
) => {
router.push('/settings/integrations')
try {
// get the token from query string
const token = router.query.pocketToken as string
const importItemState = router.query.state as ImportItemState
const result = await setIntegrationMutation({
token,
name: 'POCKET',
@ -152,33 +151,29 @@ export default function Integrations(): JSX.Element {
enabled: true,
importItemState,
})
if (result) {
revalidate()
showSuccessToast('Connected with Pocket.')
// start the import
await importFromIntegration(result.id)
} else {
showErrorToast('There was an error connecting to Pocket.')
}
revalidate()
showSuccessToast('Connected with Pocket.')
// start the import
await importFromIntegration(result.id)
} catch (err) {
showErrorToast(
'There was an error connecting to Pocket. Please try again.',
{ duration: 5000 }
)
} finally {
router.push('/settings/integrations')
}
}
const connectWithNotion = async () => {
const connectWithNotion = async (code: string) => {
router.push('/settings/integrations')
try {
// get the token from query string
const token = router.query.code as string
await setIntegrationMutation({
token,
token: code,
name: 'NOTION',
type: 'EXPORT',
enabled: false,
enabled: true,
})
showSuccessToast('Connected with Notion.')
@ -189,21 +184,36 @@ export default function Integrations(): JSX.Element {
'There was an error connecting to Notion. Please try again.',
{ duration: 5000 }
)
router.push('/settings/integrations')
}
}
if (!router.isReady) return
if (router.query.pocketToken && router.query.state && !pocketConnected) {
connectToPocket()
if (
router.query.pocketToken &&
router.query.state &&
!getIntegration('POCKET')
) {
// get the token from query string
const { pocketToken, state } = router.query as {
pocketToken: string
state: ImportItemState
}
connectToPocket(pocketToken, state)
}
if (router.query.code) {
connectWithNotion()
if (router.query.code && !getIntegration('NOTION')) {
// get the code from query string
const code = router.query.code as string
connectWithNotion(code)
}
}, [router])
}, [getIntegration, router])
useEffect(() => {
const pocket = getIntegration('POCKET')
const readwise = getIntegration('READWISE')
const notion = getIntegration('NOTION')
const integrationsArray = [
{
icon: '/static/icons/logseq.svg',
@ -239,59 +249,60 @@ export default function Integrations(): JSX.Element {
subText:
'Pocket is a place to save articles, videos, and more. Our Pocket integration allows importing your Pocket library to Omnivore. Once connected we will asyncronously import all your Pocket articles into Omnivore, as this process is resource intensive it can take some time. You will receive an email when the process is completed. Limit 20k articles per import.',
button: {
text: pocketConnected ? 'Disconnect' : 'Import',
icon: isImporting(pocketConnected) ? (
text: pocket ? 'Disconnect' : 'Import',
icon: isImporting(pocket) ? (
<Spinner size={16} />
) : (
<Link size={16} weight={'bold'} />
),
style: pocketConnected ? 'ctaWhite' : 'ctaDarkYellow',
style: pocket ? 'ctaWhite' : 'ctaDarkYellow',
action: () => {
pocketConnected
? deleteIntegration(pocketConnected.id)
: redirectToIntegration('pocket', ImportItemState.Unarchived)
pocket
? deleteIntegration(pocket.id)
: redirectToIntegration('POCKET', ImportItemState.Unarchived)
},
disabled: isImporting(pocketConnected),
isDropdown: !pocketConnected,
disabled: isImporting(pocket),
isDropdown: !pocket,
dropdownOptions: [
{
text: 'Import All',
action: () => {
redirectToIntegration('pocket', ImportItemState.All)
redirectToIntegration('POCKET', ImportItemState.All)
},
},
{
text: 'Import Unarchived',
action: () => {
redirectToIntegration('pocket', ImportItemState.Unarchived)
redirectToIntegration('POCKET', ImportItemState.Unarchived)
},
},
],
},
},
{
icon: '/static/icons/webhooks.svg',
title: 'Webhooks',
subText: `${webhooks.length} Webhooks`,
button: {
text: 'View Webhooks',
icon: <Eye size={16} weight={'bold'} />,
style: 'ctaWhite',
action: () => router.push('/settings/webhooks'),
},
},
// {
// icon: '/static/icons/webhooks.svg',
// title: 'Webhooks',
// subText: `${webhooks.length} Webhooks`,
// button: {
// text: 'View Webhooks',
// icon: <Eye size={16} weight={'bold'} />,
// style: 'ctaWhite',
// action: () => router.push('/settings/webhooks'),
// },
// },
{
icon: '/static/icons/readwise.svg',
title: 'Readwise',
subText:
'Readwise makes it easy to revisit and learn from your ebook & article highlights. Use our Readwise integration to sync your highlights from Omnivore to Readwise.',
button: {
text: readwiseConnected ? 'Remove' : 'Connect to Readwise',
text: readwise ? 'Remove' : 'Connect to Readwise',
icon: <Link size={16} weight={'bold'} />,
style: readwiseConnected ? 'ctaWhite' : 'ctaDarkYellow',
style: readwise ? 'ctaWhite' : 'ctaDarkYellow',
action: () => {
readwiseConnected
? deleteIntegration(readwiseConnected.id)
readwise
? deleteIntegration(readwise.id)
: router.push('/settings/integrations/readwise')
},
},
@ -305,11 +316,11 @@ export default function Integrations(): JSX.Element {
subText:
'Notion is an all-in-one workspace. Use our Notion integration to sync your Omnivore items to Notion.',
button: {
text: isConnected('NOTION') ? 'Settings' : 'Connect',
text: notion ? 'Settings' : 'Connect',
icon: <Link size={16} weight={'bold'} />,
style: isConnected('NOTION') ? 'ctaWhite' : 'ctaDarkYellow',
style: notion ? 'ctaWhite' : 'ctaDarkYellow',
action: () => {
isConnected('NOTION')
notion
? router.push('/settings/integrations/notion')
: redirectToIntegration('NOTION')
},
@ -317,7 +328,7 @@ export default function Integrations(): JSX.Element {
})
setIntegrationsArray(integrationsArray)
}, [pocketConnected, readwiseConnected, webhooks, integrations])
}, [getIntegration, router])
return (
<SettingsLayout>

View file

@ -1,15 +1,5 @@
import {
Button,
Checkbox,
Form,
FormProps,
Input,
message,
Space,
Spin,
} from 'antd'
import { Button, Form, FormProps, Input, message, Space, Spin } from 'antd'
import 'antd/dist/antd.compact.css'
import { CheckboxValueType } from 'antd/lib/checkbox/Group'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useState } from 'react'
@ -26,18 +16,14 @@ import {
import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation'
import { apiFetcher } from '../../../lib/networking/networkHelpers'
import { useGetIntegrationQuery } from '../../../lib/networking/queries/useGetIntegrationQuery'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { showSuccessToast } from '../../../lib/toastHelpers'
type FieldType = {
parentPageId?: string
parentDatabaseId?: string
parentDatabaseId: string
properties?: string[]
}
export default function Notion(): JSX.Element {
applyStoredTheme()
const router = useRouter()
const { integration: notion, revalidate } = useGetIntegrationQuery('notion')
@ -47,19 +33,18 @@ export default function Notion(): JSX.Element {
useEffect(() => {
form.setFieldsValue({
parentPageId: notion.settings?.parentPageId,
parentDatabaseId: notion.settings?.parentDatabaseId,
properties: notion.settings?.properties,
})
}, [form, notion])
const deleteNotion = async () => {
const deleteNotion = useCallback(async () => {
await deleteIntegrationMutation(notion.id)
showSuccessToast('Notion integration disconnected successfully.')
revalidate()
router.push('/settings/integrations')
}
}, [notion.id, router])
const updateNotion = async (values: FieldType) => {
await setIntegrationMutation({
@ -72,6 +57,28 @@ export default function Notion(): JSX.Element {
})
}
const normalizeDatabaseId = useCallback(
(value: string) => {
// check if database id is in UUIDv4 format
const uuidRegex =
/^[0-9a-fA-F]{8}[0-9a-fA-F]{4}[0-9a-fA-F]{4}[0-9a-fA-F]{4}[0-9a-fA-F]{12}$/
if (uuidRegex.test(value)) {
return value
}
// extract the database id from the URL
// https://www.notion.so/ec460c235baa4da5bb412971a12e9dbe?v=8f4e324c0b584b67b8b7cfe9a2f996d7 -> ec460c235baa4da5bb412971a12e9dbe
const urlRegex = /https:\/\/www.notion.so\/([a-f0-9]{32})\?*/
const match = value.match(urlRegex)
if (!match || match.length < 2) {
messageApi.error('Invalid Notion Database ID.')
return value
}
return match[1]
},
[messageApi]
)
const onFinish: FormProps<FieldType>['onFinish'] = async (values) => {
try {
await updateNotion(values)
@ -89,10 +96,6 @@ export default function Notion(): JSX.Element {
console.log('Failed:', errorInfo)
}
const onDataChange = (value: Array<CheckboxValueType>) => {
form.setFieldsValue({ properties: value.map((v) => v.toString()) })
}
const exportToNotion = useCallback(async () => {
if (exporting) {
messageApi.warning('Exporting process is already running.')
@ -122,7 +125,7 @@ export default function Notion(): JSX.Element {
} catch (error) {
messageApi.error('There was an error exporting to Notion.')
}
}, [exporting, messageApi, notion.id])
}, [exporting, messageApi, notion])
return (
<>
@ -168,37 +171,43 @@ export default function Notion(): JSX.Element {
onFinishFailed={onFinishFailed}
>
<Form.Item<FieldType>
label="Notion Page Id"
name="parentPageId"
help="The id of the Notion page where the items will be exported to. You can find it in the URL of the page."
label="Notion Database ID"
name="parentDatabaseId"
help="The ID of the Notion database where the items will be exported to. You can find it in the URL of the database."
normalize={normalizeDatabaseId}
rules={[
{
required: true,
message: 'Please input your Notion Page Id!',
message: 'Please input your Notion Database ID!',
},
{
validator: (_, value) => {
// check if database id is in UUIDv4 format
const uuidRegex = /^[0-9a-fA-F]{8}[0-9a-fA-F]{4}[0-9a-fA-F]{4}[0-9a-fA-F]{4}[0-9a-fA-F]{12}$/
if (uuidRegex.test(value)) {
return Promise.resolve()
}
// extract the database id from the URL
const urlRegex =
/https:\/\/www.notion.so\/([a-f0-9]{32})\?*/
const match = value.match(urlRegex)
if (match && match.length >= 2) {
return Promise.resolve()
}
return Promise.reject(
new Error('Invalid Notion Database ID.')
)
},
},
]}
>
<Input />
</Form.Item>
<Form.Item<FieldType>
label="Notion Database Id"
name="parentDatabaseId"
hidden
<Form.Item
wrapperCol={{ offset: 6 }}
style={{ marginTop: '30px' }}
>
<Input disabled />
</Form.Item>
<Form.Item<FieldType>
label="Properties to Export"
name="properties"
>
<Checkbox.Group onChange={onDataChange}>
<Checkbox value="highlights">Highlights</Checkbox>
</Checkbox.Group>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
Save

View file

@ -21,6 +21,7 @@ import { Label } from '../../lib/networking/fragments/labelFragment'
import { CheckSquare, Circle, Square } from 'phosphor-react'
import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment'
import { usePersistedState } from '../../lib/hooks/usePersistedState'
import { escapeQuotes } from '../../utils/helper'
export type PinnedSearch = {
type: 'saved-search' | 'label'
@ -282,7 +283,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
type: 'label',
itemId: props.label.id,
name: props.label.name,
search: `label:\"${props.label.name}\"`,
search: `label:\"${escapeQuotes(props.label.name)}\"`,
}}
listAction={props.listAction}
>

View file

@ -1,5 +1,4 @@
import { Button, Form, Input, Modal, Select, Space, Table, Tag } from 'antd'
// import 'antd/dist/antd.dark.css'
import 'antd/dist/antd.compact.css'
import { useCallback, useMemo, useState } from 'react'
import { Toaster } from 'react-hot-toast'
@ -8,13 +7,14 @@ import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Label } from '../../lib/networking/fragments/labelFragment'
import { deleteRuleMutation } from '../../lib/networking/mutations/deleteRuleMutation'
import { setRuleMutation } from '../../lib/networking/mutations/setRuleMutation'
import { useGetIntegrationsQuery } from '../../lib/networking/queries/useGetIntegrationsQuery'
import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery'
import {
Rule,
RuleAction,
RuleActionType,
RuleEventType,
useGetRulesQuery,
useGetRulesQuery
} from '../../lib/networking/queries/useGetRulesQuery'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
@ -30,7 +30,7 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
const onOk = async (values: any) => {
const name = form.getFieldValue('name')
const filter = form.getFieldValue('filter')
const filter = form.getFieldValue('filter') || 'in:all' // default to all
const eventTypes = form.getFieldValue('eventTypes')
try {
await setRuleMutation({
@ -81,11 +81,7 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
<Input />
</Form.Item>
<Form.Item
label="Filter"
name="filter"
rules={[{ required: true, message: 'Please enter the rule filter' }]}
>
<Form.Item label="Filter" name="filter">
<Input />
</Form.Item>
@ -108,7 +104,7 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
const value = Object.values(RuleEventType)[index]
return (
<Select.Option key={key} value={value}>
{key}
{key === 'LABEL_CREATED' ? 'LABEL_ATTACHED' : key}
</Select.Option>
)
})}
@ -128,12 +124,34 @@ type CreateActionModalProps = {
const CreateActionModal = (props: CreateActionModalProps): JSX.Element => {
const [form] = Form.useForm()
const { labels } = useGetLabelsQuery()
const { integrations } = useGetIntegrationsQuery()
const integrationOptions = ['NOTION', 'READWISE']
const isIntegrationEnabled = (integration: string): boolean => {
return integrations.some(
(i) => i.name.toUpperCase() === integration.toUpperCase()
)
}
const onOk = async (values: any) => {
const actionType = form.getFieldValue('actionType') as RuleActionType
const params =
actionType == RuleActionType.AddLabel ? form.getFieldValue('labels') : []
let params = []
if (actionType == RuleActionType.AddLabel) {
params = form.getFieldValue('labels')
} else if (actionType == RuleActionType.Webhook) {
params = [form.getFieldValue('url')]
} else if (actionType == RuleActionType.Export) {
params = form.getFieldValue('integrations')
}
if (props.rule) {
// prevent adding duplicate actions
if (props.rule.actions.some((a) => a.type === actionType)) {
showErrorToast('Action already exists in the rule.')
return
}
await setRuleMutation({
id: props.rule.id,
name: props.rule.name,
@ -155,8 +173,9 @@ const CreateActionModal = (props: CreateActionModalProps): JSX.Element => {
}
}
const [actionType, setActionType] =
useState<RuleActionType | undefined>(undefined)
const [actionType, setActionType] = useState<RuleActionType | undefined>(
undefined
)
return (
<Modal
@ -215,6 +234,59 @@ const CreateActionModal = (props: CreateActionModalProps): JSX.Element => {
</Select>
</Form.Item>
)}
{actionType == RuleActionType.Webhook && (
<Form.Item
label="URL"
name="url"
rules={[
{ required: true, message: 'Please key in your webhook url' },
]}
>
<Input />
</Form.Item>
)}
{actionType == RuleActionType.Export && (
<Form.Item
label="Integrations"
name="integrations"
hasFeedback
rules={[
{
required: true,
message: 'Please choose at least one integration',
},
{
validator: (_, value: string[]) => {
value.forEach((v) => {
if (!isIntegrationEnabled(v)) {
return Promise.reject(`Integration ${v} is not enabled`)
}
})
return Promise.resolve()
},
},
]}
>
<Select mode="multiple">
{integrationOptions.map((integration) => {
return (
<Select.Option key={integration} value={integration}>
{isIntegrationEnabled(integration) ? (
integration
) : (
<Button type="link" href="/settings/integrations">
Connect to {integration}
</Button>
)}
</Select.Option>
)
})}
</Select>
</Form.Item>
)}
</Form>
</Modal>
)
@ -224,8 +296,9 @@ export default function Rules(): JSX.Element {
const { rules, revalidate } = useGetRulesQuery()
const { labels } = useGetLabelsQuery()
const [isCreateRuleModalOpen, setIsCreateRuleModalOpen] = useState(false)
const [createActionRule, setCreateActionRule] =
useState<Rule | undefined>(undefined)
const [createActionRule, setCreateActionRule] = useState<Rule | undefined>(
undefined
)
const dataSource = useMemo(() => {
return rules.map((rule: Rule) => {
@ -267,7 +340,7 @@ export default function Rules(): JSX.Element {
})?.name ?? 'unknown'
)
}
return ''
return param
},
[labels]
)
@ -304,7 +377,7 @@ export default function Rules(): JSX.Element {
{row.actions.map((action: RuleAction, index: number) => {
const color = action.type.length > 5 ? 'geekblue' : 'green'
return (
<Tag color={color} key={index}>
<Tag color={color} key={index} style={{ whiteSpace: 'unset' }}>
{action.type}(
{action.params.map((param: string, index: number) => {
const paramString = stringForActionParam(action.type, param)

View file

@ -34,7 +34,7 @@ import { CheckSquare, Square } from 'phosphor-react'
import { Button } from '../../components/elements/Button'
import { styled } from '@stitches/react'
import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment'
import { escapeQuotes } from '../../utils/helper'
type ListAction = 'RESET' | 'ADD_ITEM' | 'REMOVE_ITEM'
const SHORTCUTS_KEY = 'library-shortcuts'
@ -365,7 +365,7 @@ const AvailableItems = (props: ListProps): JSX.Element => {
type: 'label',
label: label,
name: label.name,
filter: `label:\"${label.name}\"`,
filter: `label:\"${escapeQuotes(label.name)}\"`,
}
props.dispatchList({
item,
@ -416,7 +416,7 @@ const AvailableItems = (props: ListProps): JSX.Element => {
: 'feed',
filter:
subscription.type == SubscriptionType.NEWSLETTER
? `subscription:\"${subscription.name}\"`
? `subscription:\"${escapeQuotes(subscription.name)}\"`
: `rss:\"${subscription.url}\"`,
}
props.dispatchList({

View file

@ -0,0 +1 @@
export const escapeQuotes = (str: string) => str.replace(/"/g, '\\"')

View file

@ -1,6 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 14",
"compilerOptions": {
"lib": ["es2020", "dom"],

View file

@ -2233,17 +2233,6 @@
pirates "^4.0.5"
source-map-support "^0.5.16"
"@babel/register@^7.14.5":
version "7.15.3"
resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.15.3.tgz#6b40a549e06ec06c885b2ec42c3dd711f55fe752"
integrity sha512-mj4IY1ZJkorClxKTImccn4T81+UKTo4Ux0+OFSV9hME1ooqS9UV+pJ6BjD0qXPK4T3XW/KNa79XByjeEMZz+fw==
dependencies:
clone-deep "^4.0.1"
find-cache-dir "^2.0.0"
make-dir "^2.1.0"
pirates "^4.0.0"
source-map-support "^0.5.16"
"@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.9.2":
version "7.15.3"
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.15.3.tgz#28754263988198f2a928c09733ade2fb4d28089d"
@ -11686,16 +11675,10 @@ camelcase@^6.0.0, camelcase@^6.2.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001251, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001317:
version "1.0.30001527"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001527.tgz"
integrity sha512-YkJi7RwPgWtXVSgK4lG9AHH57nSzvvOp9MesgXmw4Q7n0C3H04L0foHqfxcmSAm5AcWb8dW9AYj2tR7/5GnddQ==
caniuse-lite@^1.0.30001406:
version "1.0.30001554"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001554.tgz#ba80d88dff9acbc0cd4b7535fc30e0191c5e2e2a"
integrity sha512-A2E3U//MBwbJVzebddm1YfNp7Nud5Ip+IPn4BozBmn4KqVX7AvluoIDFWjsv5OkGnKUXQVmMSoMKLa3ScCblcQ==
caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001251, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001406:
version "1.0.30001600"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz"
integrity sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==
capital-case@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/capital-case/-/capital-case-1.0.4.tgz#9d130292353c9249f6b00fa5852bee38a717e669"
@ -12914,19 +12897,6 @@ copy-to-clipboard@^3.3.1:
dependencies:
toggle-selection "^1.0.6"
copyfiles@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/copyfiles/-/copyfiles-2.4.1.tgz#d2dcff60aaad1015f09d0b66e7f0f1c5cd3c5da5"
integrity sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==
dependencies:
glob "^7.0.5"
minimatch "^3.0.3"
mkdirp "^1.0.4"
noms "0.0.0"
through2 "^2.0.1"
untildify "^4.0.0"
yargs "^16.1.0"
core-js-compat@^3.20.2, core-js-compat@^3.21.0:
version "3.21.1"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.21.1.tgz#cac369f67c8d134ff8f9bd1623e3bc2c42068c82"
@ -16833,7 +16803,7 @@ glob-to-regexp@^0.4.1:
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
glob@7, glob@^7.0.5:
glob@7:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@ -22431,7 +22401,7 @@ minimatch@5.0.1:
dependencies:
brace-expansion "^2.0.1"
minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
@ -23346,11 +23316,6 @@ node-mailjet@^6.0.5:
json-bigint "^1.0.0"
url-join "^4.0.0"
node-modules-regexp@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40"
integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=
node-plop@~0.26.2:
version "0.26.2"
resolved "https://registry.yarnpkg.com/node-plop/-/node-plop-0.26.2.tgz#c2523596dab4e28360e615b768b11b4d60d5b1b9"
@ -23427,14 +23392,6 @@ nofilter@^3.1.0:
resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-3.1.0.tgz#c757ba68801d41ff930ba2ec55bab52ca184aa66"
integrity sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==
noms@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/noms/-/noms-0.0.0.tgz#da8ebd9f3af9d6760919b27d9cdc8092a7332859"
integrity sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==
dependencies:
inherits "^2.0.1"
readable-stream "~1.0.31"
nopt@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
@ -25199,13 +25156,6 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pirates@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87"
integrity sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==
dependencies:
node-modules-regexp "^1.0.0"
pirates@^4.0.1, pirates@^4.0.4, pirates@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b"
@ -27114,16 +27064,6 @@ readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.0.6, readable
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readable-stream@~1.0.31:
version "1.0.34"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
integrity sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
readdir-scoped-modules@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
@ -29252,11 +29192,6 @@ string_decoder@^1.0.0, string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@ -29868,7 +29803,7 @@ throttleit@^1.0.0:
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=
through2@^2.0.0, through2@^2.0.1, through2@~2.0.0:
through2@^2.0.0, through2@~2.0.0:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
@ -32209,7 +32144,7 @@ yargs-unparser@2.0.0:
flat "^5.0.2"
is-plain-obj "^2.1.0"
yargs@16.2.0, yargs@^16.0.0, yargs@^16.1.0, yargs@^16.2.0:
yargs@16.2.0, yargs@^16.0.0, yargs@^16.2.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==