Files
omnivore/packages/api/src/services/library_item.ts
2023-12-05 10:58:18 +08:00

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
)
}