534 lines
17 KiB
TypeScript
534 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 typesenseClient = 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 typesenseClient.multiSearch.perform({
|
|
searches: [{
|
|
collection: options.collection,
|
|
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
|
query_by: options.queryBy,
|
|
include_fields: options.includeFields ?? '*',
|
|
filter_by: options.filterBy(),
|
|
per_page: options.pageSize(),
|
|
page: typePage,
|
|
highlight_full_fields: options.queryBy,
|
|
highlight_fields: options.queryBy,
|
|
highlight_start_tag: '<mark class="search-match">',
|
|
highlight_end_tag: '</mark>'
|
|
}]
|
|
}, {}, { abortSignal: 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 {
|
|
group_key: string[]
|
|
hits: TypesenseGroupedParagraphHit[]
|
|
}
|
|
|
|
interface GroupedSearchResponse {
|
|
found: number
|
|
grouped_hits?: 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
|
|
browseFilterBy?: () => 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 typesenseClient = 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() {
|
|
const base = options.browseFilterBy ? options.browseFilterBy() : options.filterBy()
|
|
return options.isUnlocked()
|
|
? `${base} && has_paragraphs:=true`
|
|
: `${base} && private:=false && has_paragraphs:=true`
|
|
}
|
|
|
|
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 typesenseClient.multiSearch.perform({
|
|
searches: [{
|
|
collection: options.paragraphsCollection,
|
|
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
|
query_by: options.queryBy,
|
|
filter_by: searchFilterBy(),
|
|
...(shouldSortByDate ? { sort_by: `$${options.mainCollection}(timestamp:desc)` } : {}),
|
|
per_page: options.pageSize(),
|
|
page: typePage,
|
|
highlight_full_fields: options.queryBy,
|
|
highlight_fields: options.queryBy,
|
|
highlight_start_tag: '<mark class="search-match">',
|
|
highlight_end_tag: '</mark>',
|
|
highlight_affix_num_tokens: highlightAffixNumTokens,
|
|
group_by: options.groupByField,
|
|
include_fields: `*, $${options.mainCollection}(${META_FIELDS})`
|
|
}]
|
|
}, {}, { abortSignal: signal })
|
|
if (!runner.isCurrent(seq)) return
|
|
|
|
const res = (multi?.results?.[0] ?? {}) as GroupedSearchResponse
|
|
const rawGroups = res?.grouped_hits ?? []
|
|
const newGroups: SearchGroup[] = rawGroups.map(g => ({
|
|
docId: g.group_key[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
|
|
|
|
console.log("testing" + browseFilterBy())
|
|
try {
|
|
const multi = await typesenseClient.multiSearch.perform({
|
|
searches: [{
|
|
collection: options.mainCollection,
|
|
q: '*',
|
|
query_by: 'title',
|
|
filter_by: browseFilterBy(),
|
|
sort_by: 'timestamp:desc',
|
|
per_page: options.pageSize(),
|
|
page: typePage,
|
|
include_fields: META_FIELDS
|
|
}]
|
|
}, {}, { abortSignal: 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
|
|
}
|
|
}
|