Files
omnivore/packages/api/src/elastic/highlights.ts
2023-03-22 10:36:38 +08:00

309 lines
7.3 KiB
TypeScript

import {
Highlight,
Page,
PageContext,
SearchItem,
SearchResponse,
} from './types'
import { ResponseError } from '@elastic/elasticsearch/lib/errors'
import { client, INDEX_ALIAS } from './index'
import { SortBy, SortOrder, SortParams } from '../utils/search'
import { EntityType } from '../datalayer/pubsub'
export const addHighlightToPage = async (
id: string,
highlight: Highlight,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id,
body: {
script: {
source: `if (ctx._source.highlights == null) {
ctx._source.highlights = [params.highlight]
} else {
ctx._source.highlights.add(params.highlight)
}
ctx._source.updatedAt = params.highlight.updatedAt`,
lang: 'painless',
params: {
highlight,
},
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result !== 'updated') return false
await ctx.pubsub.entityCreated<Highlight>(
EntityType.HIGHLIGHT,
highlight,
ctx.uid
)
return true
} catch (e) {
if (
e instanceof ResponseError &&
e.message === 'document_missing_exception'
) {
console.log('page has been deleted', id)
return false
}
console.error('failed to add highlight to a page in elastic', e)
return false
}
}
export const getHighlightById = async (
id: string
): Promise<Highlight | undefined> => {
try {
const { body } = await client.search({
index: INDEX_ALIAS,
body: {
query: {
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': id,
},
},
inner_hits: {},
},
},
_source: false,
},
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (body.hits.total.value === 0) {
return undefined
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return
return body.hits.hits[0].inner_hits.highlights.hits.hits[0]._source
} catch (e) {
console.error('failed to get highlight from a page in elastic', e)
return undefined
}
}
export const deleteHighlight = async (
highlightId: string,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.highlights.removeIf(h -> h.id == params.highlightId);
ctx._source.updatedAt = params.updatedAt`,
lang: 'painless',
params: {
highlightId: highlightId,
updatedAt: new Date(),
},
},
query: {
bool: {
filter: [
{
term: {
userId: ctx.uid,
},
},
{
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': highlightId,
},
},
},
},
],
},
},
},
refresh: ctx.refresh,
})
if (body.updated === 0) return false
await ctx.pubsub.entityDeleted(EntityType.HIGHLIGHT, highlightId, ctx.uid)
return true
} catch (e) {
console.error('failed to delete a highlight in elastic', e)
return false
}
}
export const searchHighlights = async (
args: {
from?: number
size?: number
sort?: SortParams
query?: string
},
userId: string
): Promise<[SearchItem[], number] | undefined> => {
try {
const { from = 0, size = 10, sort, query } = args
const sortOrder = sort?.order || SortOrder.DESCENDING
// default sort by updatedAt
const sortField =
sort?.by === SortBy.SCORE ? SortBy.SCORE : 'highlights.updatedAt'
const searchBody = {
query: {
bool: {
filter: [
{
nested: {
path: 'highlights',
query: {
term: {
'highlights.userId': userId,
},
},
},
},
],
should: [
{
multi_match: {
query: query || '',
fields: [
'highlights.quote^5',
'title^3',
'description^2',
'content',
],
},
},
],
minimum_should_match: query ? 1 : 0,
},
},
sort: [
'_score',
{
[sortField]: {
order: sortOrder,
nested: {
path: 'highlights',
},
},
},
],
from,
size,
_source: [
'title',
'slug',
'url',
'savedAt',
'highlights',
'readingProgressPercent',
'readingProgressAnchorIndex',
],
}
console.log('searching highlights in elastic', JSON.stringify(searchBody))
const response = await client.search<SearchResponse<Page>>({
index: INDEX_ALIAS,
body: searchBody,
})
if (response.body.hits.total.value === 0) {
return [[], 0]
}
const results: SearchItem[] = []
response.body.hits.hits.forEach((hit) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
hit._source.highlights?.forEach((highlight) => {
results.push({
...highlight,
...hit._source,
pageId: hit._id,
})
})
})
return [results, response.body.hits.total.value]
} catch (e) {
console.error('failed to search highlights in elastic', e)
return undefined
}
}
export const updateHighlight = async (
highlight: Highlight,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.highlights.removeIf(h -> h.id == params.highlight.id);
ctx._source.highlights.add(params.highlight);
ctx._source.updatedAt = params.highlight.updatedAt`,
lang: 'painless',
params: {
highlight,
},
},
query: {
bool: {
filter: [
{
term: {
userId: ctx.uid,
},
},
{
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': highlight.id,
},
},
},
},
],
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed',
})
if (body.updated === 0) return false
await ctx.pubsub.entityUpdated<Highlight>(
EntityType.HIGHLIGHT,
highlight,
ctx.uid
)
return true
} catch (e) {
console.error('failed to update highlight in elastic', e)
return false
}
}