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 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(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 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: '', highlight_end_tag: '' }] }, {}, { abortSignal: 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 { group_key: string[] hits: TypesenseGroupedParagraphHit[] } interface GroupedSearchResponse { found: number grouped_hits?: 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 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(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() { 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: '', highlight_end_tag: '', 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 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 } }