Compare commits

..

6 Commits

Author SHA1 Message Date
davish bd95eebcb3 Merge pull request 'staging' (#6) from staging into production
Deploy Search Typesense / deploy (push) Successful in 1m11s Details
Reviewed-on: #6
2026-07-26 13:25:34 +00:00
davish f2afed09be Merge pull request 'main' (#5) from main into staging
Deploy Search Typesense / deploy (push) Successful in 11m52s Details
Reviewed-on: #5
2026-07-26 12:51:26 +00:00
David Ascanio 5751227cc3 use cache server, fix bug studies filter 2026-07-26 09:49:59 -03:00
David Ascanio d2031f1ae9 fix conflicts 2026-07-24 16:08:42 -03:00
David Ascanio 4513f4d839 Merge branch 'main' of https://gitea.carpa.com/LGCC/search 2026-07-24 15:31:47 -03:00
David Ascanio 464b6d0358 optimization 2026-07-24 15:30:54 -03:00
13 changed files with 1224 additions and 608 deletions

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core' import { breakpointsTailwind } from '@vueuse/core'
import PublicationDetail from '~/components/PublicationDetail.vue' import PublicationDetail from '~/components/PublicationDetail.vue'
import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue' import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue'
import { useSettingsStore } from '~/stores/settings' import { useSettingsStore } from '~/stores/settings'
@ -31,54 +31,125 @@ const { $i18n } = useNuxtApp()
const t = $i18n.t const t = $i18n.t
const { locale } = useI18n() const { locale } = useI18n()
const filterBy = computed(() => { const settings = useSettingsStore()
const { unlocked } = useDevMode()
const toast = useToast()
const { documentsApi } = useTypesenseClient()
// Restaurar estado desde URL antes de crear los refs
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
interface BibleStudyChip {
id: number
title: string
}
const bibleStudyInput = ref<number | null>(null)
const activeBibleStudies = ref<BibleStudyChip[]>([])
const isValidating = ref(false)
const {
query, debouncedQuery, loading, loadingMore, errorMsg,
exactSearch, sortMode,
groupedHits, visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
browseItems, hasMoreBrowse,
displayGroups, activePage, displayTotal, totalPages,
runSearch, runBrowse, loadMore, goToPage, retry
} = useGroupedTypesenseSearch({
paragraphsCollection: props.paragraphsCollection,
mainCollection: props.mainCollection,
groupByField: props.groupByField,
queryBy: QUERY_BY,
filterBy: () => {
let base = `locale:=${locale.value}` let base = `locale:=${locale.value}`
if (activeBibleStudies.value.length > 0) { if (activeBibleStudies.value.length > 0) {
const ids = activeBibleStudies.value.map(bs => bs.id).join(',') const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
base += ` && $${props.mainCollection}(bible_study:=[${ids}])` base += ` && $${props.mainCollection}(bible_study:=[${ids}])`
} }
return base return base
},
browseFilterBy: () => {
let base = `locale:=${locale.value}`
if (activeBibleStudies.value.length > 0) {
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
base += ` && bible_study:=[${ids}]`
}
return base
},
isUnlocked: () => unlocked.value,
pageSize: () => settings.pageSize,
paginationType: () => settings.paginationType,
initialQuery: q0,
initialPage: p0
}) })
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() function refetchResults() {
const { unlocked } = useDevMode() if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(1, false)
} else {
groupedHits.value = []
runSearch(query.value, 1, false)
}
}
// Restaurar estado desde URL antes de crear los refs async function applyBibleStudyFilter() {
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState() const val = bibleStudyInput.value
if (val === null || val <= 0) return
if (activeBibleStudies.value.some(bs => bs.id === val)) {
toast.add({ title: 'Estudio ya agregado', description: `El estudio #${val} ya está en el filtro`, color: 'info' })
bibleStudyInput.value = null
return
}
const query = ref(q0) isValidating.value = true
const debouncedQuery = useDebounce(query, 150) try {
const loading = ref(false) const res = await documentsApi.multiSearch({
const loadingMore = ref(false) multiSearchParameters: {},
const errorMsg = ref<string | null>(null) multiSearchSearchesParameter: {
searches: [{
collection: props.mainCollection,
q: '*',
queryBy: 'title',
filterBy: `bible_study:=${val}`,
perPage: 1,
includeFields: 'bible_study,title',
useCache: true,
cacheTtl: 3600
}]
}
})
const hit = (res?.results?.[0] as { hits?: Array<{ document: { title?: string } }> })?.hits?.[0]
if (hit) {
activeBibleStudies.value = [...activeBibleStudies.value, { id: val, title: hit.document.title || '' }]
bibleStudyInput.value = null
refetchResults()
} else {
toast.add({ title: 'Estudio no encontrado', description: `No existe el estudio #${val}`, color: 'warning' })
}
} catch (err) {
console.error('Error validando bible_study', err)
} finally {
isValidating.value = false
}
}
function removeBibleStudyFilter(id: number) {
activeBibleStudies.value = activeBibleStudies.value.filter(bs => bs.id !== id)
refetchResults()
}
function clearAllBibleStudyFilters() {
activeBibleStudies.value = []
refetchResults()
}
// ---- Types ---------------------------------------------------------------- // ---- Types ----------------------------------------------------------------
interface ParagraphDoc { interface DocumentDoc extends CachedDocMeta {
id?: string
document_id: string
text: string
number: number
locale: string
type: string
}
interface DocMeta {
id: string
title: string
date?: string
timestamp?: number
place?: string
city?: string
state?: string
country?: string
type?: string
slug?: string
draft?: string
}
interface DocumentDoc extends DocMeta {
code: string code: string
locale: string locale: string
files?: { files?: {
@ -92,47 +163,6 @@ interface DocumentDoc extends DocMeta {
[key: string]: unknown [key: string]: unknown
} }
interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
interface TypesenseGroupedHit {
groupKey: string[]
hits: TypesenseParagraphHit[]
}
interface TypesenseSearchResponse {
found: number
groupedHits?: TypesenseGroupedHit[]
}
interface SearchGroup {
docId: string
firstHit: TypesenseParagraphHit
allHits: TypesenseParagraphHit[]
}
interface BrowseItem {
docId: string
meta: DocMeta
}
interface DisplayGroup {
docId: string
meta: DocMeta | undefined
firstHit: TypesenseParagraphHit | null
}
// ---- Colors ---------------------------------------------------------------- // ---- Colors ----------------------------------------------------------------
const colors = computed(() => { const colors = computed(() => {
@ -150,289 +180,7 @@ const colors = computed(() => {
} }
}) })
// ---- State ---------------------------------------------------------------- // ---- Scroll infinito y detalle ---------------------------------------------
const exactSearch = ref(false)
const sortMode = ref<'relevance' | 'date'>('relevance')
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
const {
activeBibleStudies,
activeCount,
bibleStudyInput,
isValidating,
applyBibleStudyFilter,
removeBibleStudyFilter,
clearAllBibleStudyFilters,
} = useFilters()
function refetchResults() {
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(1, false)
} else {
groupedHits.value = []
currentPage.value = 1
runSearch(query.value, 1, false)
}
}
watch(activeBibleStudies, () => {
refetchResults()
})
// ----------------------------------------------------------------------------
const groupedHits = ref<SearchGroup[]>([])
const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? groupedHits.value.length < total.value : false
)
const visibleGroupCount = ref(10)
const visibleGroups = computed(() =>
settings.paginationType === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
settings.paginationType === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
settings.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: docCache.value[g.docId],
firstHit: g.firstHit
}))
})
const activePage = ref(p0)
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
)
const docCache = ref<Record<string, DocMeta>>({})
const { documentsApi } = useTypesenseApi()
const toast = useToast()
// ---- Batch fetch de metadatos ---------------------------------------------
async function fetchDocumentMeta(docIds: string[]) {
const unique = docIds.filter(id => id && !(id in docCache.value))
if (!unique.length) return
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.mainCollection,
q: '*',
queryBy: 'title',
filterBy: `id:=[${unique.join(',')}]${unlocked.value ? '' : ' && private:=false'}`,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft',
perPage: unique.length,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
for (const hit of docHits) {
if (hit.document.id) docCache.value[hit.document.id] = hit.document
}
} catch (err) {
console.error('Error fetching document metadata', err)
}
}
// ---- Búsqueda de párrafos (con query) -------------------------------------
let searchSeq = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
async function runSearch(q: string, page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.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: props.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: unlocked.value ? filterBy.value : `${filterBy.value} && $${props.mainCollection}(private:=false)`,
...(shouldSortByDate ? { sortBy: `$${props.mainCollection}(timestamp:desc)` } : {}),
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
highlightAffixNumTokens: 30,
groupBy: props.groupByField
}]
}
})
if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const rawGroups = res?.groupedHits ?? []
const newGroups: SearchGroup[] = rawGroups.map(g => ({
docId: g.groupKey[0]!,
firstHit: g.hits[0]!,
allHits: g.hits
}))
if (!append) docCache.value = {}
await fetchDocumentMeta(newGroups.map(g => g.docId).filter(Boolean))
if (seq !== searchSeq) 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 (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { groupedHits.value = []; total.value = 0 }
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
// ---- Exploración por fecha (sin query) ------------------------------------
async function runBrowse(page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: '*',
queryBy: QUERY_BY,
filterBy: `${filterBy.value} && $${props.mainCollection}(locale:=${locale.value}${unlocked.value ? '' : ' && private:=false'})`,
sortBy: `$${props.mainCollection}(timestamp:desc)`,
groupBy: props.groupByField,
perPage: settings.pageSize,
page: typePage,
includeFields: `$${props.mainCollection}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}]
}
})
if (seq !== searchSeq) return
const result = (multi?.results?.[0] as TypesenseSearchResponse | undefined)
const rawGroups = result?.groupedHits ?? []
const newItems = rawGroups.map(g => {
const docId = g.groupKey[0]!
const parentMeta = (g.hits[0]?.document as unknown as Record<string, unknown>)[props.mainCollection] as Partial<DocMeta> | undefined
return { docId, meta: { id: docId, ...parentMeta } as DocMeta }
})
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 (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { browseItems.value = []; browseTotal.value = 0 }
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (settings.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)
}
}
const listContainer = ref<HTMLElement | null>(null) const listContainer = ref<HTMLElement | null>(null)
@ -442,7 +190,7 @@ function onListScroll() {
if (!el) return if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) { if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) { if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(browsePage.value, true) if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(1, true)
} else { } else {
if (hasMoreVisible.value) visibleGroupCount.value += 10 if (hasMoreVisible.value) visibleGroupCount.value += 10
else if (hasMore.value && !loadingMore.value && !loading.value) loadMore() else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
@ -450,86 +198,54 @@ function onListScroll() {
} }
} }
function retry() {
if (!query.value.trim()) runBrowse(activePage.value, false)
else runSearch(query.value, activePage.value, false)
}
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
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)
}
})
// ---- Selección y carga del detalle ---------------------------------------- // ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null) const selectedDocId = ref<string | null>(null)
const selectedDocument = ref<DocumentDoc | null>(null) const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false) const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseParagraphHit[]>([]) const selectedParagraphs = ref<TypesenseGroupedParagraphHit[]>([])
const paragraphsLoading = ref(false) const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseParagraphHit | null>(null) const selectedHit = ref<TypesenseGroupedParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([]) const selectedMatchingHits = ref<TypesenseGroupedParagraphHit[]>([])
let detailSeq = 0
let detailController: AbortController | null = null
onBeforeUnmount(() => { detailController?.abort() })
const { fetchDocumentDetail } = useDocumentDetailFetch()
async function fetchDocumentWithParagraphs(docId: string) { async function fetchDocumentWithParagraphs(docId: string) {
const seq = ++detailSeq
detailController?.abort()
const controller = new AbortController()
detailController = controller
documentLoading.value = true documentLoading.value = true
paragraphsLoading.value = true paragraphsLoading.value = true
selectedDocument.value = null selectedDocument.value = null
selectedParagraphs.value = [] selectedParagraphs.value = []
try { try {
const res = await documentsApi.multiSearch({ const detail = await fetchDocumentDetail(documentsApi, props.mainCollection, props.paragraphsCollection, docId, controller.signal)
multiSearchParameters: {}, if (seq !== detailSeq) return
multiSearchSearchesParameter: { if (detail) {
searches: [{ const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
collection: props.mainCollection, selectedDocument.value = detail.document as unknown as DocumentDoc
q: '*',
queryBy: 'title',
filterBy: `id:=${docId} && $${props.paragraphsCollection}(id: *)`,
includeFields: `*, $${props.paragraphsCollection}(*)`
}]
}
})
const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (hit) {
const docRaw = { ...hit.document }
const raw = docRaw[props.paragraphsCollection]
const rawParagraphs = Array.isArray(raw) ? raw : (raw ? [raw] : []) as ParagraphDoc[]
delete docRaw[props.paragraphsCollection]
selectedDocument.value = docRaw as unknown as DocumentDoc
selectedParagraphs.value = [...rawParagraphs] selectedParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0)) .sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p })) .map(p => ({ document: p }))
} }
} catch (err) { } catch (err) {
if (seq !== detailSeq) return
if ((err as { name?: string })?.name === 'AbortError') return
console.error('Error fetching document with paragraphs', err) console.error('Error fetching document with paragraphs', err)
selectedDocument.value = null selectedDocument.value = null
selectedParagraphs.value = [] selectedParagraphs.value = []
} finally { } finally {
if (seq === detailSeq) {
documentLoading.value = false documentLoading.value = false
paragraphsLoading.value = false paragraphsLoading.value = false
} }
}
} }
async function selectGroup(group: DisplayGroup) { async function selectGroup(group: DisplayGroup) {
@ -590,7 +306,7 @@ useDetailHistory(isPanelOpen, isMobile)
// ---- Helpers de presentación ---------------------------------------------- // ---- Helpers de presentación ----------------------------------------------
function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null { function highlightedFor(hit: TypesenseGroupedParagraphHit, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field) const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet if (fromArr?.snippet) return fromArr.snippet
if (fromArr?.value) return fromArr.value if (fromArr?.value) return fromArr.value
@ -600,14 +316,14 @@ function highlightedFor(hit: TypesenseParagraphHit, field: string): string | nul
return null return null
} }
function metaDate(meta: DocMeta | undefined): string { function metaDate(meta: CachedDocMeta | undefined): string {
if (!meta) return '' if (!meta) return ''
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null) const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
if (!ts) return meta.date || '' if (!ts) return meta.date || ''
return formatDate(ts) return formatDate(ts)
} }
function metaLocation(meta: DocMeta | undefined): string { function metaLocation(meta: CachedDocMeta | undefined): string {
if (!meta) return '' if (!meta) return ''
return formatLocation({ return formatLocation({
id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '', id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
@ -674,7 +390,6 @@ function metaLocation(meta: DocMeta | undefined): string {
</div> </div>
<!-- CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) --> <!-- CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) -->
<!-- En desktop los chips van debajo del input; en mobile van DENTRO del slideover -->
<div v-if="showBibleStudyFilter && activeBibleStudies.length > 0 && !isMobile" class="px-4 sm:px-6 py-2 border-b border-default"> <div v-if="showBibleStudyFilter && activeBibleStudies.length > 0 && !isMobile" class="px-4 sm:px-6 py-2 border-b border-default">
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<UBadge <UBadge
@ -722,19 +437,11 @@ function metaLocation(meta: DocMeta | undefined): string {
</div> </div>
<!-- FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) --> <!-- FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) -->
<!--
El componente FiltersContainer renderiza DISTINTO según la pantalla:
- Desktop (>lg): acordeón colapsable con header "Filtros"
- Mobile (<lg): botón "Filtros" que abre un USlideover
El slot #content (input + botón) es el MISMO en ambos casos.
El slot #chips se usa solo en mobile (dentro del slideover debajo del content).
-->
<template v-if="showBibleStudyFilter"> <template v-if="showBibleStudyFilter">
<FiltersContainer <FiltersContainer
:active-count="activeBibleStudies.length" :active-count="activeBibleStudies.length"
title="search.filters" title="search.filters"
> >
<!-- #chips se renderiza dentro del slideover en mobile -->
<template #chips> <template #chips>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<UBadge <UBadge
@ -776,7 +483,7 @@ function metaLocation(meta: DocMeta | undefined): string {
:placeholder="$t('search.bible_study_placeholder')" :placeholder="$t('search.bible_study_placeholder')"
size="sm" size="sm"
class="w-36" class="w-36"
@keyup.enter="applyBibleStudyFilter(props.mainCollection)" @keyup.enter="applyBibleStudyFilter"
/> />
<UButton <UButton
size="sm" size="sm"
@ -784,7 +491,7 @@ function metaLocation(meta: DocMeta | undefined): string {
variant="outline" variant="outline"
:loading="isValidating" :loading="isValidating"
:disabled="bibleStudyInput === null || bibleStudyInput <= 0" :disabled="bibleStudyInput === null || bibleStudyInput <= 0"
@click="applyBibleStudyFilter(props.mainCollection)" @click="applyBibleStudyFilter"
> >
+ {{ $t('search.filter') }} + {{ $t('search.filter') }}
</UButton> </UButton>

View File

@ -0,0 +1,56 @@
export interface CachedDocMeta {
id: string
title: string
date?: string
timestamp?: number
place?: string
city?: string
state?: string
country?: string
type?: string
slug?: string
draft?: string
[key: string]: unknown
}
const MAX_ENTRIES = 500
function cacheKey(collection: string, id: string): string {
return `${collection}:${id}`
}
/**
* Caché de metadata de documentos (título, fecha, lugar...) compartida entre
* páginas de búsqueda, con vida de sesión (sobrevive entre queries, no se
* vacía al buscar de nuevo). Clave `collection:id` porque los IDs no son
* únicos entre colecciones (p.ej. `conferences` vs `activities`).
*
* Tope de tamaño con eviction LRU simple: en cada lectura/escritura la
* entrada se reinserta al final del Map (orden de inserción = recencia), así
* que la más antigua a evictar siempre es `.keys().next().value`.
*/
export function useDocMetaCache() {
const cache = useState<Map<string, CachedDocMeta>>('doc-meta-cache', () => new Map())
function get(collection: string, id: string): CachedDocMeta | undefined {
const key = cacheKey(collection, id)
const entry = cache.value.get(key)
if (entry) {
cache.value.delete(key)
cache.value.set(key, entry)
}
return entry
}
function set(collection: string, id: string, meta: CachedDocMeta) {
const key = cacheKey(collection, id)
cache.value.delete(key)
cache.value.set(key, meta)
if (cache.value.size > MAX_ENTRIES) {
const oldestKey = cache.value.keys().next().value
if (oldestKey !== undefined) cache.value.delete(oldestKey)
}
}
return { get, set }
}

View File

@ -0,0 +1,82 @@
export interface DocumentDetailResult {
document: Record<string, unknown>
paragraphs: Record<string, unknown>[]
}
const MAX_ENTRIES = 25
function cacheKey(mainCollection: string, docId: string): string {
return `${mainCollection}:${docId}`
}
/**
* Fetch de "documento principal + sus párrafos" (join de Typesense), con
* caché de sesión compartida entre SearchPanel.vue y usePublicationFetch.ts
* antes cada uno hacía la misma query por separado sin cachear nada, así
* que reabrir el mismo documento en la sesión repetía la ida y vuelta de red.
* Tope de ~25 entradas (documentos completos con cuerpo/párrafos pesan más
* que la metadata de useDocMetaCache) con eviction LRU simple.
*/
export function useDocumentDetailFetch() {
const cache = useState<Map<string, DocumentDetailResult>>('doc-detail-cache', () => new Map())
function getCached(mainCollection: string, docId: string): DocumentDetailResult | undefined {
const key = cacheKey(mainCollection, docId)
const entry = cache.value.get(key)
if (entry) {
cache.value.delete(key)
cache.value.set(key, entry)
}
return entry
}
function setCached(mainCollection: string, docId: string, detail: DocumentDetailResult) {
const key = cacheKey(mainCollection, docId)
cache.value.delete(key)
cache.value.set(key, detail)
if (cache.value.size > MAX_ENTRIES) {
const oldestKey = cache.value.keys().next().value
if (oldestKey !== undefined) cache.value.delete(oldestKey)
}
}
async function fetchDocumentDetail(
documentsApi: ReturnType<typeof useTypesenseClient>['documentsApi'],
mainCollection: string,
paragraphsCollection: string,
docId: string,
signal?: AbortSignal
): Promise<DocumentDetailResult | null> {
const cached = getCached(mainCollection, docId)
if (cached) return cached
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: mainCollection,
q: '*',
queryBy: 'title',
filterBy: `id:=${docId} && $${paragraphsCollection}(id: *)`,
includeFields: `*, $${paragraphsCollection}(*)`,
useCache: true,
cacheTtl: 3600
}]
}
}, { signal })
const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (!hit) return null
const docRaw = { ...hit.document }
const raw = docRaw[paragraphsCollection]
const paragraphs = (Array.isArray(raw) ? raw : (raw ? [raw] : [])) as Record<string, unknown>[]
delete docRaw[paragraphsCollection]
const detail: DocumentDetailResult = { document: docRaw, paragraphs }
setCached(mainCollection, docId, detail)
return detail
}
return { fetchDocumentDetail }
}

View File

@ -34,7 +34,9 @@ export function useFilters() {
queryBy: 'title', queryBy: 'title',
filterBy: `bible_study:=${val}`, filterBy: `bible_study:=${val}`,
perPage: 1, perPage: 1,
includeFields: 'bible_study,title' includeFields: 'bible_study,title',
useCache: true,
cacheTtl: 3600
}] }]
} }
}) })

View File

@ -49,7 +49,15 @@ export function usePublicationFetch() {
const detailParagraphs = ref<TypesenseParagraphHit[]>([]) const detailParagraphs = ref<TypesenseParagraphHit[]>([])
const detailParagraphsLoading = ref(false) const detailParagraphsLoading = ref(false)
const { documentsApi } = useTypesenseApi() const { documentsApi } = useTypesenseClient()
const { fetchDocumentDetail } = useDocumentDetailFetch()
let fetchSeq = 0
let fetchController: AbortController | null = null
function isAbortError(err: unknown): boolean {
return (err as { name?: string } | null)?.name === 'AbortError'
}
async function fetchDetail(hit: SearchHit, favoritesCollection: string) { async function fetchDetail(hit: SearchHit, favoritesCollection: string) {
const config = COLLECTION_CONFIG[favoritesCollection] const config = COLLECTION_CONFIG[favoritesCollection]
@ -59,43 +67,37 @@ export function usePublicationFetch() {
detailParagraphs.value = [] detailParagraphs.value = []
return return
} }
const seq = ++fetchSeq
fetchController?.abort()
const controller = new AbortController()
fetchController = controller
detailDocumentLoading.value = true detailDocumentLoading.value = true
detailParagraphsLoading.value = true detailParagraphsLoading.value = true
detailDocument.value = null detailDocument.value = null
detailParagraphs.value = [] detailParagraphs.value = []
try { try {
const res = await documentsApi.multiSearch({ const detail = await fetchDocumentDetail(documentsApi, config.main, config.paragraphs, docId, controller.signal)
multiSearchParameters: {}, if (seq !== fetchSeq) return
multiSearchSearchesParameter: { if (detail) {
searches: [{ const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
collection: config.main, detailDocument.value = detail.document as unknown as DocumentDoc
q: '*',
queryBy: 'title',
filterBy: `id:=${docId} && $${config.paragraphs}(id: *)`,
includeFields: `*, $${config.paragraphs}(*)`
}]
}
})
const docHit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (docHit) {
const docRaw = { ...docHit.document }
const raw = docRaw[config.paragraphs]
const rawParagraphs = Array.isArray(raw) ? raw : (raw ? [raw] : []) as ParagraphDoc[]
delete docRaw[config.paragraphs]
detailDocument.value = docRaw as unknown as DocumentDoc
detailParagraphs.value = [...rawParagraphs] detailParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0)) .sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p })) .map(p => ({ document: p }))
} }
} catch (err) { } catch (err) {
if (seq !== fetchSeq) return
if (isAbortError(err)) return
console.error('[usePublicationFetch] Error fetching publication detail', err) console.error('[usePublicationFetch] Error fetching publication detail', err)
detailDocument.value = null detailDocument.value = null
detailParagraphs.value = [] detailParagraphs.value = []
} finally { } finally {
if (seq === fetchSeq) {
detailDocumentLoading.value = false detailDocumentLoading.value = false
detailParagraphsLoading.value = false detailParagraphsLoading.value = false
} }
} }
}
function clearDetail() { function clearDetail() {
detailDocument.value = null detailDocument.value = null

View File

@ -0,0 +1,9 @@
/**
* Devuelve la instancia única del cliente Typesense creada por el plugin
* `typesense-client.client.ts`, en vez de construir un `Configuration` +
* 14 sub-APIs nuevos en cada llamada (lo que hace `useTypesenseApi()` crudo).
*/
export function useTypesenseClient() {
const nuxtApp = useNuxtApp()
return nuxtApp.$typesenseApi
}

View File

@ -0,0 +1,541 @@
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
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 { 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() {
const base = options.browseFilterBy ? options.browseFilterBy() : options.filterBy()
return options.isUnlocked()
? base
: `${base} && 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
}
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core' import { breakpointsTailwind } from '@vueuse/core'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue' import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
import { useFavoritesStore } from '~/stores/favorites' import { useFavoritesStore } from '~/stores/favorites'
import { useSettingsStore } from '~/stores/settings' import { useSettingsStore } from '~/stores/settings'
@ -23,20 +23,10 @@ const filterBy = computed(() => {
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
}) })
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() const settings = useSettingsStore()
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState() const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
const query = ref(q0)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
interface Study { interface Study {
id?: number id?: number
title?: string title?: string
@ -59,139 +49,19 @@ interface EntrelineaDoc {
[key: string]: unknown [key: string]: unknown
} }
interface TypesenseHighlight { const {
field?: string query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
snippet?: string hits, total, activePage, totalPages, hasMore,
value?: string runSearch, loadMore, goToPage, retry
matched_tokens?: string[] } = useFlatTypesenseSearch<EntrelineaDoc>({
}
interface TypesenseHit {
document: EntrelineaDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
interface TypesenseSearchResponse {
found: number
out_of?: number
page?: number
hits?: TypesenseHit[]
}
const hits = ref<TypesenseHit[]>([])
const total = ref(0)
const currentPage = ref(1)
const activePage = ref(p0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
)
const { documentsApi } = useTypesenseApi()
let searchSeq = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
async function runSearch(q: string, page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite
? (append ? currentPage.value + 1 : 1)
: page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: COLLECTION, collection: COLLECTION,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY, queryBy: QUERY_BY,
filterBy: () => filterBy.value,
includeFields: INCLUDE_FIELDS, includeFields: INCLUDE_FIELDS,
filterBy: filterBy.value, pageSize: () => settings.pageSize,
perPage: settings.pageSize, paginationType: () => settings.paginationType,
page: typePage, initialQuery: q0,
highlightFullFields: QUERY_BY, initialPage: p0
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>'
}]
}
})
if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const newHits = res?.hits ?? []
hits.value = append ? hits.value.concat(newHits) : newHits
total.value = res?.found ?? hits.value.length
currentPage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) {
hits.value = []
total.value = 0
}
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (settings.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(() => {
if (timeoutId) clearTimeout(timeoutId)
})
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)
}) })
const selected = ref<EntrelineaDoc | null>(null) const selected = ref<EntrelineaDoc | null>(null)
@ -262,7 +132,7 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
}) })
} }
function highlightedFor(hit: TypesenseHit, field: string): string | null { function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field) const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet if (fromArr?.snippet) return fromArr.snippet
if (fromArr?.value) return fromArr.value if (fromArr?.value) return fromArr.value

