search/app/composables/useTypesenseSearch.ts

540 lines
17 KiB
TypeScript

import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'
const DEFAULT_TIMEOUT_MS = 15000
function isAbortError(err: unknown): boolean {
return (err as { name?: string } | null)?.name === 'AbortError'
}
/**
* Boilerplate compartido de cancelación: cancela el intento anterior antes de
* lanzar uno nuevo, arma un timeout que aborta si tarda demasiado, y da un
* guard de secuencia para descartar respuestas de requests ya superados.
* Usado por los dos modos de búsqueda (plano y agrupado) de este archivo.
*/
function createAbortableRunner() {
let seq = 0
let controller: AbortController | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null
function start(onTimeout: () => void, timeoutMs: number) {
const mySeq = ++seq
controller?.abort()
const myController = new AbortController()
controller = myController
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (mySeq === seq) {
myController.abort()
onTimeout()
}
}, timeoutMs)
return { seq: mySeq, signal: myController.signal }
}
function isCurrent(mySeq: number) {
return mySeq === seq
}
function settle(mySeq: number) {
if (mySeq === seq && timeoutId) clearTimeout(timeoutId)
}
function dispose() {
if (timeoutId) clearTimeout(timeoutId)
controller?.abort()
}
return { start, isCurrent, settle, dispose }
}
export interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
// ─── Modo plano: una sola colección, sin agrupar (entrelineas.vue) ───────────
export interface TypesenseFlatHit<TDoc> {
document: TDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
export interface FlatTypesenseSearchOptions {
collection: string
queryBy: string
/** Reevaluado en cada request (p.ej. depende de `locale.value`). */
filterBy: () => string
includeFields?: string
pageSize: () => number
paginationType: () => 'infinite_scroll' | 'numbered'
initialQuery: string
initialPage: number
timeoutMs?: number
}
export function useFlatTypesenseSearch<TDoc = Record<string, unknown>>(options: FlatTypesenseSearchOptions) {
const { documentsApi } = useTypesenseClient()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const query = ref(options.initialQuery)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
const hits = ref<TypesenseFlatHit<TDoc>[]>([])
const total = ref(0)
const currentPage = ref(1)
const activePage = ref(options.initialPage)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / options.pageSize())))
const hasMore = computed(() =>
options.paginationType() === 'infinite_scroll' ? hits.value.length < total.value : false
)
const runner = createAbortableRunner()
async function runSearch(q: string, page = 1, append = false) {
const { seq, signal } = runner.start(() => {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}, timeoutMs)
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const isInfinite = options.paginationType() === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: options.collection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: options.queryBy,
includeFields: options.includeFields ?? '*',
filterBy: options.filterBy(),
perPage: options.pageSize(),
page: typePage,
highlightFullFields: options.queryBy,
highlightFields: options.queryBy,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>'
}]
}
}, { signal })
if (!runner.isCurrent(seq)) return
const res = (multi?.results?.[0] ?? {}) as { found?: number, hits?: TypesenseFlatHit<TDoc>[] }
const newHits = res?.hits ?? []
hits.value = (append ? [...hits.value, ...newHits] : newHits) as TypesenseFlatHit<TDoc>[]
total.value = res?.found ?? hits.value.length
currentPage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (!runner.isCurrent(seq)) return
if (isAbortError(err)) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { hits.value = []; total.value = 0 }
} finally {
if (runner.isCurrent(seq)) {
runner.settle(seq)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (options.paginationType() !== 'infinite_scroll') return
if (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, currentPage.value, true)
}
function goToPage(p: number) {
activePage.value = p
hits.value = []
runSearch(query.value, p, false)
}
function retry() {
runSearch(query.value, activePage.value, false)
}
onBeforeUnmount(() => runner.dispose())
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
currentPage.value = 1
activePage.value = 1
runSearch(q, 1, false)
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
return {
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
hits, total, currentPage, activePage, totalPages, hasMore,
runSearch, loadMore, goToPage, retry
}
}
// ─── Modo agrupado: párrafos + join a la colección principal (SearchPanel.vue) ─
export interface ParagraphDoc {
id?: string
document_id: string
text: string
number: number
locale: string
type: string
}
export interface TypesenseGroupedParagraphHit {
document: ParagraphDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
export interface TypesenseGroupedHit {
groupKey: string[]
hits: TypesenseGroupedParagraphHit[]
}
interface GroupedSearchResponse {
found: number
groupedHits?: TypesenseGroupedHit[]
hits?: Array<{ document: Record<string, unknown> }>
}
export interface SearchGroup {
docId: string
firstHit: TypesenseGroupedParagraphHit
allHits: TypesenseGroupedParagraphHit[]
}
export interface BrowseItem {
docId: string
meta: CachedDocMeta
}
export interface DisplayGroup {
docId: string
meta: CachedDocMeta | undefined
firstHit: TypesenseGroupedParagraphHit | null
}
export interface GroupedTypesenseSearchOptions {
paragraphsCollection: string
mainCollection: string
groupByField: string
queryBy: string
/** Reevaluado en cada request (p.ej. depende de `locale.value`). */
filterBy: () => string
/** Cuando devuelve false, se excluyen los documentos `private:=true`. */
isUnlocked: () => boolean
pageSize: () => number
paginationType: () => 'infinite_scroll' | 'numbered'
initialQuery: string
initialPage: number
highlightAffixNumTokens?: number
timeoutMs?: number
}
const META_FIELDS = 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
export function useGroupedTypesenseSearch(options: GroupedTypesenseSearchOptions) {
const { documentsApi } = useTypesenseClient()
const docMetaCache = useDocMetaCache()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const highlightAffixNumTokens = options.highlightAffixNumTokens ?? 15
const query = ref(options.initialQuery)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
const sortMode = ref<'relevance' | 'date'>('relevance')
const groupedHits = ref<SearchGroup[]>([])
const total = ref(0)
const currentPage = ref(1)
const activePage = ref(options.initialPage)
const hasMore = computed(() =>
options.paginationType() === 'infinite_scroll' ? groupedHits.value.length < total.value : false
)
const visibleGroupCount = ref(10)
const visibleGroups = computed(() =>
options.paginationType() === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
options.paginationType() === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
options.paginationType() === 'infinite_scroll'
? browseItems.value.length < browseTotal.value
: false
)
const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({
docId: item.docId,
meta: item.meta,
firstHit: null
}))
}
return visibleGroups.value.map(g => ({
docId: g.docId,
meta: docMetaCache.get(options.mainCollection, g.docId),
firstHit: g.firstHit
}))
})
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / options.pageSize()))
)
function cacheParentMeta(newGroups: SearchGroup[]) {
for (const g of newGroups) {
if (!g.docId) continue
const parentMeta = (g.firstHit?.document as unknown as Record<string, unknown>)?.[options.mainCollection] as Partial<CachedDocMeta> | undefined
if (parentMeta) docMetaCache.set(options.mainCollection, g.docId, { id: g.docId, ...parentMeta } as CachedDocMeta)
}
}
function searchFilterBy() {
return options.isUnlocked()
? options.filterBy()
: `${options.filterBy()} && $${options.mainCollection}(private:=false)`
}
function browseFilterBy() {
return options.isUnlocked()
? options.filterBy()
: `${options.filterBy()} && private:=false`
}
const runner = createAbortableRunner()
async function runSearch(q: string, page = 1, append = false) {
const { seq, signal } = runner.start(() => {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}, timeoutMs)
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const isInfinite = options.paginationType() === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const shouldSortByDate = sortMode.value === 'date' && q.trim()
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: options.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: options.queryBy,
filterBy: searchFilterBy(),
...(shouldSortByDate ? { sortBy: `$${options.mainCollection}(timestamp:desc)` } : {}),
perPage: options.pageSize(),
page: typePage,
highlightFullFields: options.queryBy,
highlightFields: options.queryBy,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
highlightAffixNumTokens,
groupBy: options.groupByField,
includeFields: `*, $${options.mainCollection}(${META_FIELDS})`
}]
}
}, { signal })
if (!runner.isCurrent(seq)) return
const res = (multi?.results?.[0] ?? {}) as GroupedSearchResponse
const rawGroups = res?.groupedHits ?? []
const newGroups: SearchGroup[] = rawGroups.map(g => ({
docId: g.groupKey[0]!,
firstHit: g.hits[0]!,
allHits: g.hits
}))
cacheParentMeta(newGroups)
if (!runner.isCurrent(seq)) return
groupedHits.value = append ? groupedHits.value.concat(newGroups) : newGroups
total.value = res?.found ?? groupedHits.value.length
currentPage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (!runner.isCurrent(seq)) return
if (isAbortError(err)) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { groupedHits.value = []; total.value = 0 }
} finally {
if (runner.isCurrent(seq)) {
runner.settle(seq)
loading.value = false
loadingMore.value = false
}
}
}
async function runBrowse(page = 1, append = false) {
const { seq, signal } = runner.start(() => {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}, timeoutMs)
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const isInfinite = options.paginationType() === 'infinite_scroll'
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: options.mainCollection,
q: '*',
queryBy: 'title',
filterBy: browseFilterBy(),
sortBy: 'timestamp:desc',
perPage: options.pageSize(),
page: typePage,
includeFields: META_FIELDS
}]
}
}, { signal })
if (!runner.isCurrent(seq)) return
const result = (multi?.results?.[0] as GroupedSearchResponse | undefined)
const rawHits = result?.hits ?? []
const newItems = rawHits.map((h) => {
const meta = h.document as Partial<CachedDocMeta>
const docId = String(meta.id ?? '')
return { docId, meta: { id: docId, ...meta } as CachedDocMeta }
})
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (!runner.isCurrent(seq)) return
if (isAbortError(err)) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { browseItems.value = []; browseTotal.value = 0 }
} finally {
if (runner.isCurrent(seq)) {
runner.settle(seq)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (options.paginationType() !== 'infinite_scroll') return
if (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, currentPage.value, true)
}
function goToPage(p: number) {
activePage.value = p
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
groupedHits.value = []
runSearch(query.value, p, false)
}
}
function retry() {
if (!query.value.trim()) runBrowse(activePage.value, false)
else runSearch(query.value, activePage.value, false)
}
onBeforeUnmount(() => runner.dispose())
watch(debouncedQuery, (q) => {
activePage.value = 1
if (!q.trim()) {
groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
browseItems.value = []; browseTotal.value = 0; browsePage.value = 1
runBrowse(1, false)
} else {
browseItems.value = []; browseTotal.value = 0; browsePage.value = 1
groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
runSearch(q, 1, false)
}
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
watch(sortMode, () => {
if (query.value.trim()) {
groupedHits.value = []
total.value = 0
currentPage.value = 1
runSearch(query.value, 1, false)
}
})
return {
query, debouncedQuery, loading, loadingMore, errorMsg,
exactSearch, sortMode,
groupedHits, total, currentPage,
visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
browseItems, browseTotal, browsePage, hasMoreBrowse,
displayGroups, activePage, displayTotal, totalPages,
runSearch, runBrowse, loadMore, goToPage, retry
}
}