1217 lines
34 KiB
TypeScript
1217 lines
34 KiB
TypeScript
import { LiqeQuery } from 'liqe'
|
|
import { DateTime } from 'luxon'
|
|
import {
|
|
Brackets,
|
|
DeepPartial,
|
|
ObjectLiteral,
|
|
SelectQueryBuilder,
|
|
} from 'typeorm'
|
|
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
|
import { EntityLabel } from '../entity/entity_label'
|
|
import { Highlight } from '../entity/highlight'
|
|
import { Label } from '../entity/label'
|
|
import { LibraryItem, LibraryItemState } from '../entity/library_item'
|
|
import { BulkActionType } from '../generated/graphql'
|
|
import { createPubSubClient, EntityType } from '../pubsub'
|
|
import { authTrx, getColumns } from '../repository'
|
|
import { libraryItemRepository } from '../repository/library_item'
|
|
import { SaveFollowingItemRequest } from '../routers/svc/following'
|
|
import { generateSlug, wordsCount } from '../utils/helpers'
|
|
import { createThumbnailUrl } from '../utils/imageproxy'
|
|
import {
|
|
DateFilter,
|
|
FieldFilter,
|
|
HasFilter,
|
|
InFilter,
|
|
LabelFilter,
|
|
LabelFilterType,
|
|
NoFilter,
|
|
RangeFilter,
|
|
ReadFilter,
|
|
Sort,
|
|
SortBy,
|
|
SortOrder,
|
|
} from '../utils/search'
|
|
|
|
export interface SearchArgs {
|
|
from?: number
|
|
size?: number
|
|
sort?: Sort
|
|
query?: string
|
|
inFilter?: InFilter
|
|
readFilter?: ReadFilter
|
|
typeFilter?: string
|
|
labelFilters?: LabelFilter[]
|
|
hasFilters?: HasFilter[]
|
|
dateFilters?: DateFilter[]
|
|
termFilters?: FieldFilter[]
|
|
matchFilters?: FieldFilter[]
|
|
includePending?: boolean | null
|
|
includeDeleted?: boolean
|
|
ids?: string[]
|
|
recommendedBy?: string
|
|
includeContent?: boolean
|
|
noFilters?: NoFilter[]
|
|
rangeFilters?: RangeFilter[]
|
|
useFolders?: boolean
|
|
searchQuery?: LiqeQuery
|
|
}
|
|
|
|
export interface SearchResultItem {
|
|
annotation?: string | null
|
|
author?: string | null
|
|
createdAt: Date
|
|
description?: string | null
|
|
id: string
|
|
image?: string | null
|
|
pageId?: string
|
|
pageType: string
|
|
publishedAt?: Date
|
|
quote?: string | null
|
|
shortId?: string | null
|
|
slug: string
|
|
title: string
|
|
uploadFileId?: string | null
|
|
url: string
|
|
readingProgressTopPercent?: number
|
|
readingProgressPercent: number
|
|
readingProgressAnchorIndex: number
|
|
userId: string
|
|
state?: LibraryItemState
|
|
language?: string
|
|
readAt?: Date
|
|
savedAt: Date
|
|
updatedAt?: Date
|
|
labels?: Label[]
|
|
highlights?: Highlight[]
|
|
wordsCount?: number
|
|
siteName?: string
|
|
siteIcon?: string
|
|
content?: string
|
|
}
|
|
|
|
const getColumnName = (field: string) => {
|
|
switch (field) {
|
|
case 'language':
|
|
return 'item_language'
|
|
case 'subscription':
|
|
case 'rss':
|
|
return 'subscription'
|
|
case 'site':
|
|
return 'site_name'
|
|
case 'wordsCount':
|
|
return 'word_count'
|
|
case 'readPosition':
|
|
return 'reading_progress_bottom_percent'
|
|
default:
|
|
return field
|
|
}
|
|
}
|
|
|
|
export const buildQuery = (
|
|
searchQuery: LiqeQuery,
|
|
parameters: ObjectLiteral[]
|
|
) => {
|
|
const escapeQueryWithParameters = (
|
|
query: string,
|
|
parameter: ObjectLiteral
|
|
) => {
|
|
parameters.push(parameter)
|
|
return query
|
|
}
|
|
|
|
const serializeTagExpression = (ast: LiqeQuery): string => {
|
|
if (ast.type !== 'Tag') {
|
|
throw new Error('Expected a tag expression.')
|
|
}
|
|
|
|
const { field, expression } = ast
|
|
|
|
if (field.type === 'ImplicitField') {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const value = expression.value?.toString()
|
|
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const param = `implicit_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(
|
|
`websearch_to_tsquery('english', :${param}) @@ library_item.search_tsv`,
|
|
{ [param]: value }
|
|
)
|
|
} else {
|
|
switch (field.name) {
|
|
case 'in': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const folder = expression.value?.toString()
|
|
if (!folder) {
|
|
return ''
|
|
}
|
|
|
|
switch (folder) {
|
|
case InFilter.INBOX:
|
|
return 'library_item.archived_at IS NULL'
|
|
case InFilter.ARCHIVE:
|
|
return 'library_item.archived_at IS NOT NULL'
|
|
case InFilter.TRASH:
|
|
// return only deleted pages within 14 days
|
|
return "library_item.deleted_at >= now() - interval '14 days'"
|
|
default: {
|
|
const param = `folder_${parameters.length}`
|
|
return escapeQueryWithParameters(
|
|
`library_item.folder = :${param}`,
|
|
{ [param]: folder }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
case 'is': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const value = expression.value?.toString()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
switch (value) {
|
|
case ReadFilter.READ:
|
|
return 'library_item.reading_progress_bottom_percent > 98'
|
|
case ReadFilter.READING:
|
|
return 'library_item.reading_progress_bottom_percent BETWEEN 2 AND 98'
|
|
case ReadFilter.UNREAD:
|
|
return 'library_item.reading_progress_bottom_percent < 2'
|
|
default:
|
|
throw new Error(`Unexpected keyword: ${value}`)
|
|
}
|
|
}
|
|
case 'type': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const value = expression.value?.toString()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const param = `type_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(
|
|
`LOWER(library_item.item_type) = :${param}`,
|
|
{
|
|
[param]: value.toLowerCase(),
|
|
}
|
|
)
|
|
}
|
|
case 'label': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const label = expression.value?.toString()?.toLowerCase()
|
|
if (!label) {
|
|
return ''
|
|
}
|
|
|
|
const param = `label_${parameters.length}`
|
|
|
|
const hasWildcard = label.includes('*')
|
|
if (hasWildcard) {
|
|
return escapeQueryWithParameters(
|
|
`exists (select 1 from unnest(array_cat(library_item.label_names, library_item.highlight_labels)::text[]) as label where label ILIKE :${param})`,
|
|
{
|
|
[param]: label.replace(/\*/g, '%'),
|
|
}
|
|
)
|
|
}
|
|
|
|
return escapeQueryWithParameters(
|
|
`:${param} = ANY(lower(array_cat(library_item.label_names, library_item.highlight_labels)::text)::text[])`,
|
|
{
|
|
[param]: label,
|
|
}
|
|
)
|
|
}
|
|
// case 'sort':
|
|
// result.sort = parseSort(keyword.value)
|
|
// break
|
|
case 'has': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const value = expression.value?.toString()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
switch (value) {
|
|
case HasFilter.HIGHLIGHTS:
|
|
return "library_item.highlight_annotations <> '{}'"
|
|
case HasFilter.LABELS:
|
|
return "library_item.label_names <> '{}'"
|
|
case HasFilter.SUBSCRIPTIONS:
|
|
return 'library_item.subscription is NOT NULL'
|
|
default:
|
|
throw new Error(`Unexpected keyword: ${value}`)
|
|
}
|
|
}
|
|
case 'saved':
|
|
case 'read':
|
|
case 'updated':
|
|
case 'published': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const date = expression.value?.toString()
|
|
if (!date) {
|
|
return ''
|
|
}
|
|
|
|
let startDate: Date | undefined
|
|
let endDate: Date | undefined
|
|
// check for special date filters
|
|
switch (date.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] = date.split('..')
|
|
startDate = start && start !== '*' ? new Date(start) : undefined
|
|
endDate = end && end !== '*' ? new Date(end) : undefined
|
|
}
|
|
}
|
|
|
|
const startParam = `${field.name}_start_${parameters.length}`
|
|
const endParam = `${field.name}_end_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(
|
|
`library_item.${field.name}_at BETWEEN :${startParam} AND :${endParam}`,
|
|
{
|
|
[startParam]: startDate ?? new Date(0),
|
|
[endParam]: endDate ?? new Date(),
|
|
}
|
|
)
|
|
}
|
|
// term filters
|
|
case 'subscription':
|
|
case 'rss':
|
|
case 'language': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const value = expression.value?.toString()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const columnName = getColumnName(field.name)
|
|
const param = `term_${field.name}_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(
|
|
`library_item.${columnName} = :${param}`,
|
|
{
|
|
[param]: value,
|
|
}
|
|
)
|
|
}
|
|
// match filters
|
|
case 'author':
|
|
case 'title':
|
|
case 'description':
|
|
case 'note':
|
|
case 'site': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
// normalize the term to lower case
|
|
const value = expression.value?.toString()?.toLowerCase()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const columnName = getColumnName(field.name)
|
|
const param = `match_${field.name}_${parameters.length}`
|
|
const wildcardParam = `match_${field.name}_wildcard_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(
|
|
`(websearch_to_tsquery('english', :${param}) @@ library_item.${columnName}_tsv OR library_item.${columnName} ILIKE :${wildcardParam})`,
|
|
{
|
|
[param]: value,
|
|
[wildcardParam]: `%${value}%`,
|
|
}
|
|
)
|
|
}
|
|
case 'includes': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const ids = expression.value?.toString()?.split(',')
|
|
if (!ids || ids.length === 0) {
|
|
return ''
|
|
}
|
|
|
|
const param = `includes_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(`library_item.id = ANY(:${param})`, {
|
|
[param]: ids,
|
|
})
|
|
}
|
|
// case 'recommendedBy': {
|
|
// result.recommendedBy = parseStringValue(keyword.value)
|
|
// break
|
|
// }
|
|
case 'no': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
const value = expression.value?.toString()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
let column = ''
|
|
switch (value) {
|
|
case 'highlight':
|
|
column = 'highlight_annotations'
|
|
break
|
|
case 'label':
|
|
column = 'label_names'
|
|
break
|
|
case 'subscription':
|
|
column = 'subscription'
|
|
break
|
|
default:
|
|
throw new Error(`Unexpected keyword: ${value}`)
|
|
}
|
|
|
|
return `(library_item.${column} = '{}' OR library_item.${column} IS NULL)`
|
|
}
|
|
case 'mode':
|
|
// mode is ignored and used only by the frontend
|
|
return ''
|
|
case 'readPosition':
|
|
case 'wordsCount': {
|
|
if (expression.type !== 'LiteralExpression') {
|
|
throw new Error('Expected a literal expression.')
|
|
}
|
|
|
|
let value = expression.value?.toString()
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const column = getColumnName(field.name)
|
|
|
|
const operatorRegex = /([<>]=?)/
|
|
const operator = value.match(operatorRegex)?.[0]
|
|
if (!operator) {
|
|
return ''
|
|
}
|
|
|
|
value = value.replace(operatorRegex, '')
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const param = `range_${field.name}_${parameters.length}`
|
|
|
|
return escapeQueryWithParameters(
|
|
`library_item.${column} ${operator} :${param}`,
|
|
{
|
|
[param]: parseInt(value, 10),
|
|
}
|
|
)
|
|
}
|
|
default:
|
|
throw new Error(`Unexpected keyword: ${field.name}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const serialize = (ast: LiqeQuery): string => {
|
|
if (ast.type === 'Tag') {
|
|
return serializeTagExpression(ast)
|
|
}
|
|
|
|
if (ast.type === 'LogicalExpression') {
|
|
let operator = ''
|
|
if (ast.operator.operator === 'AND') {
|
|
operator = 'AND'
|
|
} else if (ast.operator.operator === 'OR') {
|
|
operator = 'OR'
|
|
} else {
|
|
throw new Error('Unexpected operator.')
|
|
}
|
|
|
|
return `${serialize(ast.left)} ${operator} ${serialize(ast.right)}`
|
|
}
|
|
|
|
if (ast.type === 'UnaryOperator') {
|
|
return `NOT ${serialize(ast.operand)}`
|
|
}
|
|
if (ast.type === 'ParenthesizedExpression') {
|
|
return `(${serialize(ast.expression)})`
|
|
}
|
|
|
|
throw new Error('Missing AST type.')
|
|
}
|
|
|
|
return serialize(searchQuery)
|
|
}
|
|
|
|
const buildWhereClause = (
|
|
queryBuilder: SelectQueryBuilder<LibraryItem>,
|
|
args: SearchArgs
|
|
) => {
|
|
if (args.query) {
|
|
queryBuilder
|
|
.addSelect(
|
|
"ts_rank_cd(library_item.search_tsv, websearch_to_tsquery('english', :query))",
|
|
'rank'
|
|
)
|
|
.andWhere(
|
|
"websearch_to_tsquery('english', :query) @@ library_item.search_tsv"
|
|
)
|
|
.setParameter('query', args.query)
|
|
.orderBy('rank', 'DESC')
|
|
}
|
|
|
|
if (args.typeFilter) {
|
|
queryBuilder.andWhere('lower(library_item.item_type) = :typeFilter', {
|
|
typeFilter: args.typeFilter.toLowerCase(),
|
|
})
|
|
}
|
|
|
|
if (args.inFilter !== InFilter.ALL) {
|
|
switch (args.inFilter) {
|
|
case InFilter.INBOX: {
|
|
// if useFolders is true, we only return items in the inbox folder
|
|
args.useFolders &&
|
|
queryBuilder.andWhere("library_item.folder = 'inbox'")
|
|
// for old clients, we return items that are not archived
|
|
queryBuilder.andWhere('library_item.archived_at IS NULL')
|
|
break
|
|
}
|
|
case InFilter.ARCHIVE:
|
|
queryBuilder.andWhere('library_item.archived_at IS NOT NULL')
|
|
break
|
|
case InFilter.TRASH:
|
|
// return only deleted pages within 14 days
|
|
queryBuilder.andWhere(
|
|
"library_item.deleted_at >= now() - interval '14 days'"
|
|
)
|
|
break
|
|
default:
|
|
queryBuilder.andWhere('library_item.folder = :folder', {
|
|
folder: args.inFilter,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (args.readFilter !== ReadFilter.ALL) {
|
|
switch (args.readFilter) {
|
|
case ReadFilter.READ:
|
|
queryBuilder.andWhere(
|
|
'library_item.reading_progress_bottom_percent > 98'
|
|
)
|
|
break
|
|
case ReadFilter.READING:
|
|
queryBuilder.andWhere(
|
|
'library_item.reading_progress_bottom_percent BETWEEN 2 AND 98'
|
|
)
|
|
break
|
|
case ReadFilter.UNREAD:
|
|
queryBuilder.andWhere(
|
|
'library_item.reading_progress_bottom_percent < 2'
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
if (args.hasFilters && args.hasFilters.length > 0) {
|
|
args.hasFilters.forEach((filter) => {
|
|
switch (filter) {
|
|
case HasFilter.HIGHLIGHTS:
|
|
queryBuilder.andWhere("library_item.highlight_annotations <> '{}'")
|
|
break
|
|
case HasFilter.LABELS:
|
|
queryBuilder.andWhere("library_item.label_names <> '{}'")
|
|
break
|
|
case HasFilter.SUBSCRIPTIONS:
|
|
queryBuilder.andWhere('library_item.subscription is NOT NULL')
|
|
}
|
|
})
|
|
}
|
|
|
|
if (args.labelFilters && args.labelFilters.length > 0) {
|
|
const includeLabels = args.labelFilters?.filter(
|
|
(filter) => filter.type === LabelFilterType.INCLUDE
|
|
)
|
|
const excludeLabels = args.labelFilters?.filter(
|
|
(filter) => filter.type === LabelFilterType.EXCLUDE
|
|
)
|
|
|
|
if (includeLabels && includeLabels.length > 0) {
|
|
includeLabels.forEach((includeLabel, i) => {
|
|
const param = `includeLabels_${i}`
|
|
const hasWildcard = includeLabel.labels.some((label) =>
|
|
label.includes('*')
|
|
)
|
|
if (hasWildcard) {
|
|
queryBuilder.andWhere(
|
|
new Brackets((qb) => {
|
|
includeLabel.labels.forEach((label, j) => {
|
|
const param = `includeLabels_${i}_${j}`
|
|
qb.orWhere(
|
|
`array_to_string(array_cat(library_item.label_names, library_item.highlight_labels)::text[], ',') ILIKE :${param}`,
|
|
{
|
|
[param]: label.replace(/\*/g, '%'),
|
|
}
|
|
)
|
|
})
|
|
})
|
|
)
|
|
} else {
|
|
queryBuilder.andWhere(
|
|
`lower(array_cat(library_item.label_names, library_item.highlight_labels)::text)::text[] && ARRAY[:...${param}]::text[]`,
|
|
{
|
|
[param]: includeLabel.labels,
|
|
}
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
if (excludeLabels && excludeLabels.length > 0) {
|
|
const labels = excludeLabels.flatMap((filter) => filter.labels)
|
|
|
|
const hasWildcard = labels.some((label) => label.includes('*'))
|
|
|
|
if (hasWildcard) {
|
|
queryBuilder.andWhere(
|
|
new Brackets((qb) => {
|
|
labels.forEach((label, i) => {
|
|
const param = `excludeLabels_${i}`
|
|
qb.andWhere(
|
|
`array_to_string(array_cat(library_item.label_names, library_item.highlight_labels)::text[], ',') NOT ILIKE :${param}`,
|
|
{
|
|
[param]: label.replace(/\*/g, '%'),
|
|
}
|
|
)
|
|
})
|
|
})
|
|
)
|
|
} else {
|
|
queryBuilder.andWhere(
|
|
'NOT lower(array_cat(library_item.label_names, library_item.highlight_labels)::text)::text[] && ARRAY[:...excludeLabels]::text[]',
|
|
{
|
|
excludeLabels: labels,
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (args.dateFilters && args.dateFilters.length > 0) {
|
|
args.dateFilters.forEach((filter) => {
|
|
const startDate = `${filter.field}_start`
|
|
const endDate = `${filter.field}_end`
|
|
queryBuilder.andWhere(
|
|
`library_item.${filter.field} between :${startDate} and :${endDate}`,
|
|
{
|
|
[startDate]: filter.startDate ?? new Date(0),
|
|
[endDate]: filter.endDate ?? new Date(),
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
if (args.termFilters && args.termFilters.length > 0) {
|
|
args.termFilters.forEach((filter) => {
|
|
const param = `term_${filter.field}`
|
|
queryBuilder.andWhere(`lower(library_item.${filter.field}) = :${param}`, {
|
|
[param]: filter.value.toLowerCase(),
|
|
})
|
|
})
|
|
}
|
|
|
|
if (args.matchFilters && args.matchFilters.length > 0) {
|
|
args.matchFilters.forEach((filter) => {
|
|
const param = `match_${filter.field}`
|
|
queryBuilder.andWhere(
|
|
new Brackets((qb) => {
|
|
qb.andWhere(
|
|
`websearch_to_tsquery('english', :${param}) @@ library_item.${filter.field}_tsv`,
|
|
{
|
|
[param]: filter.value,
|
|
}
|
|
).orWhere(`${filter.field} ILIKE :value`, {
|
|
value: `%${filter.value}%`,
|
|
})
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
if (args.ids && args.ids.length > 0) {
|
|
queryBuilder.andWhere('library_item.id = ANY(:ids)', {
|
|
ids: args.ids,
|
|
})
|
|
}
|
|
|
|
if (!args.includePending) {
|
|
queryBuilder.andWhere("library_item.state <> 'PROCESSING'")
|
|
}
|
|
|
|
if (!args.includeDeleted && args.inFilter !== InFilter.TRASH) {
|
|
queryBuilder.andWhere("library_item.state <> 'DELETED'")
|
|
}
|
|
|
|
if (args.noFilters) {
|
|
args.noFilters.forEach((filter) => {
|
|
queryBuilder.andWhere(
|
|
`(library_item.${filter.field} = '{}' OR library_item.${filter.field} IS NULL)`
|
|
)
|
|
})
|
|
}
|
|
|
|
if (args.recommendedBy) {
|
|
if (args.recommendedBy === '*') {
|
|
// select all if * is provided
|
|
queryBuilder.andWhere(`library_item.recommender_names <> '{}'`)
|
|
} else {
|
|
// select only if the user is recommended by the provided user
|
|
queryBuilder.andWhere(
|
|
'lower(library_item.recommender_names::text)::text[] && ARRAY[:recommendedBy]::text[]',
|
|
{
|
|
recommendedBy: args.recommendedBy.toLowerCase(),
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
if (args.includeContent) {
|
|
queryBuilder.addSelect('library_item.readableContent')
|
|
}
|
|
|
|
if (args.rangeFilters && args.rangeFilters.length > 0) {
|
|
args.rangeFilters.forEach((filter, i) => {
|
|
const param = `range_${filter.field}_${i}`
|
|
queryBuilder.andWhere(
|
|
`library_item.${filter.field} ${filter.operator} :${param}`,
|
|
{
|
|
[param]: filter.value,
|
|
}
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
export const searchLibraryItems = async (
|
|
args: SearchArgs,
|
|
userId: string
|
|
): Promise<{ libraryItems: LibraryItem[]; count: number }> => {
|
|
const { from = 0, size = 10, sort } = args
|
|
|
|
// default order is descending
|
|
const sortOrder = sort?.order || SortOrder.DESCENDING
|
|
// default sort by saved_at
|
|
const sortField = sort?.by || SortBy.SAVED
|
|
|
|
const selectColumns = getColumns(libraryItemRepository)
|
|
.map((column) => `library_item.${column}`)
|
|
.filter(
|
|
(column) =>
|
|
column !== 'library_item.readableContent' &&
|
|
column !== 'library_item.originalContent'
|
|
)
|
|
|
|
// add pagination and sorting
|
|
return authTrx(
|
|
async (tx) => {
|
|
const queryBuilder = tx
|
|
.createQueryBuilder(LibraryItem, 'library_item')
|
|
.select(selectColumns)
|
|
.where('library_item.user_id = :userId', { userId })
|
|
|
|
if (args.searchQuery) {
|
|
const parameters: ObjectLiteral[] = []
|
|
const whereClause = buildQuery(args.searchQuery, parameters)
|
|
whereClause &&
|
|
queryBuilder.andWhere(
|
|
whereClause,
|
|
parameters.reduce((a, b) => ({ ...a, ...b }), {})
|
|
)
|
|
}
|
|
|
|
const libraryItems = await queryBuilder
|
|
.addOrderBy(`library_item.${sortField}`, sortOrder, 'NULLS LAST')
|
|
.skip(from)
|
|
.take(size)
|
|
.getMany()
|
|
|
|
const count = await queryBuilder.getCount()
|
|
|
|
return { libraryItems, count }
|
|
},
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const findLibraryItemById = async (
|
|
id: string,
|
|
userId: string
|
|
): Promise<LibraryItem | null> => {
|
|
return authTrx(
|
|
async (tx) =>
|
|
tx
|
|
.createQueryBuilder(LibraryItem, 'library_item')
|
|
.leftJoinAndSelect('library_item.labels', 'labels')
|
|
.leftJoinAndSelect('library_item.highlights', 'highlights')
|
|
.leftJoinAndSelect('highlights.user', 'user')
|
|
.where('library_item.id = :id', { id })
|
|
.getOne(),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const findLibraryItemByUrl = async (
|
|
url: string,
|
|
userId: string
|
|
): Promise<LibraryItem | null> => {
|
|
return authTrx(
|
|
async (tx) =>
|
|
tx
|
|
.createQueryBuilder(LibraryItem, 'library_item')
|
|
.leftJoinAndSelect('library_item.labels', 'labels')
|
|
.leftJoinAndSelect('library_item.highlights', 'highlights')
|
|
.leftJoinAndSelect('library_item.recommendations', 'recommendations')
|
|
.leftJoinAndSelect('recommendations.recommender', 'recommender')
|
|
.leftJoinAndSelect('recommender.profile', 'profile')
|
|
.leftJoinAndSelect('recommendations.group', 'group')
|
|
.where('library_item.user_id = :userId', { userId })
|
|
.andWhere('library_item.original_url = :url', { url })
|
|
.getOne(),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const restoreLibraryItem = async (
|
|
id: string,
|
|
userId: string,
|
|
pubsub = createPubSubClient()
|
|
): Promise<LibraryItem> => {
|
|
return updateLibraryItem(
|
|
id,
|
|
{
|
|
state: LibraryItemState.Succeeded,
|
|
savedAt: new Date(),
|
|
archivedAt: null,
|
|
deletedAt: null,
|
|
},
|
|
userId,
|
|
pubsub
|
|
)
|
|
}
|
|
|
|
export const updateLibraryItem = async (
|
|
id: string,
|
|
libraryItem: QueryDeepPartialEntity<LibraryItem>,
|
|
userId: string,
|
|
pubsub = createPubSubClient()
|
|
): Promise<LibraryItem> => {
|
|
const updatedLibraryItem = await authTrx(
|
|
async (tx) => {
|
|
const itemRepo = tx.withRepository(libraryItemRepository)
|
|
|
|
// reset deletedAt and archivedAt
|
|
switch (libraryItem.state) {
|
|
case LibraryItemState.Archived:
|
|
libraryItem.archivedAt = new Date()
|
|
break
|
|
case LibraryItemState.Deleted:
|
|
libraryItem.deletedAt = new Date()
|
|
break
|
|
case LibraryItemState.Processing:
|
|
case LibraryItemState.Succeeded:
|
|
libraryItem.archivedAt = null
|
|
libraryItem.deletedAt = null
|
|
break
|
|
}
|
|
await itemRepo.update(id, libraryItem)
|
|
|
|
return itemRepo.findOneByOrFail({ id })
|
|
},
|
|
undefined,
|
|
userId
|
|
)
|
|
|
|
await pubsub.entityUpdated<QueryDeepPartialEntity<LibraryItem>>(
|
|
EntityType.PAGE,
|
|
{
|
|
...libraryItem,
|
|
id,
|
|
// don't send original content and readable content
|
|
originalContent: undefined,
|
|
readableContent: undefined,
|
|
},
|
|
userId
|
|
)
|
|
|
|
return updatedLibraryItem
|
|
}
|
|
|
|
export const updateLibraryItemReadingProgress = async (
|
|
id: string,
|
|
userId: string,
|
|
bottomPercent: number,
|
|
topPercent: number | null = null,
|
|
anchorIndex: number | null = null,
|
|
pubsub = createPubSubClient()
|
|
): Promise<LibraryItem | null> => {
|
|
// If we have a top percent, we only save it if it's greater than the current top percent
|
|
// or set to zero if the top percent is zero.
|
|
const result = (await authTrx(
|
|
async (tx) =>
|
|
tx.getRepository(LibraryItem).query(
|
|
`
|
|
UPDATE omnivore.library_item
|
|
SET reading_progress_top_percent = CASE
|
|
WHEN reading_progress_top_percent < $2 THEN $2
|
|
WHEN $2 = 0 THEN 0
|
|
ELSE reading_progress_top_percent
|
|
END,
|
|
reading_progress_bottom_percent = CASE
|
|
WHEN reading_progress_bottom_percent < $3 THEN $3
|
|
WHEN $3 = 0 THEN 0
|
|
ELSE reading_progress_bottom_percent
|
|
END,
|
|
reading_progress_highest_read_anchor = CASE
|
|
WHEN reading_progress_top_percent < $4 THEN $4
|
|
WHEN $4 = 0 THEN 0
|
|
ELSE reading_progress_highest_read_anchor
|
|
END,
|
|
read_at = now()
|
|
WHERE id = $1 AND (
|
|
(reading_progress_top_percent < $2 OR $2 = 0) OR
|
|
(reading_progress_bottom_percent < $3 OR $3 = 0) OR
|
|
(reading_progress_highest_read_anchor < $4 OR $4 = 0)
|
|
)
|
|
RETURNING
|
|
id,
|
|
reading_progress_top_percent as "readingProgressTopPercent",
|
|
reading_progress_bottom_percent as "readingProgressBottomPercent",
|
|
reading_progress_highest_read_anchor as "readingProgressHighestReadAnchor",
|
|
read_at as "readAt"
|
|
`,
|
|
[id, topPercent, bottomPercent, anchorIndex]
|
|
),
|
|
undefined,
|
|
userId
|
|
)) as [LibraryItem[], number]
|
|
if (result[1] === 0) {
|
|
return null
|
|
}
|
|
|
|
const updatedItem = result[0][0]
|
|
await pubsub.entityUpdated<QueryDeepPartialEntity<LibraryItem>>(
|
|
EntityType.PAGE,
|
|
{
|
|
id,
|
|
readingProgressBottomPercent: updatedItem.readingProgressBottomPercent,
|
|
readingProgressTopPercent: updatedItem.readingProgressTopPercent,
|
|
readingProgressHighestReadAnchor:
|
|
updatedItem.readingProgressHighestReadAnchor,
|
|
readAt: updatedItem.readAt,
|
|
},
|
|
userId
|
|
)
|
|
|
|
return updatedItem
|
|
}
|
|
|
|
export const createLibraryItems = async (
|
|
libraryItems: DeepPartial<LibraryItem>[],
|
|
userId: string
|
|
): Promise<LibraryItem[]> => {
|
|
return authTrx(
|
|
async (tx) => tx.withRepository(libraryItemRepository).save(libraryItems),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const createLibraryItem = async (
|
|
libraryItem: DeepPartial<LibraryItem>,
|
|
userId: string,
|
|
pubsub = createPubSubClient(),
|
|
skipPubSub = false
|
|
): Promise<LibraryItem> => {
|
|
const newLibraryItem = await authTrx(
|
|
async (tx) =>
|
|
tx.withRepository(libraryItemRepository).save({
|
|
...libraryItem,
|
|
wordCount:
|
|
libraryItem.wordCount ??
|
|
wordsCount(libraryItem.readableContent || ''),
|
|
}),
|
|
undefined,
|
|
userId
|
|
)
|
|
|
|
if (skipPubSub) {
|
|
return newLibraryItem
|
|
}
|
|
|
|
await pubsub.entityCreated<DeepPartial<LibraryItem>>(
|
|
EntityType.PAGE,
|
|
{
|
|
...newLibraryItem,
|
|
// don't send original content and readable content
|
|
originalContent: undefined,
|
|
readableContent: undefined,
|
|
},
|
|
userId
|
|
)
|
|
|
|
return newLibraryItem
|
|
}
|
|
|
|
export const saveFeedItemInFollowing = (
|
|
input: SaveFollowingItemRequest,
|
|
userId: string
|
|
) => {
|
|
const thumbnail = input.thumbnail && createThumbnailUrl(input.thumbnail)
|
|
|
|
return authTrx(
|
|
async (tx) => {
|
|
const itemToSave: QueryDeepPartialEntity<LibraryItem> = {
|
|
...input,
|
|
user: { id: userId },
|
|
originalUrl: input.url,
|
|
subscription: input.addedToFollowingBy,
|
|
folder: InFilter.FOLLOWING,
|
|
slug: generateSlug(input.title),
|
|
thumbnail,
|
|
}
|
|
|
|
return tx
|
|
.getRepository(LibraryItem)
|
|
.createQueryBuilder()
|
|
.insert()
|
|
.values(itemToSave)
|
|
.orIgnore() // ignore if the item already exists
|
|
.returning('*')
|
|
.execute()
|
|
},
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const findLibraryItemsByPrefix = async (
|
|
prefix: string,
|
|
userId: string,
|
|
limit = 5
|
|
): Promise<LibraryItem[]> => {
|
|
const prefixWildcard = `${prefix}%`
|
|
|
|
return authTrx(async (tx) =>
|
|
tx
|
|
.createQueryBuilder(LibraryItem, 'library_item')
|
|
.where('library_item.user_id = :userId', { userId })
|
|
.andWhere(
|
|
'(library_item.title ILIKE :prefix OR library_item.site_name ILIKE :prefix)',
|
|
{ prefix: prefixWildcard }
|
|
)
|
|
.orderBy('library_item.savedAt', 'DESC')
|
|
.limit(limit)
|
|
.getMany()
|
|
)
|
|
}
|
|
|
|
export const countByCreatedAt = async (
|
|
userId: string,
|
|
startDate = new Date(0),
|
|
endDate = new Date()
|
|
): Promise<number> => {
|
|
return authTrx(
|
|
async (tx) =>
|
|
tx
|
|
.createQueryBuilder(LibraryItem, 'library_item')
|
|
.where('library_item.user_id = :userId', { userId })
|
|
.andWhere('library_item.created_at between :startDate and :endDate', {
|
|
startDate,
|
|
endDate,
|
|
})
|
|
.getCount(),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const updateLibraryItems = async (
|
|
action: BulkActionType,
|
|
searchQuery: LiqeQuery,
|
|
userId: string,
|
|
labels?: Label[],
|
|
args?: unknown
|
|
) => {
|
|
interface FolderArguments {
|
|
folder: string
|
|
}
|
|
|
|
const isFolderArguments = (args: any): args is FolderArguments => {
|
|
return 'folder' in args
|
|
}
|
|
|
|
// build the script
|
|
let values: QueryDeepPartialEntity<LibraryItem> = {}
|
|
let addLabels = false
|
|
switch (action) {
|
|
case BulkActionType.Archive:
|
|
values = {
|
|
archivedAt: new Date(),
|
|
state: LibraryItemState.Archived,
|
|
}
|
|
break
|
|
case BulkActionType.Delete:
|
|
values = {
|
|
state: LibraryItemState.Deleted,
|
|
deletedAt: new Date(),
|
|
}
|
|
break
|
|
case BulkActionType.AddLabels:
|
|
addLabels = true
|
|
break
|
|
case BulkActionType.MarkAsRead:
|
|
values = {
|
|
readAt: new Date(),
|
|
readingProgressTopPercent: 100,
|
|
readingProgressBottomPercent: 100,
|
|
}
|
|
break
|
|
case BulkActionType.MoveToFolder:
|
|
if (!args || !isFolderArguments(args)) {
|
|
throw new Error('Invalid arguments')
|
|
}
|
|
|
|
values = {
|
|
folder: args.folder,
|
|
savedAt: new Date(),
|
|
}
|
|
|
|
break
|
|
default:
|
|
throw new Error('Invalid bulk action')
|
|
}
|
|
|
|
await authTrx(async (tx) => {
|
|
const queryBuilder = tx
|
|
.createQueryBuilder(LibraryItem, 'library_item')
|
|
.where('library_item.user_id = :userId', { userId })
|
|
|
|
// build the where clause
|
|
// buildWhereClause(queryBuilder, searchQuery)
|
|
|
|
if (addLabels) {
|
|
if (!labels) {
|
|
throw new Error('Labels are required for this action')
|
|
}
|
|
|
|
const libraryItems = await queryBuilder.getMany()
|
|
// add labels in library items
|
|
const labelsToAdd = libraryItems.flatMap((libraryItem) =>
|
|
labels
|
|
.map((label) => ({
|
|
labelId: label.id,
|
|
libraryItemId: libraryItem.id,
|
|
}))
|
|
.filter((entityLabel) => {
|
|
const existingLabel = libraryItem.labels?.find(
|
|
(l) => l.id === entityLabel.labelId
|
|
)
|
|
return !existingLabel
|
|
})
|
|
)
|
|
return tx.getRepository(EntityLabel).save(labelsToAdd)
|
|
}
|
|
|
|
return queryBuilder.update(LibraryItem).set(values).execute()
|
|
})
|
|
}
|
|
|
|
export const deleteLibraryItemById = async (id: string, userId?: string) => {
|
|
return authTrx(
|
|
async (tx) => tx.withRepository(libraryItemRepository).delete(id),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const deleteLibraryItems = async (
|
|
items: LibraryItem[],
|
|
userId?: string
|
|
) => {
|
|
return authTrx(
|
|
async (tx) => tx.withRepository(libraryItemRepository).remove(items),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const deleteLibraryItemByUrl = async (url: string, userId: string) => {
|
|
return authTrx(
|
|
async (tx) =>
|
|
tx
|
|
.withRepository(libraryItemRepository)
|
|
.delete({ originalUrl: url, user: { id: userId } }),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|
|
|
|
export const deleteLibraryItemsByUserId = async (userId: string) => {
|
|
return authTrx(
|
|
async (tx) =>
|
|
tx.withRepository(libraryItemRepository).delete({
|
|
user: { id: userId },
|
|
}),
|
|
undefined,
|
|
userId
|
|
)
|
|
}
|