Compare commits
2 Commits
9079893371
...
4513f4d839
| Author | SHA1 | Date |
|---|---|---|
|
|
4513f4d839 | |
|
|
464b6d0358 |
|
|
@ -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,141 +31,74 @@ const { $i18n } = useNuxtApp()
|
||||||
const t = $i18n.t
|
const t = $i18n.t
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const filterBy = computed(() => {
|
|
||||||
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
|
|
||||||
})
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const { unlocked } = useDevMode()
|
const { unlocked } = useDevMode()
|
||||||
|
const toast = useToast()
|
||||||
|
const { documentsApi } = useTypesenseClient()
|
||||||
|
|
||||||
// ── Restaurar estado desde URL antes de crear los refs ─────────────────────
|
// ── Restaurar estado desde URL antes de crear los refs ─────────────────────
|
||||||
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
||||||
|
|
||||||
const query = ref(q0)
|
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
|
||||||
const debouncedQuery = useDebounce(query, 150)
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMore = ref(false)
|
|
||||||
const errorMsg = ref<string | null>(null)
|
|
||||||
|
|
||||||
// ---- Types ----------------------------------------------------------------
|
interface BibleStudyChip {
|
||||||
|
id: number
|
||||||
interface ParagraphDoc {
|
|
||||||
id?: string
|
|
||||||
document_id: string
|
|
||||||
text: string
|
|
||||||
number: number
|
|
||||||
locale: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocMeta {
|
|
||||||
id: string
|
|
||||||
title: 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 {
|
const bibleStudyInput = ref<number | null>(null)
|
||||||
code: string
|
const activeBibleStudies = ref<BibleStudyChip[]>([])
|
||||||
locale: string
|
const isValidating = ref(false)
|
||||||
files?: {
|
|
||||||
youtube?: string
|
async function applyBibleStudyFilter() {
|
||||||
video?: string
|
const val = bibleStudyInput.value
|
||||||
audio?: string
|
if (val === null || val <= 0) return
|
||||||
booklet?: string
|
if (activeBibleStudies.value.some(bs => bs.id === val)) {
|
||||||
simple?: string
|
toast.add({ title: 'Estudio ya agregado', description: `El estudio #${val} ya está en el filtro`, color: 'info' })
|
||||||
}
|
bibleStudyInput.value = null
|
||||||
body?: string
|
return
|
||||||
[key: string]: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TypesenseHighlight {
|
isValidating.value = true
|
||||||
field?: string
|
try {
|
||||||
snippet?: string
|
const res = await documentsApi.multiSearch({
|
||||||
value?: string
|
multiSearchParameters: {},
|
||||||
matched_tokens?: string[]
|
multiSearchSearchesParameter: {
|
||||||
}
|
searches: [{
|
||||||
|
collection: props.mainCollection,
|
||||||
interface TypesenseParagraphHit {
|
q: '*',
|
||||||
document: ParagraphDoc
|
queryBy: 'title',
|
||||||
highlights?: TypesenseHighlight[]
|
filterBy: `bible_study:=${val}`,
|
||||||
highlight?: Record<string, { snippet?: string, value?: string }>
|
perPage: 1,
|
||||||
text_match?: number
|
includeFields: 'bible_study,title'
|
||||||
}
|
}]
|
||||||
|
|
||||||
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(() => {
|
|
||||||
if (props.accentColor === 'green') {
|
|
||||||
return {
|
|
||||||
selectedItem: 'border-carpagreen bg-carpagreen/10',
|
|
||||||
hoverItem: 'border-gray-200 hover:border-carpagreen hover:bg-carpagreen/5',
|
|
||||||
icon: 'text-carpagreen',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
selectedItem: 'border-carpablue bg-carpablue/10',
|
|
||||||
hoverItem: 'border-gray-200 hover:border-carpablue hover:bg-carpablue/5',
|
|
||||||
icon: 'text-carpablue',
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- State ----------------------------------------------------------------
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exactSearch = ref(false)
|
function removeBibleStudyFilter(id: number) {
|
||||||
const sortMode = ref<'relevance' | 'date'>('relevance')
|
activeBibleStudies.value = activeBibleStudies.value.filter(bs => bs.id !== id)
|
||||||
|
refetchResults()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
|
function clearAllBibleStudyFilters() {
|
||||||
|
activeBibleStudies.value = []
|
||||||
const {
|
refetchResults()
|
||||||
activeBibleStudies,
|
}
|
||||||
activeCount,
|
|
||||||
bibleStudyInput,
|
|
||||||
isValidating,
|
|
||||||
applyBibleStudyFilter,
|
|
||||||
removeBibleStudyFilter,
|
|
||||||
clearAllBibleStudyFilters,
|
|
||||||
} = useFilters()
|
|
||||||
|
|
||||||
function refetchResults() {
|
function refetchResults() {
|
||||||
if (!debouncedQuery.value.trim()) {
|
if (!debouncedQuery.value.trim()) {
|
||||||
|
|
@ -178,10 +111,6 @@ function refetchResults() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(activeBibleStudies, () => {
|
|
||||||
refetchResults()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
const groupedHits = ref<SearchGroup[]>([])
|
const groupedHits = ref<SearchGroup[]>([])
|
||||||
|
|
@ -442,7 +371,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,87 +379,59 @@ function onListScroll() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
function isAbortError(err: unknown): boolean {
|
||||||
if (!query.value.trim()) runBrowse(activePage.value, false)
|
return (err as { name?: string } | null)?.name === 'AbortError'
|
||||||
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 (isAbortError(err)) 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) {
|
||||||
selectedDocId.value = group.docId
|
selectedDocId.value = group.docId
|
||||||
|
|
@ -590,7 +491,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 +501,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 ?? '',
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
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}(*)`
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}, { 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 }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
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
|
||||||
|
/** 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() {
|
||||||
|
return options.isUnlocked()
|
||||||
|
? options.filterBy()
|
||||||
|
: `${options.filterBy()} && private:=false`
|
||||||
|
}
|
||||||
|
|
||||||
|
const runner = createAbortableRunner()
|
||||||
|
|
||||||
|
async function runSearch(q: string, page = 1, append = false) {
|
||||||
|
const { seq, signal } = runner.start(() => {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
if (append) loadingMore.value = true
|
||||||
|
else loading.value = true
|
||||||
|
errorMsg.value = null
|
||||||
|
|
||||||
|
const isInfinite = options.paginationType() === 'infinite_scroll'
|
||||||
|
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shouldSortByDate = sortMode.value === 'date' && q.trim()
|
||||||
|
|
||||||
|
const multi = await documentsApi.multiSearch({
|
||||||
|
multiSearchParameters: {},
|
||||||
|
multiSearchSearchesParameter: {
|
||||||
|
searches: [{
|
||||||
|
collection: options.paragraphsCollection,
|
||||||
|
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
||||||
|
queryBy: options.queryBy,
|
||||||
|
filterBy: searchFilterBy(),
|
||||||
|
...(shouldSortByDate ? { sortBy: `$${options.mainCollection}(timestamp:desc)` } : {}),
|
||||||
|
perPage: options.pageSize(),
|
||||||
|
page: typePage,
|
||||||
|
highlightFullFields: options.queryBy,
|
||||||
|
highlightFields: options.queryBy,
|
||||||
|
highlightStartTag: '<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 sí 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue