diff --git a/packages/api/src/jobs/trigger_rule.ts b/packages/api/src/jobs/trigger_rule.ts index d00d6ded8..f94e3c37a 100644 --- a/packages/api/src/jobs/trigger_rule.ts +++ b/packages/api/src/jobs/trigger_rule.ts @@ -4,9 +4,8 @@ import { LibraryItemState } from '../entity/library_item' import { Rule, RuleAction, RuleActionType, RuleEventType } from '../entity/rule' import { addLabelsToLibraryItem } from '../services/labels' import { - CreateItemEvent, + ItemEvent, softDeleteLibraryItem, - UpdateItemEvent, updateLibraryItem, } from '../services/library_item' import { findEnabledRules, markRuleAsFailed } from '../services/rules' @@ -14,19 +13,18 @@ import { sendPushNotifications } from '../services/user' import { logger } from '../utils/logger' import { parseSearchQuery } from '../utils/search' -type Data = CreateItemEvent | UpdateItemEvent export interface TriggerRuleJobData { libraryItemId: string userId: string ruleEventType: RuleEventType - data: Data + data: ItemEvent } interface RuleActionObj { libraryItemId: string userId: string action: RuleAction - data: Data + data: ItemEvent } type RuleActionFunc = (obj: RuleActionObj) => Promise @@ -104,12 +102,12 @@ const triggerActions = async ( libraryItemId: string, userId: string, rules: Rule[], - data: Data + data: ItemEvent ) => { const actionPromises: Promise[] = [] for (const rule of rules) { - let filteredData: Data + let filteredData: ItemEvent try { const ast = parseSearchQuery(rule.filter) diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index b2f2fa049..dcea18b29 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -1,4 +1,5 @@ import { ExpressionToken, LiqeQuery } from '@omnivore/liqe' +import { camelCase } from 'lodash' import { DateTime } from 'luxon' import { DeepPartial, @@ -32,12 +33,10 @@ import { addLabelsToLibraryItem } from './labels' type IgnoredFields = | 'user' | 'uploadFile' - | 'labelNames' - | 'highlightAnnotations' | 'previewContentType' | 'links' - | 'recommenderNames' | 'textContentHash' +export type ItemEvent = CreateItemEvent | UpdateItemEvent export type CreateItemEvent = Omit, IgnoredFields> export type UpdateItemEvent = Omit< QueryDeepPartialEntity, @@ -1295,3 +1294,297 @@ export const findLibraryItemIdsByLabelId = async ( userId ) } + +export const filterItemEvents = ( + ast: LiqeQuery, + events: readonly ItemEvent[] +): readonly ItemEvent[] => { + const testNo = (value: string, event: ItemEvent) => { + const keywordRegexMap: Record = { + highlightAnnotations: /^highlight(s)?$/i, + labelNames: /^label(s)?$/i, + subscription: /^subscription(s)?$/i, + } + + const matchingKeyword = Object.keys(keywordRegexMap).find((keyword) => + value.match(keywordRegexMap[keyword]) + ) + + if (!matchingKeyword) { + throw new Error(`Unexpected keyword: ${value}`) + } + + const key = matchingKeyword as + | 'highlightAnnotations' + | 'labelNames' + | 'subscription' + const eventValue = event[key] as string | string[] + + return !eventValue || (Array.isArray(eventValue) && eventValue.length === 0) + } + + const testEvent = (ast: LiqeQuery, event: ItemEvent) => { + if (ast.type !== 'Tag') { + throw new Error('Expected a tag expression.') + } + + const { field, expression } = ast + + if (field.type === 'ImplicitField') { + // TODO: Implement full text search + throw new Error('Full text search is not supported.') + } + if (expression.type !== 'LiteralExpression') { + // ignore empty values + throw new Error('Expected a literal expression.') + } + + const value = expression.value?.toString() + if (!value) { + // ignore empty values + throw new Error('Expected a non-empty value.') + } + + switch (field.name.toLowerCase()) { + case 'in': { + switch (value.toLowerCase()) { + case InFilter.ALL: + return true + case InFilter.ARCHIVE: + return event.state === LibraryItemState.Archived + case InFilter.TRASH: + return event.state === LibraryItemState.Deleted + default: + return event.state === LibraryItemState.Succeeded + } + } + + case 'is': { + switch (value.toLowerCase()) { + case ReadFilter.READ: + return ( + event.readingProgressBottomPercent && + event.readingProgressBottomPercent > 98 + ) + case ReadFilter.READING: + return ( + event.readingProgressBottomPercent && + event.readingProgressBottomPercent >= 2 && + event.readingProgressBottomPercent <= 98 + ) + case ReadFilter.UNREAD: + return ( + !event.readingProgressBottomPercent || + event.readingProgressBottomPercent < 2 + ) + default: + throw new Error(`Unexpected keyword: ${value}`) + } + } + case 'type': { + return event.itemType?.toString().toLowerCase() === value.toLowerCase() + } + case 'label': { + const labels = event.labelNames as string[] + const labelsToTest = value.toLowerCase().split(',') + return labelsToTest.some((label) => { + const hasWildcard = label.includes('*') + if (hasWildcard) { + return labels.some((l) => l.match(new RegExp(label, 'i'))) + } + + return labels.some((l) => l.toLowerCase() === label) + }) + } + case 'has': + return !testNo(value, event) + case 'read': + case 'updated': + case 'published': { + let startDate: Date | undefined + let endDate: Date | undefined + // check for special date filters + switch (value.toLowerCase()) { + case 'today': + startDate = DateTime.local().startOf('day').toJSDate() + break + case 'yesterday': { + const yesterday = DateTime.local().minus({ days: 1 }) + startDate = yesterday.startOf('day').toJSDate() + endDate = yesterday.endOf('day').toJSDate() + break + } + case 'this week': + startDate = DateTime.local().startOf('week').toJSDate() + break + case 'this month': + startDate = DateTime.local().startOf('month').toJSDate() + break + default: { + // check for date ranges + const [start, end] = value.split('..') + // validate date + if (start && start !== '*') { + startDate = new Date(start) + if (isNaN(startDate.getTime())) { + throw new Error('Invalid start date') + } + } + + if (end && end !== '*') { + endDate = new Date(end) + if (isNaN(endDate.getTime())) { + throw new Error('Invalid end date') + } + } + } + } + + const start = startDate ?? new Date(0) + const end = endDate ?? new Date() + const key = `${field.name.toLowerCase()}At` as + | 'readAt' + | 'updatedAt' + | 'publishedAt' + const eventValue = event[key] as Date + + return eventValue >= start && eventValue <= end + } + // term filters + case 'subscription': + case 'rss': + case 'language': { + const columnName = getColumnName(field.name) + // get camel case column name + const key = camelCase(columnName) as 'subscription' | 'itemLanguage' + + return event[key]?.toString().toLowerCase() === value.toLowerCase() + } + // match filters + case 'author': + case 'title': + case 'description': + case 'note': + case 'site': { + const columnName = getColumnName(field.name) + const key = camelCase(columnName) as + | 'author' + | 'title' + | 'description' + | 'note' + | 'siteName' + + // TODO: Implement full text search + return event[key]?.toString().match(new RegExp(value, 'i')) + } + case 'includes': { + const ids = value.split(',') + if (!ids || ids.length === 0) { + throw new Error('Expected ids') + } + + return event.id && ids.includes(event.id.toString()) + } + case 'recommendedby': { + if (value === '*') { + // select all if * is provided + return event.recommenderNames && event.recommenderNames.length > 0 + } + + return ( + event.recommenderNames && + (event.recommenderNames as string[]).some( + (name) => name.toLowerCase() === value.toLowerCase() + ) + ) + } + case 'no': + return testNo(value, event) + case 'use': + case 'mode': + case 'event': + // mode is ignored and used only by the frontend + return true + case 'readposition': + case 'wordscount': { + const operatorRegex = /([<>]=?)/ + const operator = value.match(operatorRegex)?.[0] + if (!operator) { + throw new Error('Expected operator') + } + + const newValue = value.replace(operatorRegex, '') + const intValue = parseInt(newValue, 10) + + const column = getColumnName(field.name) + const key = camelCase(column) as + | 'wordCount' + | 'readingProgressBottomPercent' + const eventValue = event[key] as number + + switch (operator) { + case '>': + return eventValue > intValue + case '>=': + return eventValue >= intValue + case '<': + return eventValue < intValue + case '<=': + return eventValue <= intValue + default: + throw new Error('Unexpected operator') + } + } + default: + // TODO: Implement full text search + return false + // treat unknown fields as implicit fields + // return serializeImplicitField({ + // ...expression, + // value: `${field.name}:${value}`, + // }) + } + } + + if (ast.type === 'Tag') { + return events.filter((event) => { + return testEvent(ast, event) + }) + } + + if (ast.type === 'UnaryOperator') { + const removeRows = filterItemEvents(ast.operand, events) + + return events.filter((row) => { + return !removeRows.includes(row) + }) + } + + if (ast.type === 'ParenthesizedExpression') { + return filterItemEvents(ast.expression, events) + } + + if (!ast.left) { + throw new Error('Expected left to be defined.') + } + + const leftRows = filterItemEvents(ast.left, events) + + if (!ast.right) { + throw new Error('Expected right to be defined.') + } + + if (ast.type !== 'LogicalExpression') { + throw new Error('Expected a tag expression.') + } + + if (ast.operator.operator === 'OR') { + const rightRows = filterItemEvents(ast.right, events) + + return Array.from(new Set([...leftRows, ...rightRows])) + } else if (ast.operator.operator === 'AND') { + return filterItemEvents(ast.right, leftRows) + } + + throw new Error('Unexpected state.') +}