Compare commits

..

No commits in common. "main" and "new-collections" have entirely different histories.

33 changed files with 739 additions and 1918 deletions

View File

@ -1,86 +0,0 @@
name: Deploy Search Typesense
on:
push:
branches:
- staging
- production
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Route each branch to a specific directory and PM2 environment
- name: Setup Environment Variables
run: |
if [[ "${{ github.ref_name }}" == "production" ]]; then
TARGET="/var/www/search.carpa.com"
echo "PM2_ENV=production" >> $GITHUB_ENV
elif [[ "${{ github.ref_name }}" == "staging" ]]; then
TARGET="/var/www/dev.search.carpa.com"
echo "PM2_ENV=staging" >> $GITHUB_ENV
fi
PM2_NAME="search-${{ github.ref_name }}"
echo "TARGET_DIR=$TARGET" >> $GITHUB_ENV
echo "PM2_NAME=$PM2_NAME" >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_WEBHOOK=${{ secrets.NUXT_PUBLIC_FEEDBACK_WEBHOOK }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ secrets.NUXT_PUBLIC_RECAPTCHA_SITE_KEY }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC=${{ secrets.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_MIN_SECONDS=${{ secrets.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS }}' >> $GITHUB_ENV
echo 'NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_TYPESENSE_API_KEY=${{ secrets.NUXT_PUBLIC_TYPESENSE_API_KEY }}' >> $GITHUB_ENV
echo 'TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_ENTRELINEAS_DEV_KEY=${{ secrets.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY }}' >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Build Nuxt Application
run: npm run build
env:
NITRO_PRESET: node-server
TYPESENSE_NODES: ${{ secrets.TYPESENSE_NODES }}
- name: Copy Build Files to Vultr
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
source: ".output/*,package.json,ecosystem.config.cjs"
target: "${{ env.TARGET_DIR }}"
- name: Restart PM2 on Vultr
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
script: |
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ env.TARGET_DIR }}
PM2_NAME=${{ env.PM2_NAME }} \
NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }} \
TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }} \
pm2 reload ecosystem.config.cjs --update-env --env ${{ env.PM2_ENV }} || \
PM2_NAME=${{ env.PM2_NAME }} \
NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }} \
TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }} \
pm2 start ecosystem.config.cjs --update-env --env ${{ env.PM2_ENV }}

1
.gitignore vendored
View File

@ -17,7 +17,6 @@ logs
.DS_Store
.fleet
.idea
--port
# Local env files
.env

View File