View File

@ -0,0 +1,24 @@
/**
* Construye el cliente de Typesense (Configuration + 14 sub-APIs) UNA sola
* vez por carga de página, en vez de reconstruirlo en cada componente que
* llama `useTypesenseApi()` (SearchPanel.vue, entrelineas.vue,
* usePublicationFetch.ts). Se expone vía `nuxtApp.$typesenseApi` (tipado
* automáticamente por Nuxt gracias al `provide` de retorno); el acceso lo
* da el composable `useTypesenseClient()`.
*
* Sin sufijo `.client`: debe correr también en SSR porque los composables
* lo consumen de forma síncrona en `setup()` (que se ejecuta en SSR,
* aunque los fetches reales solo disparen en cliente) con `.client` el
* valor provisto queda `undefined` en el render de servidor y todo revienta.
*/
export default defineNuxtPlugin({
name: 'typesense-client',
setup() {
const api = useTypesenseApi()
return {
provide: {
typesenseApi: api
}
}
}
})

View File

@ -118,10 +118,11 @@ export const useHistoryStore = defineStore('history', () => {
// entradas. Como `visit()` siempre añade al inicio, recortamos por el final. // entradas. Como `visit()` siempre añade al inicio, recortamos por el final.
const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next
items.value = trimmed items.value = trimmed
writeStorage(trimmed)
} }
// Red de seguridad: cualquier mutación directa de `items.value` se persiste. // Única vía de persistencia: cualquier mutación de `items.value` (incluida
// la de `commit`) se guarda aquí. No duplicar con un `writeStorage` extra
// en `commit`, o cada visita serializa y escribe el historial dos veces.
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
watch(items, (next) => { watch(items, (next) => {
if (!hydrated) return if (!hydrated) return

View File

@ -0,0 +1,119 @@
# Limpieza de HTML embebido (Publicaciones y Entrelíneas)
Notas técnicas sobre el saneamiento de HTML que llega desde el CMS/índice de búsqueda
y se renderiza con `v-html` en los paneles de detalle. Documenta el problema, la causa
raíz y el fix aplicado en cada caso, para poder depurar o mejorar esto más adelante.
## Contexto general
Varios documentos (publicaciones, entrelíneas) traen un campo `html` que en teoría es
un fragmento de texto formateado, pero en la práctica viene "contaminado" con marcado
que no debería estar ahí: estilos de Word, anchos fijos, o incluso HTML ya renderizado
de otro componente que fue copiado/pegado en Directus por error. Ese marcado extra
puede romper el layout porque se inyecta directo en el DOM de la app (clases de
Tailwind, `data-*`, tablas de Word con `width` en `cm`/`pt`, etc.).
Cada vista que hace `v-html` de estos campos tiene su propia función de limpieza
(basada en regex, no en un parser DOM) justo antes de renderizar.
---
## Caso 1 — `PublicationDetail.vue`: HTML pegado desde Word
**Archivo:** [`app/components/PublicationDetail.vue`](../app/components/PublicationDetail.vue)
**Síntoma:** tablas/celdas con overflow horizontal o contenido comprimido en el panel
de detalle de publicaciones.
**Causa raíz:** el HTML exportado/pegado desde Word trae:
- Bloques `<style>` completos con reglas `mso-*`.
- Anchos fijos en unidades absolutas (`width: 15.5cm`, `pt`, etc.) en tablas y celdas,
que no responden al layout del contenedor.
**Fix:** función `cleanWordHtml()` (cerca de la línea 85) aplicada al `v-html` del
párrafo:
- Elimina bloques `<style>`.
- Elimina declaraciones `width: <número><cm|mm|pt|px|em|rem|in|pc>`.
- Colapsa saltos de línea literales a un espacio.
Reforzado con CSS en `.paragraph-html` (`:deep(p|span|li|td|th)` → `white-space:
normal`, tablas a `width: 100%` / `table-layout: auto`, celdas y divs a `width: auto` /
`max-width: 100%`) para cubrir estilos que la regex no puede tocar.
---
## Caso 2 — `EntrelineaDetail.vue`: HTML contaminado con clases de otro componente
**Archivo:** [`app/components/entrelineas/EntrelineaDetail.vue`](../app/components/entrelineas/EntrelineaDetail.vue)
**Síntoma:** el texto de la entrelínea se mostraba con **una palabra por línea**,
dejando la mayor parte del panel en blanco.
**Causa raíz (confirmada consultando el documento directo en Typesense):** el campo
`html` de ese registro no contenía un `<p>` por palabra (esa fue la hipótesis inicial,
descartada). Contenía el HTML **ya renderizado de `PublicationDetail.vue`** pegado por
error en Directus, incluyendo:
- Atributos de scoping de Vue (`data-v-d5ee3d80`).
- `data-paragraph-number="37"`.
- Clases de Tailwind reales: `grid grid-cols-1fr items-start gap-2 mb-2
grid-cols-[20px_1fr]`.
Como esas clases son utilidades globales de Tailwind, se aplicaban igual dentro de
`EntrelineaDetail`. El contenedor quedaba como grid de 2 columnas (`20px 1fr`) y, al
tener un solo hijo, el auto-placement lo metía en la columna de **20px** → todo el
párrafo se comprimía a un ancho mínimo y cada palabra terminaba en su propia línea al
hacer wrap.
**Cómo se verificó:** se consultó el documento crudo directo en Typesense:
```bash
curl -s "$NUXT_PUBLIC_TYPESENSE_URL/multi_search" \
-H "X-TYPESENSE-API-KEY: $NUXT_PUBLIC_TYPESENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"searches":[{"collection":"entrelineas","q":"*","filter_by":"id:=<ID_DEL_DOC>","include_fields":"*"}]}'
```
Esto es útil para cualquier bug futuro de renderizado: primero confirmar qué HTML
crudo hay realmente en el índice antes de asumir la causa.
**Fix:** función `formatEntrelineaText()` (cerca de la línea 56), agrega dos
reemplazos antes de los ya existentes:
- Elimina cualquier atributo `class="..."` / `class='...'`.
- Elimina cualquier atributo `data-*="..."` / `data-*='...'`.
Esto neutraliza clases o atributos de scoping filtrados desde cualquier otra fuente,
sin tocar los `style` inline (que sí son necesarios: cursiva, colores, fuente del
documento original).
---
## Limitaciones conocidas
- Ambas limpiezas son **basadas en regex**, no en un parser DOM real. Cubren los casos
vistos hasta ahora, pero no garantizan sanear cualquier HTML arbitrario (por ejemplo,
no tocan `style="display: grid; ..."` puesto inline, solo `width` en unidades
absolutas).
- La lógica está **duplicada** entre `cleanWordHtml` (Publicaciones) y
`formatEntrelineaText` (Entrelíneas). Si aparece un caso nuevo, hay que recordar
aplicarlo en los dos lugares (o consolidarlos, ver abajo).
- El origen del problema está en los datos (Directus / proceso de carga), no en el
frontend. Estas funciones son un parche en el punto de renderizado, no una
corrección en la fuente.
## Ideas para mejorar esto a futuro
1. **Consolidar en un solo util compartido**, por ejemplo en
[`app/utils/textUtilities.ts`](../app/utils/textUtilities.ts) o un nuevo
`app/utils/htmlSanitizer.ts`, con funciones nombradas por lo que hacen
(`stripStyleBlocks`, `stripAbsoluteWidths`, `stripClassAndDataAttrs`,
`collapseNewlines`) y componerlas según necesite cada vista, en vez de tener dos
funciones casi idénticas.
2. **Agregar tests de regresión** con fixtures de HTML "sucio" ya vistos en producción
(el de Word con `mso-*`, el de la entrelínea contaminada con clases de grid) para
que un cambio futuro no reintroduzca estos bugs silenciosamente.
3. **Sanear en el origen** (al indexar en Typesense o al guardar en Directus) en lugar
de en cada punto de render, para que cualquier consumidor futuro del campo `html`
(snippets en listas, exportaciones, etc.) reciba datos ya limpios.
4. Si se detectan más casos de contaminación cruzada entre componentes, vale la pena
revisar el flujo de carga de contenido en Directus para encontrar dónde se está
pegando HTML renderizado en vez de HTML fuente.

View File

@ -8,7 +8,8 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "nuxt typecheck" "typecheck": "nuxt typecheck",
"benchmark:search": "node --env-file=.env scripts/benchmark-search.mjs"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",

View File

@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* Benchmark de red para el buscador: compara la latencia del patrón de
* búsqueda ANTES de las optimizaciones (varias requests secuenciales) contra
* el patrón DESPUÉS (una sola request con join), golpeando directamente el
* cluster real de Typesense sin pasar por el navegador ni por Nuxt.
*
* Sirve como línea base repetible: correr este script antes/después de un
* cambio futuro en las queries de búsqueda muestra si mejoró o empeoró la
* latencia real contra el servidor, no solo "se siente más rápido".
*
* Uso:
* node --env-file=.env scripts/benchmark-search.mjs
* node --env-file=.env scripts/benchmark-search.mjs --iterations 20 --query "amor"
* pnpm run benchmark:search -- --iterations 20
*
* Requiere NUXT_PUBLIC_TYPESENSE_URL y NUXT_PUBLIC_TYPESENSE_API_KEY en el
* entorno (--env-file=.env los carga automáticamente en Node 20.6+).
*/
const args = process.argv.slice(2)
function argValue(name, fallback) {
const idx = args.indexOf(`--${name}`)
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback
}
const ITERATIONS = Number(argValue('iterations', '15'))
const QUERY = argValue('query', 'amor')
const LOCALE = argValue('locale', 'es')
const TYPESENSE_URL = process.env.NUXT_PUBLIC_TYPESENSE_URL
const API_KEY = process.env.NUXT_PUBLIC_TYPESENSE_API_KEY
if (!TYPESENSE_URL || !API_KEY) {
console.error('Faltan NUXT_PUBLIC_TYPESENSE_URL / NUXT_PUBLIC_TYPESENSE_API_KEY.')
console.error('Corré con: node --env-file=.env scripts/benchmark-search.mjs')
process.exit(1)
}
async function multiSearch(searches) {
const res = await fetch(`${TYPESENSE_URL}/multi_search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-TYPESENSE-API-KEY': API_KEY
},
body: JSON.stringify({ searches })
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
return res.json()
}
async function timeIt(fn) {
const start = performance.now()
await fn()
return performance.now() - start
}
function stats(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const sum = sorted.reduce((a, b) => a + b, 0)
const p = q => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
return {
mean: sum / sorted.length,
median: p(0.5),
p95: p(0.95),
min: sorted[0],
max: sorted[sorted.length - 1]
}
}
async function runScenario(name, fn) {
const samples = []
// Un warmup fuera de la medición, para no medir handshake TLS/DNS frío.
await fn().catch(() => {})
for (let i = 0; i < ITERATIONS; i++) {
samples.push(await timeIt(fn))
}
return { name, ...stats(samples) }
}
// ── Escenarios por colección (conferences / activities) ─────────────────────
const COLLECTIONS = [
{ label: 'conferences', main: 'conferences', paragraphs: 'conferences_paragraphs', groupBy: 'conferences_id' },
{ label: 'activities', main: 'activities', paragraphs: 'activities_paragraphs', groupBy: 'activities_id' }
]
function oldSearchScenario({ main, paragraphs, groupBy }) {
return async () => {
// 1) búsqueda de párrafos, SIN join (como antes de la Fase 1.1)
const r1 = await multiSearch([{
collection: paragraphs,
q: QUERY,
query_by: 'text',
filter_by: `locale:=${LOCALE}`,
per_page: 10,
highlight_full_fields: 'text',
highlight_fields: 'text',
highlight_affix_num_tokens: 30,
group_by: groupBy
}])
const ids = (r1.results?.[0]?.grouped_hits ?? [])
.map(g => g.group_key?.[0])
.filter(Boolean)
if (!ids.length) return
// 2) segunda request para la metadata (la cascada eliminada en 1.1)
await multiSearch([{
collection: main,
q: '*',
query_by: 'title',
filter_by: `id:=[${ids.join(',')}]`,
per_page: ids.length,
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
}])
}
}
function newSearchScenario({ main, paragraphs, groupBy }) {
return async () => {
// Una sola request: join a la colección principal + highlight recortado
await multiSearch([{
collection: paragraphs,
q: QUERY,
query_by: 'text',
filter_by: `locale:=${LOCALE}`,
per_page: 10,
highlight_full_fields: 'text',
highlight_fields: 'text',
highlight_affix_num_tokens: 15,
group_by: groupBy,
include_fields: `*, $${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}])
}
}
function oldBrowseScenario({ main, paragraphs, groupBy }) {
return async () => {
// Explorar sin query contra la colección grande de párrafos (antes de 1.2)
await multiSearch([{
collection: paragraphs,
q: '*',
query_by: 'text',
filter_by: `locale:=${LOCALE} && $${main}(locale:=${LOCALE})`,
sort_by: `$${main}(timestamp:desc)`,
group_by: groupBy,
per_page: 10,
include_fields: `$${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}])
}
}
function newBrowseScenario({ main }) {
return async () => {
// Explorar sin query contra la colección principal, directo (después de 1.2)
await multiSearch([{
collection: main,
q: '*',
query_by: 'title',
filter_by: `locale:=${LOCALE}`,
sort_by: 'timestamp:desc',
per_page: 10,
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
}])
}
}
function printTable(rows) {
const cols = ['name', 'mean', 'median', 'p95', 'min', 'max']
const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(typeof r[c] === 'number' ? r[c].toFixed(1) : r[c]).length)))
const fmtRow = vals => vals.map((v, i) => String(v).padEnd(widths[i])).join(' ')
console.log(fmtRow(cols.map(c => c.toUpperCase())))
console.log(widths.map(w => '-'.repeat(w)).join(' '))
for (const r of rows) {
console.log(fmtRow(cols.map(c => (typeof r[c] === 'number' ? r[c].toFixed(1) + 'ms' : r[c]))))
}
}
async function main() {
console.log(`Typesense: ${TYPESENSE_URL} | query="${QUERY}" | locale=${LOCALE} | iteraciones=${ITERATIONS}\n`)
for (const col of COLLECTIONS) {
console.log(`\n=== ${col.label} — búsqueda con texto ===`)
const oldR = await runScenario('antes (2 requests)', oldSearchScenario(col))
const newR = await runScenario('después (1 request)', newSearchScenario(col))
printTable([oldR, newR])
const improvement = ((oldR.mean - newR.mean) / oldR.mean * 100).toFixed(1)
console.log(`${improvement}% más rápido en promedio`)
console.log(`\n=== ${col.label} — explorar sin query ===`)
const oldB = await runScenario('antes (colección párrafos)', oldBrowseScenario(col))
const newB = await runScenario('después (colección principal)', newBrowseScenario(col))
printTable([oldB, newB])
const improvementB = ((oldB.mean - newB.mean) / oldB.mean * 100).toFixed(1)
console.log(`${improvementB}% más rápido en promedio`)
}
}
main().catch((err) => {
console.error('Error corriendo el benchmark:', err)
process.exit(1)
})