mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge branch 'main' into fix/android-library-flickering
This commit is contained in:
commit
b0c02bebff
69 changed files with 1136 additions and 775 deletions
|
|
@ -1,5 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
readabilityjs/
|
||||
src/generated/
|
||||
test/resolvers/
|
||||
|
|
|
|||
|
|
@ -5,5 +5,13 @@
|
|||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unsafe-argument": 0
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["test/**/*.ts"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unsafe-member-access": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,9 @@ export const findThumbnail = async (data: Data) => {
|
|||
{
|
||||
thumbnail,
|
||||
},
|
||||
userId
|
||||
userId,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
logger.info(`thumbnail updated: ${thumbnail}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
151
packages/api/test/services/library_item.test.ts
Normal file
151
packages/api/test/services/library_item.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,6 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"],
|
||||
"exclude": ["./src/generated", "./test"]
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["./src/generated"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
9
packages/db/migrations/0170.do.drop_unique_key_on_rules.sql
Executable file
9
packages/db/migrations/0170.do.drop_unique_key_on_rules.sql
Executable 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;
|
||||
9
packages/db/migrations/0170.undo.drop_unique_key_on_rules.sql
Executable file
9
packages/db/migrations/0170.undo.drop_unique_key_on_rules.sql
Executable 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;
|
||||
18
packages/db/migrations/0171.do.migrate_webhooks_and_exporter.sql
Executable file
18
packages/db/migrations/0171.do.migrate_webhooks_and_exporter.sql
Executable 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;
|
||||
7
packages/db/migrations/0171.undo.migrate_webhooks_and_exporter.sql
Executable file
7
packages/db/migrations/0171.undo.migrate_webhooks_and_exporter.sql
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
-- Type: UNDO
|
||||
-- Name: migrate_webhooks_and_exporter
|
||||
-- Description: Migrate data from webhooks and exporters to rules
|
||||
|
||||
BEGIN;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default;
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] });
|
||||
|
|
@ -12,8 +12,8 @@ chai.use(chaiString)
|
|||
describe('Test csv importer', () => {
|
||||
let stub: ImportContext
|
||||
|
||||
beforeEach(async () => {
|
||||
stub = await stubImportCtx()
|
||||
beforeEach(() => {
|
||||
stub = stubImportCtx()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default;
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] });
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default;
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] });
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
"spec": "test/**/*.test.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
|
|
@ -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)
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
1
packages/web/utils/helper.ts
Normal file
1
packages/web/utils/helper.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const escapeQuotes = (str: string) => str.replace(/"/g, '\\"')
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Node 14",
|
||||
|
||||
"compilerOptions": {
|
||||
"lib": ["es2020", "dom"],
|
||||
|
|
|
|||
81
yarn.lock
81
yarn.lock
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Reference in a new issue