@ -61,4 +61,4 @@ Check out the [deployment documentation](https://nuxt.com/docs/getting-started/d
## Renovate integration
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go. chage v4
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go.

View File

@ -66,8 +66,6 @@ const props = defineProps<{
selectedHit?: TypesenseParagraphHit | null
selectedMatchingHits?: TypesenseParagraphHit[] | null
accentColor?: 'green' | 'blue'
noTrackVisit?: boolean
author?: string
}>()
const emits = defineEmits(['close'])
@ -105,7 +103,7 @@ watch(
() => [props.collection, props.document?.id] as const,
([collection, id]) => {
if (!collection || !id || !props.document) return
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
history.visit(collection, toSearchHit(props.document))
},
{ immediate: true }
)
@ -846,7 +844,7 @@ const items = computed(() => {
@click="toggleInternalSearch"
/>
</UTooltip>
<UTooltip text="¿Cómo funciona?">
<UTooltip text="Como funciona?">
<UButton
icon="ph-student"
color="neutral"
@ -886,10 +884,6 @@ const items = computed(() => {
<UIcon name="ph:file-dashed" class="size-4 text-carpared" />
{{ $t('search.draft') }}
</p>
<p v-if="author" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:user-circle" :class="['size-4', iconColor]" />
<span class="italic">{{ author }}</span>
</p>
<p v-if="safeDate()" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:calendar" :class="['size-4', iconColor]" />
{{ safeDate() }}

View File

@ -31,7 +31,6 @@ const props = defineProps<{
document: EntrelineaDoc
collection?: string
highlightedText?: string | null
noTrackVisit?: boolean
}>()
const emits = defineEmits<{ close: [] }>()
@ -105,7 +104,7 @@ watch(
() => [props.collection, props.document?.id] as const,
([collection, id]) => {
if (!collection || !id) return
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
history.visit(collection, toSearchHit(props.document))
},
{ immediate: true }
)

View File

@ -1,106 +0,0 @@
<script setup lang="ts">
import { breakpointsTailwind } from '@vueuse/core'
interface Props {
activeCount?: number
title?: string
}
withDefaults(defineProps<Props>(), {
activeCount: 0,
title: 'search.filters'
})
/*
RESPONSIVE: un solo componente, dos renders
- Desktop (>lg): acordeón colapsable (#content dentro)
- Mobile (<lg): botón USlideover (#content dentro del slide)
El slot #content es el MISMO en ambos casos
(el input + botón para agregar bible_study)
*/
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
const isExpanded = ref(true)
const isSlideoverOpen = ref(false)
const { $i18n } = useNuxtApp()
const t = $i18n.t
</script>
<template>
<div class="border-b border-default">
<!-- DESKTOP (>lg): ACORDEÓN COLAPSABLE -->
<div v-if="!isMobile" class="select-none">
<button
class="w-full flex items-center justify-between px-4 sm:px-6 py-2 text-sm font-medium text-muted hover:text-foreground transition-colors"
@click="isExpanded = !isExpanded"
>
<span class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4" />
{{ t(title) }}
<UBadge v-if="activeCount > 0" size="sm" variant="solid" color="primary">
{{ activeCount }}
</UBadge>
</span>
<UIcon
:name="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-4 transition-transform"
/>
</button>
<div v-show="isExpanded" class="px-4 sm:px-6 pb-3">
<slot name="content" />
</div>
</div>
<!-- MOBILE (<lg): BOTÓN + SLIDEOVER -->
<div v-else class="px-4 sm:px-6 py-2">
<UButton
size="sm"
color="neutral"
variant="outline"
class="w-full justify-between"
@click="isSlideoverOpen = true"
>
<span class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4" />
{{ t(title) }}
</span>
<span class="flex items-center gap-2">
<UBadge v-if="activeCount > 0" size="sm" variant="solid" color="primary">
{{ activeCount }}
</UBadge>
<UIcon name="i-lucide-chevron-right" class="size-4" />
</span>
</UButton>
<ClientOnly>
<USlideover v-model:open="isSlideoverOpen">
<template #content>
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-5" />
{{ t(title) }}
</h3>
<UButton
icon="i-lucide-x"
size="sm"
color="neutral"
variant="ghost"
@click="isSlideoverOpen = false"
/>
</div>
<slot name="content" />
<div v-if="$slots.chips" class="pt-2">
<slot name="chips" />
</div>
</div>
</template>
</USlideover>
</ClientOnly>
</div>
</div>
</template>

View File

@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind } from '@vueuse/core'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import PublicationDetail from '~/components/PublicationDetail.vue'
import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue'
import { useSettingsStore } from '~/stores/settings'
interface Props {
@ -15,14 +14,10 @@ interface Props {
accentColor: 'green' | 'blue'
emptyDetailText: string
showDraft?: boolean
author?: string
showBibleStudyFilter?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showDraft: false,
author: '',
showBibleStudyFilter: false
showDraft: false
})
const QUERY_BY = 'text'
@ -31,122 +26,46 @@ const { $i18n } = useNuxtApp()
const t = $i18n.t
const { locale } = useI18n()
const filterBy = computed(() => `locale:=${locale.value}`)
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
const { unlocked } = useDevMode()
const toast = useToast()
const typesenseClient = 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}`
if (activeBibleStudies.value.length > 0) {
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
base += ` && $${props.mainCollection}(bible_study:=[${ids}])`
}
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
})
function refetchResults() {
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(1, false)
} else {
groupedHits.value = []
runSearch(query.value, 1, false)
}
}
async function applyBibleStudyFilter() {
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
}
isValidating.value = true
try {
const res = await typesenseClient.multiSearch.perform({
searches: [{
collection: props.mainCollection,
q: '*',
query_by: 'title',
filter_by: `bible_study:=${val}`,
per_page: 1,
include_fields: 'bible_study,title',
use_cache: true,
cache_ttl: 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()
}
const query = ref(q0)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
// ---- Types ----------------------------------------------------------------
interface DocumentDoc extends CachedDocMeta {
interface ParagraphDoc {
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
locale: string
files?: {
@ -160,6 +79,47 @@ interface DocumentDoc extends CachedDocMeta {
[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 ----------------------------------------------------------------
const colors = computed(() => {
@ -177,7 +137,256 @@ const colors = computed(() => {
}
})
// ---- Scroll infinito y detalle ---------------------------------------------
// ---- State ----------------------------------------------------------------
const exactSearch = ref(false)
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()
// ---- 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(',')}]`,
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 multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
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,
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)`
}]
}
})
console.log('Browse result', multi)
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)
@ -187,7 +396,7 @@ function onListScroll() {
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(1, true)
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(browsePage.value, true)
} else {
if (hasMoreVisible.value) visibleGroupCount.value += 10
else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
@ -195,55 +404,77 @@ 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)
})
// ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null)
const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseGroupedParagraphHit[]>([])
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseGroupedParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseGroupedParagraphHit[]>([])
let detailSeq = 0
let detailController: AbortController | null = null
onBeforeUnmount(() => { detailController?.abort() })
const { fetchDocumentDetail } = useDocumentDetailFetch()
const selectedHit = ref<TypesenseParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
async function fetchDocumentWithParagraphs(docId: string) {
const seq = ++detailSeq
detailController?.abort()
const controller = new AbortController()
detailController = controller
documentLoading.value = true
paragraphsLoading.value = true
selectedDocument.value = null
selectedParagraphs.value = []
try {
const detail = await fetchDocumentDetail(typesenseClient, props.mainCollection, props.paragraphsCollection, docId, controller.signal)
if (seq !== detailSeq) return
if (detail) {
const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
selectedDocument.value = detail.document as unknown as DocumentDoc
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.mainCollection,
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 rawParagraphs = (docRaw[props.paragraphsCollection] as ParagraphDoc[] | undefined) ?? []
delete docRaw[props.paragraphsCollection]
selectedDocument.value = docRaw as unknown as DocumentDoc
selectedParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
if (seq !== detailSeq) return
if ((err as { name?: string })?.name === 'AbortError') return
console.error('Error fetching document with paragraphs', err)
selectedDocument.value = null
selectedParagraphs.value = []
} finally {
if (seq === detailSeq) {
documentLoading.value = false
paragraphsLoading.value = false
}
}
}
async function selectGroup(group: DisplayGroup) {
selectedDocId.value = group.docId
@ -303,7 +534,7 @@ useDetailHistory(isPanelOpen, isMobile)
// ---- Helpers de presentación ----------------------------------------------
function highlightedFor(hit: TypesenseGroupedParagraphHit, field: string): string | null {
function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet
if (fromArr?.value) return fromArr.value
@ -313,14 +544,14 @@ function highlightedFor(hit: TypesenseGroupedParagraphHit, field: string): strin
return null
}
function metaDate(meta: CachedDocMeta | undefined): string {
function metaDate(meta: DocMeta | undefined): string {
if (!meta) return ''
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
if (!ts) return meta.date || ''
return formatDate(ts)
}
function metaLocation(meta: CachedDocMeta | undefined): string {
function metaLocation(meta: DocMeta | undefined): string {
if (!meta) return ''
return formatLocation({
id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
@ -351,15 +582,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
</template>
</UDashboardNavbar>
<div
v-if="author"
class="px-4 sm:px-6 py-2 border-b border-default flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="ph:user-circle" :class="['size-3.5 shrink-0', colors.icon]" />
<span class="italic">{{ author }}</span>
</div>
<!-- BUSCADOR -->
<div class="px-4 sm:px-6 py-3 border-b border-default flex items-center gap-2" id="inputField">
<UInput
v-model="query"
@ -386,117 +608,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
</div>
</div>
<!-- CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) -->
<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">
<UBadge
v-for="bs in activeBibleStudies"
:key="bs.id"
size="sm"
variant="subtle"
color="primary"
class="max-w-full"
>
<span class="truncate">{{ $t('search.bible_study_chip', { number: bs.id }) }}</span>
<UButton
icon="i-lucide-x"
size="2xs"
color="neutral"
variant="ghost"
class="ml-1 shrink-0"
@click="removeBibleStudyFilter(bs.id)"
/>
</UBadge>
<UButton
size="2xs"
color="neutral"
variant="ghost"
class="text-xs"
@click="clearAllBibleStudyFilters"
>
{{ $t('search.bible_study_clear') }}
</UButton>
</div>
</div>
<!-- SORT -->
<div v-if="query.trim()" class="px-4 sm:px-6 py-3 flex items-center gap-2">
<p class="text-sm">Busqueda: </p>
<USelect
v-model="sortMode"
:items="[
{ label: t('search.sort.relevance'), value: 'relevance' },
{ label: t('search.sort.date'), value: 'date' }
]"
size="sm"
class="shrink-0 min-w-[130px]"
/>
</div>
<!-- FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) -->
<template v-if="showBibleStudyFilter">
<FiltersContainer
:active-count="activeBibleStudies.length"
title="search.filters"
>
<template #chips>
<div class="flex flex-wrap items-center gap-1.5">
<UBadge
v-for="bs in activeBibleStudies"
:key="bs.id"
size="sm"
variant="subtle"
color="primary"
class="max-w-full"
>
<span class="truncate">{{ $t('search.bible_study_chip', { number: bs.id }) }}</span>
<UButton
icon="i-lucide-x"
size="2xs"
color="neutral"
variant="ghost"
class="ml-1 shrink-0"
@click="removeBibleStudyFilter(bs.id)"
/>
</UBadge>
<div v-if="activeBibleStudies.length > 0">
<UButton
size="sm"
color="neutral"
variant="ghost"
class="text-xs"
@click="clearAllBibleStudyFilters">
{{ $t('search.bible_study_clear') }}
</UButton>
</div>
</div>
</template>
<template #content>
<div class="flex items-center gap-2">
<UInput
v-model="bibleStudyInput"
type="number"
min="1"
:placeholder="$t('search.bible_study_placeholder')"
size="sm"
class="w-36"
@keyup.enter="applyBibleStudyFilter"
/>
<UButton
size="sm"
color="neutral"
variant="outline"
:loading="isValidating"
:disabled="bibleStudyInput === null || bibleStudyInput <= 0"
@click="applyBibleStudyFilter"
>
+ {{ $t('search.filter') }}
</UButton>
</div>
</template>
</FiltersContainer>
</template>
<UAlert
v-if="errorMsg"
:title="errorMsg"
@ -604,7 +715,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
:accent-color="accentColor"
:author="author"
@close="isPanelOpen = false"
/>
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
@ -629,7 +739,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
:accent-color="accentColor"
:author="author"
@close="isPanelOpen = false"
/>
</template>

View File

@ -1,56 +0,0 @@
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

@ -1,79 +0,0 @@
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(
typesenseClient: ReturnType<typeof useTypesenseClient>,
mainCollection: string,
paragraphsCollection: string,
docId: string,
signal?: AbortSignal
): Promise<DocumentDetailResult | null> {
const cached = getCached(mainCollection, docId)
if (cached) return cached
const res = await typesenseClient.multiSearch.perform({
searches: [{
collection: mainCollection,
q: '*',
query_by: 'title',
filter_by: `id:=${docId} && $${paragraphsCollection}(id: *)`,
include_fields: `*, $${paragraphsCollection}(*)`,
use_cache: true,
cache_ttl: 3600
}]
}, {}, { abortSignal: 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

@ -1,72 +0,0 @@
interface BibleStudyChip {
id: number
title: string
}
const activeBibleStudies = useState<BibleStudyChip[]>('bible-study-chips', () => [])
export function useFilters() {
const typesenseClient = useTypesenseClient()
const toast = useToast()
const bibleStudyInput = ref<number | null>(null)
const isValidating = ref(false)
const activeCount = computed(() => activeBibleStudies.value.length)
async function applyBibleStudyFilter(mainCollection: string) {
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
}
isValidating.value = true
try {
const res = await typesenseClient.multiSearch.perform({
searches: [{
collection: mainCollection,
q: '*',
query_by: 'title',
filter_by: `bible_study:=${val}`,
per_page: 1,
include_fields: 'bible_study,title',
use_cache: true,
cache_ttl: 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
} 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)
}
function clearAllBibleStudyFilters() {
activeBibleStudies.value = []
}
return {
activeBibleStudies,
activeCount,
bibleStudyInput,
isValidating,
applyBibleStudyFilter,
removeBibleStudyFilter,
clearAllBibleStudyFilters
}
}

View File

@ -49,15 +49,7 @@ export function usePublicationFetch() {
const detailParagraphs = ref<TypesenseParagraphHit[]>([])
const detailParagraphsLoading = ref(false)
const typesenseClient = 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'
}
const { documentsApi } = useTypesenseApi()
async function fetchDetail(hit: SearchHit, favoritesCollection: string) {
const config = COLLECTION_CONFIG[favoritesCollection]
@ -67,37 +59,42 @@ export function usePublicationFetch() {
detailParagraphs.value = []
return
}
const seq = ++fetchSeq
fetchController?.abort()
const controller = new AbortController()
fetchController = controller
detailDocumentLoading.value = true
detailParagraphsLoading.value = true
detailDocument.value = null
detailParagraphs.value = []
try {
const detail = await fetchDocumentDetail(typesenseClient, config.main, config.paragraphs, docId, controller.signal)
if (seq !== fetchSeq) return
if (detail) {
const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
detailDocument.value = detail.document as unknown as DocumentDoc
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: config.main,
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 rawParagraphs = (docRaw[config.paragraphs] as ParagraphDoc[] | undefined) ?? []
delete docRaw[config.paragraphs]
detailDocument.value = docRaw as unknown as DocumentDoc
detailParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
if (seq !== fetchSeq) return
if (isAbortError(err)) return
console.error('[usePublicationFetch] Error fetching publication detail', err)
detailDocument.value = null
detailParagraphs.value = []
} finally {
if (seq === fetchSeq) {
detailDocumentLoading.value = false
detailParagraphsLoading.value = false
}
}
}
function clearDetail() {
detailDocument.value = null

View File

@ -1,6 +0,0 @@
import type Client from 'typesense/Typesense/Client'
export function useTypesenseClient(): Client {
const nuxtApp = useNuxtApp()
return nuxtApp.$typesenseClient as Client
}

View File

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

View File

@ -22,9 +22,6 @@ const { total: favTotal } = storeToRefs(favorites)
const history = useHistoryStore()
const { total: histTotal } = storeToRefs(history)
const toCarpa = () => {
window.location.href = `https://carpa.com/${$i18n.locale.value}`;
}
const links = computed(() => {
const links = [
@ -32,7 +29,7 @@ const links = computed(() => {
id: 'bible-studies',
label: t('nav.bible_studies'),
icon: 'ph-books',
to: '/estudios',
to: '/estudios-biblicos',
onSelect: () => { open.value = false },
},
{
@ -121,9 +118,9 @@ const links = computed(() => {
class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white" :ui="{ footer: 'lg:border-t lg:border-default' }">
<template #header="{ collapsed }">
<div v-if="!collapsed" class="mt-2 flex justify-center">
<img v-on:click="toCarpa" src="/logo.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
<img src="/logo.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
</div>
<img v-if="collapsed" v-on:click="toCarpa" src="/logo_round.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
<img v-if="collapsed" src="/logo_round.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
</template>
<template #default="{ collapsed }">

View File

@ -8,6 +8,5 @@
nav-title-key="nav.conferences_ts"
accent-color="blue"
:empty-detail-text="$t('ui.empty_conferences')"
author="Dr. William Soto Santiago"
/>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { breakpointsTailwind } from '@vueuse/core'
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
import { useFavoritesStore } from '~/stores/favorites'
import { useSettingsStore } from '~/stores/settings'
@ -23,10 +23,20 @@ const filterBy = computed(() => {
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
})
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
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 {
id?: number
title?: string
@ -49,19 +59,139 @@ interface EntrelineaDoc {
[key: string]: unknown
}
const {
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
hits, total, activePage, totalPages, hasMore,
runSearch, loadMore, goToPage, retry
} = useFlatTypesenseSearch<EntrelineaDoc>({
interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
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,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: () => filterBy.value,
includeFields: INCLUDE_FIELDS,
pageSize: () => settings.pageSize,
paginationType: () => settings.paginationType,
initialQuery: q0,
initialPage: p0
filterBy: filterBy.value,
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
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)
@ -132,7 +262,7 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
})
}
function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): string | null {
function highlightedFor(hit: TypesenseHit, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet
if (fromArr?.value) return fromArr.value
@ -155,11 +285,6 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="total" variant="subtle" :ui="{
base: 'total-results'
}" />
</template>
</UDashboardNavbar>
<!-- Banner: se muestra cuando NO hay clave de desarrollador -->

View File

@ -9,7 +9,5 @@
accent-color="green"
:empty-detail-text="$t('ui.empty_bible_studies')"
:show-draft="true"
author="Dr. José Benjamín Pérez Matos"
:show-bible-study-filter="true"
/>
</template>

View File

@ -30,28 +30,14 @@ const toast = useToast()
const ENTRELINEAS_COLLECTION = 'entrelineas'
const { t } = useI18n()
const COLLECTION_LABELS: Record<string, string> = {
activities: 'Actividades',
conferences: 'Conferencias',
entrelineas: 'Entre Líneas'
}
function labelFor(c: string): string {
const labels: Record<string, string> = {
activities: t('nav.bible_studies_ts'),
'bible-studies-ts': t('nav.bible_studies_ts'),
conferences: t('nav.conferences_ts'),
'conferences-ts': t('nav.conferences_ts'),
entrelineas: t('nav.between_the_lines'),
}
return labels[c] || c.charAt(0).toUpperCase() + c.slice(1)
}
const COLLECTION_AUTHORS: Record<string, string> = {
'bible-studies-ts': 'Dr. José Benjamín Pérez Matos',
activities: 'Dr. José Benjamín Pérez Matos',
'conferences-ts': 'Dr. William Soto Santiago',
conferences: 'Dr. William Soto Santiago',
}
function authorFor(c: string): string {
return COLLECTION_AUTHORS[c] || ''
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1)
}
// Filtros: pestaña por colección o "todos".
@ -71,9 +57,9 @@ const tabs = computed(() => {
items.push({
value: c,
label: `${labelFor(c)} (${count})`,
icon: (c === 'activities' || c === 'bible-studies-ts')
icon: c === 'activities'
? 'i-lucide-calendar-days'
: (c === 'conferences' || c === 'conferences-ts')
: c === 'conferences'
? 'i-lucide-mic'
: c === 'entrelineas'
? 'i-lucide-book-open'
@ -106,7 +92,6 @@ const selected = ref<HistoryItem | null>(null)
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
const selectedAuthor = computed(() => authorFor(selectedCollection.value ?? ''))
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
@ -567,13 +552,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
<USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
<span class="truncate">{{ formatLocation(it.hit) }}</span>
<template v-if="authorFor(it.collection)">
<USeparator orientation="vertical" class="h-3 hidden sm:block" />
<span class="inline-flex items-center gap-1 italic truncate">
<UIcon name="ph:user-circle" class="size-3 shrink-0" />
{{ authorFor(it.collection) }}
</span>
</template>
</p>
</div>
</div>
@ -585,7 +563,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
v-if="selected && !isMobile && isEntrelinea"
:document="selectedEntrelineaDoc!"
:collection="selectedCollection"
no-track-visit
@close="selected = null"
/>
<!-- Resto (actividades, conferencias) detalle completo con párrafos. -->
@ -596,8 +573,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
:author="selectedAuthor"
no-track-visit
@close="selected = null"
/>
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
@ -616,7 +591,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
v-if="selected && isEntrelinea"
:document="selectedEntrelineaDoc!"
:collection="selectedCollection"
no-track-visit
@close="selected = null"
/>
<PublicationDetail
@ -626,8 +600,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
:author="selectedAuthor"
no-track-visit
@close="selected = null"
/>
</template>

View File

@ -18,7 +18,7 @@ const links = ref<ButtonProps[]>([
},
{
label: t('nav.bible_studies'),
to: `/${$i18n.locale.value}/estudios`,
to: `/${$i18n.locale.value}/estudios-biblicos`,
icon: 'ph-books',
color: 'primary'
},
@ -52,7 +52,7 @@ const links = ref<ButtonProps[]>([
<div>
<h1 class="text-3xl sm:text-4xl font-bold text-highlighted tracking-tight">
{{ $t('nav.search_title') }}
Buscador Carpa
</h1>
<p class="mt-3 text-base text-muted leading-relaxed">
{{ $t('home.instructions') }}

View File

@ -1,24 +0,0 @@
import Typesense from 'typesense'
export default defineNuxtPlugin({
name: 'typesense-client',
setup() {
const config = useRuntimeConfig()
const nodes = JSON.parse(config.public.typeSenseNodes as string)
const apiKey = config.public.typeSenseApiKey as string
const client = new Typesense.Client({
nodes,
apiKey,
numRetries: 3,
retryIntervalSeconds: 0.5,
healthcheckIntervalSeconds: 30
})
return {
provide: {
typesenseClient: client
}
}
}
})

View File

@ -118,11 +118,10 @@ export const useHistoryStore = defineStore('history', () => {
// entradas. Como `visit()` siempre añade al inicio, recortamos por el final.
const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next
items.value = trimmed
writeStorage(trimmed)
}
// Ú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.
// Red de seguridad: cualquier mutación directa de `items.value` se persiste.
if (typeof window !== 'undefined') {
watch(items, (next) => {
if (!hydrated) return

View File

@ -19,41 +19,14 @@ export interface Release {
}
export const releases: Release[] = [
{
version: '0.9',
date: '20 de julio, 2026',
title: 'Filtro por N° de Estudio',
changes: [
{ type: 'nuevo', text: 'Nuevo filtro por número de Estudio en la sección de Estudios: ingresá el número y se agrega como chip para buscar dentro de ese estudio' },
{ type: 'nuevo', text: 'Se pueden agregar varios estudios a la vez, cada uno como un chip individual' },
{ type: 'nuevo', text: 'Los chips con los filtros activos se muestran siempre visibles debajo de la barra de búsqueda' },
{ type: 'mejora', text: 'En escritorio los filtros se agrupan en una sección colapsable para no ocupar espacio innecesario' },
{ type: 'mejora', text: 'En móvil los filtros se configuran desde un panel deslizante para aprovechar mejor la pantalla' }
]
},
{
version: '0.8',
date: '5 de junio, 2026',
title: 'Autoría visible en Estudios Bíblicos, Conferencias e Historial',
changes: [
{ type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Estudios Bíblicos (Dr. José Benjamín Pérez Matos)' },
{ type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Conferencias (Dr. William Soto Santiago)' },
{ type: 'nuevo', text: 'Nombre del autor visible en el panel de detalle del documento para ambas secciones' },
{ type: 'nuevo', text: 'El historial muestra el nombre del autor en cada entrada de Estudios Bíblicos y Conferencias' },
{ type: 'nuevo', text: 'Se agregó al momento de hacer una búsqueda un desplegable con los resultados "Más recientes" y "Normal"' },
{ type: 'mejora', text: 'El panel de detalle abierto desde el historial también muestra el autor correspondiente' },
{ type: 'fix', text: 'Las colecciones en el historial ahora muestran "Estudios Bíblicos" y "Conferencias" en lugar de los identificadores internos' },
{ type: 'fix', text: 'Corrección de etiquetas en inglés para las pestañas del historial' }
]
},
{
version: '0.7',
date: '31 de mayo, 2026 11:50PM',
title: 'Tour y optimizaciones',
changes: [
{ type: 'nuevo', text: 'Agregado tour virtual con localización para explicar funcionamiento del buscador.'},
{ type: 'nuevo', text: '_Agregada página de inicio que muestra la versión más reciente del changelog.' },
{ type: 'mejora', text: 'Separación de changelog a un TS aparte, para utilizar en changelog y en el home sin duplicación de código.'},
{ type: 'nuevo', text: 'Agregado tour virtual con localizacion para explicar funcionamiento del buscador'},
{ type: 'nuevo', text: 'Agregada pagina de inicio que muestra la version mas reciente del changelog' },
{ type: 'mejora', text: 'Separacion de changelog a un TS aparte para utilizar en changelog y en el home sin duplicacion de codigo'},
{ type: 'nuevo', text: 'Finalizado flow de automatizacion entre backend y typesense'}
]
},

View File

@ -1,119 +0,0 @@
# 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

@ -1,25 +0,0 @@
module.exports = {
apps: [
{
// You can append the PM2_ENV dynamically in the script if you want unique names in the PM2 list,
// or rely on the directory path to keep them separate.
name: process.env.PM2_NAME || 'SearchTs',
// port: '3000',
// exec_mode: 'cluster',
// instances: 'max',
script: './.output/server/index.mjs',
// Triggered by 'staging' branch
env_staging: {
NODE_ENV: 'staging',
PORT: 3005
},
// Triggered by 'production' branch
env_production: {
NODE_ENV: 'production',
PORT: 3010
}
}
]
};

View File

@ -2,28 +2,19 @@
"name": "English",
"nav": {
"home": "Home",
"bible_studies": "Studies",
"bible_studies_ts": "Studies",
"conferences_ts": "Conferences",
"bible_studies": "Bible Studies",
"bible_studies_ts": "Bible Studies Typesense",
"conferences_ts": "Conferences Typesense",
"conferences": "Conferences",
"between_the_lines": "Between the Lines",
"my_list": "My List",
"history": "History",
"settings": "Settings",
"changelog": "What's New",
"search_title": "Search of La Gran Carpa Catedral"
"changelog": "What's New"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Most recent"
},
"word": "Word",
"phrase": "Phrase",
"bible_study_placeholder": "Study no...",
"bible_study_chip": "Study #{number} {title}",
"bible_study_clear": "Clear filters",
"filters": "Filters",
"placeholder": "Search for...",
"searching": "Searching...",
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",
@ -94,7 +85,7 @@
"ui": {
"copy": "Copy",
"draft": "Draft",
"empty_bible_studies": "Choose a Study to see the detail",
"empty_bible_studies": "Choose a Bible Study to see the detail",
"empty_conferences": "Choose a Conference to see the detail"
}
}

View File

@ -1,8 +1,8 @@
{
"nav": {
"home": "Inicio",
"bible_studies": "Estudios",
"bible_studies_ts": "Estudios",
"bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudios Bíblicos",
"conferences_ts": "Conferencias",
"conferences": "Conferencias",
"between_the_lines": "Entrelíneas",
@ -10,39 +10,32 @@
"history": "Historial",
"settings": "Configuración",
"changelog": "Novedades",
"tour": "Toma el tour",
"localeselector": "Selector de idioma",
"search_title": "Buscador de La Gran Carpa Catedral"
"tour": "Toma el tour"
},
"tour": {
"progress": "{current} de {total}",
"next": "Siguiente",
"prev": "Anterior",
"done": "Finalizar",
"bible_studies_description": "Realiza búsquedas en los Estudios predicados por el Dr. José Benjamín Pérez Matos",
"conferences_description": "Realiza búsquedas en las conferencias predicadas por el Dr. William Soto Santiago",
"betweenthelines_description": "Realiza búsquedas en las imágenes de entrelíneas de los estudios del Dr. José Benjamín Pérez Matos",
"favorites_description": "Listado de resultados guardados como favoritos para fácil acceso futuro",
"history_description": "Historial de los resultados de búsqueda que has visto",
"settings_description": "Configuración, cantidad de resultados por página, tipo de paginación, entre otros",
"changelog_description": "Bitácora de cambios realizados al sitio en orden cronológico",
"feedback_description": "¿Tienes alguna sugerencia o queja? Realízala aquí.",
"localeselector_description": "Cambia fácilmente el idioma de la página",
"index_changelog": "Panel de últimos cambios subidos",
"favorites_toggle": "Botón para guardar / quitar este documento de mis favoritos",
"collapse_sidebar_description": "Oculta o muestra la barra de navegación lateral para tener más espacio en pantalla",
"total_results_description": "Muestra la cantidad total de resultados encontrados para tu búsqueda",
"favorites_button": "Botón de favoritos"
"bible_studies_description": "Realiza busquedas en los estudios biblicos predicados por el Dr. José Benjamín Pérez",
"conferences_description": "Realiza busquedas en las conferencias predicadas por el Dr. William Soto Santiago",
"betweenthelines_description": "Realiza busquedas en las imagenes de entrelineas de los estudios del Dr. José Benjamín Perez",
"favorites_description": "Listado de resultados guardados como favoritos para facil acceso futuro",
"history_description": "Historial de los resultados de busqueda que has visto",
"settings_description": "Configuracion, cantidad de resultados por pagina, tipo de paginacion, entre otros",
"changelog_description": "Bitacora de cambios realizados al sitio en orden cronologico",
"feedback_description": "Tienes alguna sugerencia o queja? realizala aqui",
"localeselector_description": "Cambia facilmente el idioma de la pagina",
"index_changelog": "Panel de ultimos cambios subidos",
"favorites_toggle": "Boton para guardar / quitar este documento de mis favoritos"
},
"home": {
"instructions": "Bienvenidos, aquí podrán buscar, entre los Estudios de las escrituras, las conferencias y las entrelíneas que están disponibles en el material de archivo de La Gran Carpa Catedral."
"instructions": "Bienvenidos, aqui podran buscar entre los estudios biblicos, las conferencias y las entrelineas que estan disponibles en el material de archivo de La Gran Carpa Catedral."
},
"search": {
"placeholder": "Buscar...",
"searching": "Buscando...",
"tip": "Consejo: envuelve en \"comillas\" para frase exacta en ese orden.",
"collapse": "Colapsar menú lateral",
"total_results": "Total de resultados",
"publication": "Publicación",
"draft": "Borrador",
"country": "País",
@ -56,21 +49,13 @@
"hits_per_page": "aciertos por página",
"hits_retrieved_in": "aciertos logrados en",
"for": "Buscando",
"sort": {
"relevance": "Normal",
"date": "Más recientes"
},
"word": "Palabra",
"phrase": "Frase",
"words": "palabras",
"phrases": "frases",
"bible_study_placeholder": "N° de estudio...",
"bible_study_chip": "Estudio #{number} {title}",
"bible_study_clear": "Limpiar filtros",
"filters": "Filtros",
"words_tooltip": "Buscar por palabras",
"phrases_tooltip": "Buscar por frases",
"instructions": "Selecciona un resultado de búsqueda...",
"instructions": "Selecciona un resultado de busqueda...",
"tab1": {
"tab_title": "Actividades"
},
@ -83,7 +68,7 @@
"page_size_desc": "Cuántos resultados cargar en cada página o petición.",
"results": "resultados",
"pagination_title": "Tipo de paginación",
"pagination_desc": "¿Cómo quieres navegar entre los resultados?",
"pagination_desc": "Cómo quieres navegar entre los resultados.",
"infinite_scroll": "Scroll infinito",
"infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.",
"numbered": "Páginas numeradas",
@ -101,7 +86,7 @@
"downloads": {
"audio": "Audio",
"book": "Libro",
"simple": "Sencillo"
"simple": "Sencillo",
},
"feedback": {
"title": "Reportar un error",
@ -126,7 +111,7 @@
"ui": {
"copy": "Copiar",
"draft": "Borrador",
"empty_bible_studies": "Selecciona un Estudio para ver el detalle",
"empty_bible_studies": "Selecciona un Estudio Bíblico para ver el detalle",
"empty_conferences": "Selecciona una Conferencia para ver el detalle"
},
"seo": {

View File

@ -2,26 +2,17 @@
"name": "Français",
"nav": {
"home": "Commencer",
"bible_studies": "Études",
"bible_studies_ts": "Études",
"conferences_ts": "Conférences",
"bible_studies": "Études Bibliques",
"bible_studies_ts": "Études Bibliques Typesense",
"conferences_ts": "Conférences Typesense",
"conferences": "Conférences",
"between_the_lines": "Entre les lignes",
"my_list": "Ma liste",
"history": "Historique",
"settings": "Paramètres",
"changelog": "Nouveautés",
"search_title": "Buscador de La Gran Carpa Catedral"
"changelog": "Nouveautés"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Plus récents"
},
"bible_study_placeholder": "N° d'étude...",
"bible_study_chip": "Étude #{number} {title}",
"bible_study_clear": "Effacer les filtres",
"filters": "Filtres",
"word": "Mot",
"phrase": "Phrase",
"placeholder": "Rechercher des activités",

View File

@ -2,26 +2,17 @@
"name": "Português",
"nav": {
"home": "Inicio",
"bible_studies": "Estudios",
"bible_studies_ts": "Estudos",
"bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudos Bíblicos Typesense",
"conferences_ts": "Conferências Typesense",
"conferences": "Conferências",
"between_the_lines": "Entre as linhas",
"my_list": "Minha lista",
"history": "Registro",
"settings": "Configurações",
"changelog": "Novidades",
"search_title": "Buscador de La Gran Carpa Catedral"
"changelog": "Novidades"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Mais recentes"
},
"bible_study_placeholder": "N° do estudo...",
"bible_study_chip": "Estudo #{number} {title}",
"bible_study_clear": "Limpar filtros",
"filters": "Filtros",
"word": "Palavra",
"phrase": "Frase",
"placeholder": "Digite para pesquisar...",

View File

@ -1,6 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', '@nuxtjs/i18n', '@pinia/nuxt', 'nuxt-driver.js'],
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', '@nuxtjs/i18n', '@pinia/nuxt', '@sfxcode/nuxt-typesense','nuxt-driver.js'],
app: {
head: {
@ -28,9 +28,7 @@ export default defineNuxtConfig({
feedbackMaxPerSession: Number(process.env.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION) || 3,
feedbackCooldownSec: Number(process.env.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC) || 45,
feedbackMinSeconds: Number(process.env.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS) || 4,
entrelineasDevKey: process.env.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY || '',
typeSenseNodes: process.env.TYPESENSE_NODES || '[]',
typeSenseApiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || ''
entrelineasDevKey: process.env.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY || ''
}
},
@ -89,4 +87,10 @@ export default defineNuxtConfig({
optimizeTranslationDirective: false,
},
},
typesense: {
url: process.env.NUXT_PUBLIC_TYPESENSE_URL || 'https://searchts.carpa.com',
apiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || '',
clientMode: true
}
})

View File

@ -8,8 +8,7 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck",
"benchmark:search": "node --env-file=.env scripts/benchmark-search.mjs"
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@babel/runtime": "^7.29.2",
@ -19,6 +18,7 @@
"@nuxt/ui": "^4.7.0",
"@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.2",
"@sfxcode/nuxt-typesense": "^1.2.0",
"@tanstack/table-core": "^8.21.3",
"@unovis/ts": "^1.6.5",
"@unovis/vue": "^1.6.5",

View File

@ -29,6 +29,9 @@ importers:
'@pinia/nuxt':
specifier: ^0.11.2
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))
'@sfxcode/nuxt-typesense':
specifier: ^1.2.0
version: 1.2.0(magicast@0.5.2)
'@tanstack/table-core':
specifier: ^8.21.3
version: 8.21.3
@ -2057,6 +2060,9 @@ packages:
cpu: [x64]
os: [win32]
'@sfxcode/nuxt-typesense@1.2.0':
resolution: {integrity: sha512-5h/nc7AL4POo/RBG+M7zl2fcK1aMED+LWuP/Ob18g9rGZa4cgArLNtp+8GwYFzGIr7aZa6fH6KM+Uuu4kbXFjQ==}
'@simple-git/args-pathspec@1.0.3':
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
@ -8248,6 +8254,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.3':
optional: true
'@sfxcode/nuxt-typesense@1.2.0(magicast@0.5.2)':
dependencies:
'@nuxt/kit': 4.4.5(magicast@0.5.2)
consola: 3.4.2
defu: 6.1.7
transitivePeerDependencies:
- magicast
'@simple-git/args-pathspec@1.0.3': {}
'@simple-git/argv-parser@1.1.1':

View File

@ -1,202 +0,0 @@
#!/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)
})

View File

@ -1,8 +0,0 @@
export default defineEventHandler((event) => {
const url = getRequestURL(event)
if (url.pathname.includes('estudios-biblicos')) {
const newPath = url.pathname.replace('estudios-biblicos', 'estudios')
return sendRedirect(event, newPath + url.search, 301)
}
})