Files
omnivore/packages/api/src/utils/search.ts
2023-10-10 12:52:49 +08:00

549 lines
12 KiB
TypeScript

/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
ISearchParserDictionary,
parse,
SearchParserKeyWordOffset,
SearchParserTextOffset,
} from 'search-query-parser'
import { InputMaybe, PageType, SortParams } from '../generated/graphql'
export enum ReadFilter {
ALL,
READ,
READING,
UNREAD,
}
export enum InFilter {
ALL,
INBOX,
ARCHIVE,
TRASH,
SUBSCRIPTION,
LIBRARY,
}
export interface SearchFilter {
query: string | undefined
inFilter: InFilter
readFilter: ReadFilter
typeFilter?: string
labelFilters: LabelFilter[]
sort?: Sort
hasFilters: HasFilter[]
dateFilters: DateFilter[]
termFilters: FieldFilter[]
matchFilters: FieldFilter[]
ids: string[]
recommendedBy?: string
noFilters: NoFilter[]
rangeFilters: RangeFilter[]
}
export enum LabelFilterType {
INCLUDE,
EXCLUDE,
}
export type LabelFilter = {
type: LabelFilterType
labels: string[]
}
export enum HasFilter {
HIGHLIGHTS,
LABELS,
}
export interface DateFilter {
field: string
startDate?: Date
endDate?: Date
}
export interface RangeFilter {
field: string
operator: string
value: number
}
export enum SortBy {
SAVED = 'savedAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
READ = 'readAt',
WORDS_COUNT = 'wordCount',
}
export enum SortOrder {
ASCENDING = 'ASC',
DESCENDING = 'DESC',
}
export interface Sort {
by: SortBy
order?: SortOrder
}
export interface FieldFilter {
nested?: boolean
field: string
value: string
}
export interface NoFilter {
field: string
}
const parseStringValue = (str?: string): string | undefined => {
if (str === undefined) {
return undefined
}
return str.toLowerCase()
}
const parseIsFilter = (str: string | undefined): ReadFilter => {
switch (str?.toUpperCase()) {
case 'READ':
return ReadFilter.READ
case 'READING':
return ReadFilter.READING
case 'UNREAD':
return ReadFilter.UNREAD
}
return ReadFilter.ALL
}
const parseInFilter = (
str: string | undefined,
query: string | undefined
): InFilter => {
switch (str?.toUpperCase()) {
case 'ALL':
return InFilter.ALL
case 'INBOX':
return InFilter.INBOX
case 'ARCHIVE':
return InFilter.ARCHIVE
case 'TRASH':
return InFilter.TRASH
case 'SUBSCRIPTION':
return InFilter.SUBSCRIPTION
case 'LIBRARY':
return InFilter.LIBRARY
}
return query ? InFilter.ALL : InFilter.INBOX
}
const parseTypeFilter = (str: string | undefined): string | undefined => {
if (str === undefined) {
return undefined
}
switch (str.toLowerCase()) {
case 'article':
return PageType.Article
case 'book':
return PageType.Book
case 'pdf':
case 'file':
return PageType.File
case 'profile':
return PageType.Profile
case 'website':
return PageType.Website
case 'unknown':
return PageType.Unknown
}
return undefined
}
const parseLabelFilter = (
str?: string,
exclude?: ISearchParserDictionary
): LabelFilter | undefined => {
if (str === undefined) {
return undefined
}
const labels = str.split(',')
// check if the labels are on the exclusion list
const excluded = exclude?.label && exclude.label.includes(...labels)
return {
type: excluded ? LabelFilterType.EXCLUDE : LabelFilterType.INCLUDE,
// use lower case for label names
labels: labels.map((label) => label.toLowerCase()),
}
}
const parseSort = (str?: string): Sort | undefined => {
if (str === undefined) {
return undefined
}
const [sort, order] = str.split('-')
const sortOrder =
order?.toUpperCase() === 'ASC' ? SortOrder.ASCENDING : SortOrder.DESCENDING
switch (sort.toUpperCase()) {
case 'UPDATED':
return {
by: SortBy.UPDATED,
order: sortOrder,
}
case 'SAVED':
return {
by: SortBy.SAVED,
order: sortOrder,
}
case 'PUBLISHED':
return {
by: SortBy.PUBLISHED,
order: sortOrder,
}
case 'READ':
return {
by: SortBy.READ,
order: sortOrder,
}
case 'WORDSCOUNT':
return {
by: SortBy.WORDS_COUNT,
order: sortOrder,
}
}
}
const parseHasFilter = (str?: string): HasFilter | undefined => {
if (str === undefined) {
return undefined
}
switch (str.toUpperCase()) {
case 'HIGHLIGHTS':
return HasFilter.HIGHLIGHTS
case 'LABELS':
return HasFilter.LABELS
}
}
const parseDateFilter = (
field: string,
str?: string
): DateFilter | undefined => {
if (str === undefined) {
return undefined
}
const [start, end] = str.split('..')
const startDate = start && start !== '*' ? new Date(start) : undefined
const endDate = end && end !== '*' ? new Date(end) : undefined
switch (field.toUpperCase()) {
case 'PUBLISHED':
field = 'publishedAt'
break
case 'SAVED':
field = 'savedAt'
break
case 'UPDATED':
field = 'updatedAt'
}
return {
field,
startDate,
endDate,
}
}
const parseRangeFilter = (
field: string,
str?: string
): RangeFilter | undefined => {
if (str === undefined) {
return undefined
}
switch (field.toUpperCase()) {
case 'WORDSCOUNT':
field = 'word_count'
break
case 'READPOSITION':
field = 'reading_progress_bottom_percent'
break
default:
return undefined
}
const operatorRegex = /([<>]=?)/
const operator = str.match(operatorRegex)?.[0]
if (!operator) {
return undefined
}
const value = str.replace(operatorRegex, '')
if (!value) {
return undefined
}
return {
field,
operator,
value: Number(value),
}
}
const parseFieldFilter = (
field: string,
str?: string
): FieldFilter | undefined => {
if (str === undefined) {
return undefined
}
// normalize the term to lower case
const value = str.toLowerCase()
switch (field.toUpperCase()) {
case 'LANGUAGE':
return {
field: 'item_language',
value,
}
case 'SUBSCRIPTION':
case 'RSS':
return {
field: 'subscription',
value,
}
}
return {
field,
value,
}
}
const parseIds = (field: string, str?: string): string[] | undefined => {
if (str === undefined) {
return undefined
}
return str.split(',')
}
const parseNoFilter = (str?: string): NoFilter | undefined => {
if (str === undefined) {
return undefined
}
const strLower = str.toLowerCase()
switch (strLower) {
case 'highlight':
return { field: 'highlight_annotations' }
case 'label':
return { field: 'label_names' }
}
return undefined
}
export const parseSearchQuery = (query: string | undefined): SearchFilter => {
const searchQuery = query ? query.replace(/\W\s":/g, '') : undefined
const result: SearchFilter = {
query: searchQuery,
readFilter: ReadFilter.ALL,
inFilter: searchQuery ? InFilter.ALL : InFilter.INBOX,
labelFilters: [],
hasFilters: [],
dateFilters: [],
termFilters: [],
matchFilters: [],
ids: [],
noFilters: [],
rangeFilters: [],
}
if (!searchQuery) {
return {
query: undefined,
inFilter: InFilter.INBOX,
readFilter: ReadFilter.ALL,
labelFilters: [],
hasFilters: [],
dateFilters: [],
termFilters: [],
matchFilters: [],
ids: [],
noFilters: [],
rangeFilters: [],
}
}
const parsed = parse(searchQuery, {
keywords: [
'in',
'is',
'type',
'label',
'sort',
'has',
'saved',
'author',
'published',
'subscription',
'language',
'title',
'description',
'content',
'updated',
'includes',
'recommendedBy',
'no',
'mode',
'site',
'note',
'rss',
'wordsCount',
'readPosition',
],
tokenize: true,
})
if (parsed.offsets) {
const texts = parsed.offsets
.filter((offset) => 'text' in offset)
.map((offset) => offset as SearchParserTextOffset)
if (texts.length > 0) {
result.query = texts
.map((offset: SearchParserTextOffset) => {
// TODO: the parser library doesn't let us accurately
// pull out quoted text, so we are just assuming
// anything with spaces is quoted.
if (offset.text.indexOf(' ') > -1) {
return `"${offset.text}"`
}
return offset.text
})
.join(' ')
} else {
result.query = undefined
}
const keywords = parsed.offsets
.filter((offset) => 'keyword' in offset)
.map((offset) => offset as SearchParserKeyWordOffset)
for (const keyword of keywords) {
switch (keyword.keyword) {
case 'in':
result.inFilter = parseInFilter(keyword.value, result.query)
break
case 'is':
result.readFilter = parseIsFilter(keyword.value)
break
case 'type':
result.typeFilter = parseTypeFilter(keyword.value)
break
case 'label': {
const labelFilter = parseLabelFilter(keyword.value, parsed.exclude)
labelFilter && result.labelFilters.push(labelFilter)
break
}
case 'sort':
result.sort = parseSort(keyword.value)
break
case 'has': {
const hasFilter = parseHasFilter(keyword.value)
hasFilter !== undefined && result.hasFilters.push(hasFilter)
break
}
case 'saved':
case 'read':
case 'updated':
case 'published': {
const dateFilter = parseDateFilter(keyword.keyword, keyword.value)
dateFilter && result.dateFilters.push(dateFilter)
break
}
// term filters
case 'subscription':
case 'rss':
case 'language': {
const fieldFilter = parseFieldFilter(keyword.keyword, keyword.value)
fieldFilter && result.termFilters.push(fieldFilter)
break
}
// match filters
case 'author':
case 'title':
case 'description':
case 'note':
case 'site':
case 'content': {
const fieldFilter = parseFieldFilter(keyword.keyword, keyword.value)
fieldFilter && result.matchFilters.push(fieldFilter)
break
}
case 'includes': {
const ids = parseIds(keyword.keyword, keyword.value)
ids && result.ids.push(...ids)
break
}
case 'recommendedBy': {
result.recommendedBy = parseStringValue(keyword.value)
break
}
case 'no': {
const noFilter = parseNoFilter(keyword.value)
noFilter && result.noFilters.push(noFilter)
break
}
case 'mode':
// mode is ignored and used only by the frontend
break
case 'readPosition':
case 'wordsCount': {
const rangeFilter = parseRangeFilter(keyword.keyword, keyword.value)
rangeFilter && result.rangeFilters.push(rangeFilter)
break
}
}
}
}
return result
}
export const sortParamsToSort = (
sortParams: InputMaybe<SortParams> | undefined
) => {
const sort = { by: SortBy.UPDATED, order: SortOrder.DESCENDING }
if (sortParams) {
sortParams.order === 'ASCENDING' && (sort.order = SortOrder.ASCENDING)
switch (sortParams.by) {
case 'UPDATED_TIME':
sort.by = SortBy.UPDATED
break
case 'PUBLISHED_AT':
sort.by = SortBy.PUBLISHED
break
case 'SAVED_AT':
sort.by = SortBy.SAVED
break
}
}
return sort
}