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 | 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 { document: TDoc highlights?: TypesenseHighlight[] highlight?: Record 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>(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(null) const exactSearch = ref(false) const hits = ref[]>([]) 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: '', highlightEndTag: '' }] } }, { signal }) if (!runner.isCurrent(seq)) return const res = (multi?.results?.[0] ?? {}) as { found?: number, hits?: TypesenseFlatHit[] } const newHits = res?.hits ?? [] hits.value = (append ? [...hits.value, ...newHits] : newHits) as TypesenseFlatHit[] 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 text_match?: number } export interface TypesenseGroupedHit { groupKey: string[] hits: TypesenseGroupedParagraphHit[] } interface GroupedSearchResponse { found: number groupedHits?: TypesenseGroupedHit[] hits?: Array<{ document: Record }> } 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(null) const exactSearch = ref(false) const sortMode = ref<'relevance' | 'date'>('relevance') const groupedHits = ref([]) 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([]) 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)?.[options.mainCollection] as Partial | 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: '', highlightEndTag: '', 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 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 } }