diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 738e134..67e1d1b 100755 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -75,3 +75,20 @@ mark.search-match.is-current { 70% { box-shadow: 0 0 0 14px rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; } 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; } } + +/* Span de coincidencia en la vista de detalle de estudios Typesense. + Marca el párrafo que el usuario clickeó desde los resultados de búsqueda. */ +#bible-study-search-match { + background-color: #fdff32; + color: #000; + padding: 2px; + border-radius: 2px; + font-weight: 600; + box-shadow: 0 0 0 1px #e9ff32 inset; +} + +.dark #bible-study-search-match { + background-color: #fdff32; + color: #000; + box-shadow: 0 0 0 1px #e9ff32 inset; +} diff --git a/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue b/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue index 5060fde..04d7884 100644 --- a/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue +++ b/app/components/estudiosTypensense/EstudiosTypensenseDetail.vue @@ -58,6 +58,7 @@ const props = defineProps<{ paragraphsLoading: boolean collection: string query?: string + selectedHit?: TypesenseParagraphHit | null }>() const emits = defineEmits(['close']) @@ -154,21 +155,8 @@ function safeLocation(): string { }) } -// ---- Snippet resaltado por Typesense -------------------------------------- +// ---- Utilerías de highlight ------------------------------------------------ -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 - const fromObj = hit.highlight?.[field] - if (fromObj?.snippet) return fromObj.snippet - if (fromObj?.value) return fromObj.value - return null -} - -// Para análisis de fragmentos preferimos `value` (campo completo con marks) -// en lugar del snippet truncado, para no perder grupos de marks adyacentes -// que el snippet podría partir. function fullHighlightedFor(hit: TypesenseParagraphHit, field: string): string | null { const fromArr = hit.highlights?.find(h => h.field === field) if (fromArr?.value) return fromArr.value @@ -179,9 +167,6 @@ function fullHighlightedFor(hit: TypesenseParagraphHit, field: string): string | return null } -// Decodifica entidades HTML usando el parser nativo del navegador, sin -// depender de mantener un mapa propio. Evita falsos negativos cuando el -// fragmento extraído lleva entidades pero el body ya está renderizado. function decodeEntities(s: string): string { if (!s || s.indexOf('&') === -1) return s if (typeof document === 'undefined') return s @@ -190,12 +175,6 @@ function decodeEntities(s: string): string { return tmp.textContent || s } -// Recorre los del HTML resaltado por Typesense -// y devuelve los fragmentos contiguos (marks separadas solo por whitespace -// se unen como una frase; cualquier texto no-blanco entre marks rompe el -// grupo). Esto reproduce la noción de "phrase hit" del usuario: -// "programa divino" → ["programa divino"] -// "programa de Dios divino" → ["programa", "divino"] function extractMarkFragments(html: string): string[] { if (!html) return [] const markRe = /]*>([\s\S]*?)<\/mark>/gi @@ -221,55 +200,113 @@ function extractMarkFragments(html: string): string[] { return groups.map(g => g.join(' ').trim()).filter(Boolean) } -// Construye la query inicial del find-in-document a partir de los párrafos -// que devolvió Typesense, para que el resaltado del body coincida con lo -// que el motor realmente matcheó: -// - 1 fragmento único en todos los hits → frase completa (ej. "programa divino"). -// - varios fragmentos distintos → cada uno como frase entre comillas, OR'd. -// Si no hay párrafos (p. ej. usuario sin query), cae al stripOuterQuotes(props.query). -function computeInitialQuery(paragraphs: TypesenseParagraphHit[] | undefined): string { - const fallback = stripOuterQuotes(props.query || '') - if (!paragraphs?.length) return fallback - const seen = new Set() - const fragments: string[] = [] - for (const hit of paragraphs) { - const html = fullHighlightedFor(hit, 'text') - if (!html) continue - for (const f of extractMarkFragments(html)) { - const key = normalize(f) - if (!key || seen.has(key)) continue - seen.add(key) - fragments.push(f) - } - } - if (!fragments.length) return fallback - if (fragments.length === 1) return fragments[0] - // Frases más largas primero — al construir el regex en findMatchesInText, - // la alternancia se evalúa de izquierda a derecha y queremos que - // "programa divino" gane sobre "programa" cuando ambos están presentes. - fragments.sort((a, b) => b.length - a.length) - return fragments.map(f => `"${f}"`).join(' ') +function normalize(s: string): string { + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() } -// ---- Find-in-document (igual que InboxActivity) --------------------------- +// ---- Renderizado de párrafos y scroll a coincidencia ----------------------- -const bodyContainer = ref(null) +const paragraphsContainer = ref(null) const scrollContainer = ref(null) -function stripOuterQuotes(s: string): string { - return s.trim().replace(/^"+|"+$/g, '').trim() +function getHighlightText(): string | null { + if (!props.selectedHit) return null + const html = fullHighlightedFor(props.selectedHit, 'text') + if (!html) return null + const fragments = extractMarkFragments(html) + if (!fragments.length) return null + fragments.sort((a, b) => b.length - a.length) + return fragments[0] } -const localQuery = ref(stripOuterQuotes(props.query || '')) +async function highlightAndScroll() { + await nextTick() + + const container = paragraphsContainer.value + if (!container) return + + container.querySelectorAll('#bible-study-search-match').forEach(el => { + const parent = el.parentNode + if (parent) { + parent.replaceChild(document.createTextNode(el.textContent || ''), el) + parent.normalize() + } + }) + + const matchText = getHighlightText() + const matchNumber = props.selectedHit?.document?.number + if (!matchText || matchNumber === undefined) return + + const paraEl = container.querySelector(`[data-paragraph-number="${matchNumber}"]`) + if (!paraEl) return + + const walker = document.createTreeWalker(paraEl, NodeFilter.SHOW_TEXT, null) + const textNodes: Text[] = [] + let n: Node | null + while ((n = walker.nextNode())) { + textNodes.push(n as Text) + } + + let flatText = '' + const nodeMap: { node: Text, start: number, end: number }[] = [] + for (const node of textNodes) { + const t = node.nodeValue || '' + nodeMap.push({ node, start: flatText.length, end: flatText.length + t.length }) + flatText += t + } + + const normFlat = normalize(flatText) + const normMatch = normalize(matchText) + const startIdx = normFlat.indexOf(normMatch) + if (startIdx === -1) return + const endIdx = startIdx + normMatch.length + + let applied = false + for (let i = nodeMap.length - 1; i >= 0; i--) { + const info = nodeMap[i] + const segStart = Math.max(startIdx, info.start) - info.start + const segEnd = Math.min(endIdx, info.end) - info.start + if (segStart >= segEnd) continue + + const nodeText = info.node.nodeValue || '' + const frag = document.createDocumentFragment() + let cursor = 0 + + if (segStart > cursor) { + frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart))) + } + + const span = document.createElement('span') + span.id = 'bible-study-search-match' + span.textContent = nodeText.slice(segStart, segEnd) + frag.appendChild(span) + cursor = segEnd + + if (cursor < nodeText.length) { + frag.appendChild(document.createTextNode(nodeText.slice(cursor))) + } + + info.node.parentNode?.replaceChild(frag, info.node) + applied = true + } + + if (applied) { + await nextTick() + const matchEl = document.getElementById('bible-study-search-match') + if (matchEl) { + matchEl.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + } +} + +// ---- Find-in-document ---------------------------------------------------- + +const localQuery = ref((props.query || '').replace(/^"+|"+$/g, '').trim()) const debouncedLocalQuery = useDebounce(localQuery, 200) const currentIdx = ref(0) const totalMatches = ref(0) -// Reset de contadores al cambiar de documento. localQuery se gestiona en el -// watcher de párrafos de abajo — necesitamos que el reset y el refinamiento -// vivan en un solo sitio para no pisarnos según el orden en que resuelvan -// las dos llamadas (fetchFullDocument vs fetchDocumentParagraphs). watch( () => props.document?.id, () => { @@ -278,30 +315,6 @@ watch( } ) -// Cuando llegan los párrafos resaltados por Typesense (o cambian al re-buscar), -// recalcular localQuery a partir de los fragmentos contiguos de marks. Así el -// find-in-document busca exactamente lo que Typesense matcheó: un solo -// fragmento adyacente → frase completa; varios fragmentos → cada uno como -// frase OR'd. { immediate: true } cubre el primer render del componente. -watch( - () => props.paragraphs, - (paragraphs) => { - const next = computeInitialQuery(paragraphs) - if (next !== localQuery.value) localQuery.value = next - }, - { immediate: true } -) - -// Sincroniza localQuery cuando el padre cambia su query mientras el detalle -// sigue abierto y aún NO ha llegado un nuevo lote de párrafos para refinar. -// Si los párrafos llegan después, el watcher de arriba lo afina. -watch(() => props.query, (q) => { - const stripped = stripOuterQuotes(q || '') - if (stripped !== localQuery.value && !localQuery.value.includes('"')) { - localQuery.value = stripped - } -}) - function escapeRegex(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -319,10 +332,6 @@ function normalizeWithMap(text: string): { normalized: string, map: number[] } { return { normalized, map } } -function normalize(s: string): string { - return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() -} - function termToRegexSource(term: string): string { const parts = term.split(/\s+/).filter(Boolean) if (parts.length === 0) return '' @@ -332,9 +341,6 @@ function termToRegexSource(term: string): string { function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> { if (!text || !terms.length) return [] - // Ordenar por longitud desc para que en alternancia regex `(a|ab)`, la - // frase más larga gane sobre el prefijo. Por ej., `["programa", "programa divino"]` - // debe matchear "programa divino" como un solo hit, no como "programa" + sobra. const sources = terms .map(t => ({ term: t, src: termToRegexSource(normalize(t)) })) .filter(x => x.src.length > 0) @@ -369,16 +375,8 @@ function parseTerms(query: string): string[] { function termsFor(query: string): string[] { if (!query) return [] - // Si la query trae comillas explícitas (caso típico cuando la inicializamos - // desde los fragmentos de Typesense, ej. `"programa" "divino"` o - // `"programa divino"`), parseTerms las separa correctamente en frases. if (query.includes('"')) return parseTerms(query) - // Si la query coincide con la del padre (sin comillas), tokenizamos como - // hasta ahora — cada palabra suelta cuenta como término independiente. - if (query === (props.query || '')) return parseTerms(query) - // Query escrita por el usuario en el input local (distinta del padre y - // sin comillas) → tratarla como FRASE EXACTA, igual que antes. - const stripped = stripOuterQuotes(query) + const stripped = query.replace(/^"+|"+$/g, '').trim() return stripped ? [stripped] : [] } @@ -432,14 +430,14 @@ function highlightTextNodes(root: HTMLElement, terms: string[]): number { } function getMarks(): HTMLElement[] { - return bodyContainer.value - ? Array.from(bodyContainer.value.querySelectorAll('mark.search-match')) as HTMLElement[] + return paragraphsContainer.value + ? Array.from(paragraphsContainer.value.querySelectorAll('mark.search-match')) as HTMLElement[] : [] } function getMatchStarts(): HTMLElement[] { - return bodyContainer.value - ? Array.from(bodyContainer.value.querySelectorAll('mark.search-match.match-start')) as HTMLElement[] + return paragraphsContainer.value + ? Array.from(paragraphsContainer.value.querySelectorAll('mark.search-match.match-start')) as HTMLElement[] : [] } @@ -451,7 +449,6 @@ function applyCurrent(scroll = true) { const target = starts[idx] if (!target) return target.classList.remove('is-current') - // eslint-disable-next-line @typescript-eslint/no-unused-expressions void target.offsetWidth target.classList.add('is-current') if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' }) @@ -472,7 +469,7 @@ function prevMatch() { function clearLocalQuery() { localQuery.value = '' } function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) { - const el = bodyContainer.value + const el = paragraphsContainer.value if (!el) return el.querySelectorAll('mark.search-match').forEach((m) => { m.parentNode?.replaceChild(document.createTextNode(m.textContent || ''), m) @@ -485,18 +482,7 @@ function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) if (count > 0) nextTick(() => applyCurrent(scroll)) } -async function renderBody() { - await nextTick() - const el = bodyContainer.value - if (!el) return - el.innerHTML = props.document?.body ? ((fixLink(props.document.body) as string) || '') : '' - if (scrollContainer.value) scrollContainer.value.scrollTop = 0 - applyHighlights({ query: localQuery.value, scroll: true }) -} - -watch(() => props.document?.id, renderBody, { immediate: true }) watch(debouncedLocalQuery, (q) => applyHighlights({ query: q, scroll: false })) -onMounted(renderBody) function onInputKey(e: KeyboardEvent) { if (e.key === 'Enter') { @@ -507,6 +493,22 @@ function onInputKey(e: KeyboardEvent) { clearLocalQuery() } } + +watch( + () => props.paragraphs, + async (paragraphs) => { + console.log('[Typesense paragraphs]', paragraphs) + if (paragraphs.length) { + await highlightAndScroll() + } + } +) + +onMounted(async () => { + if (props.paragraphs.length) { + await highlightAndScroll() + } +}) diff --git a/app/pages/estudios-typensense.vue b/app/pages/estudios-typensense.vue index 4215c36..505eeac 100644 --- a/app/pages/estudios-typensense.vue +++ b/app/pages/estudios-typensense.vue @@ -237,6 +237,9 @@ const documentLoading = ref(false) const selectedParagraphs = ref([]) const paragraphsLoading = ref(false) +// Hit original del grupo seleccionado (contiene highlights de Typesense) +const selectedHit = ref(null) + async function fetchFullDocument(docId: string) { documentLoading.value = true selectedDocument.value = null @@ -264,29 +267,36 @@ async function fetchFullDocument(docId: string) { } } -async function fetchDocumentParagraphs(docId: string, q: string) { +async function fetchDocumentParagraphs(docId: string) { paragraphsLoading.value = true selectedParagraphs.value = [] + const PER_PAGE = 250 + let page = 1 + let total = 0 + const all: Array<{ document: ParagraphDoc }> = [] try { - const res = await documentsApi.multiSearch({ - multiSearchParameters: {}, - multiSearchSearchesParameter: { - searches: [{ - collection: PARAGRAPHS_COLLECTION, - q: q || '*', - queryBy: QUERY_BY, - filterBy: `document_id:=${docId} && ${filterBy.value}`, - perPage: 250, - page: 1, - sortBy: 'number:asc', - highlightFullFields: QUERY_BY, - highlightFields: QUERY_BY, - highlightStartTag: '', - highlightEndTag: '' - }] - } - }) - selectedParagraphs.value = (res?.results?.[0] as { hits?: TypesenseParagraphHit[] })?.hits ?? [] + do { + const res = await documentsApi.multiSearch({ + multiSearchParameters: {}, + multiSearchSearchesParameter: { + searches: [{ + collection: PARAGRAPHS_COLLECTION, + q: '*', + queryBy: '', + filterBy: `document_id:=${docId} && ${filterBy.value}`, + perPage: PER_PAGE, + page, + sortBy: 'number:asc' + }] + } + }) + const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined) + if (!result) break + if (page === 1) total = result.found ?? 0 + all.push(...(result.hits ?? [])) + page++ + } while (all.length < total) + selectedParagraphs.value = all.map(h => ({ document: h.document })) } catch (err) { console.error('Error fetching paragraphs', err) selectedParagraphs.value = [] @@ -295,15 +305,11 @@ async function fetchDocumentParagraphs(docId: string, q: string) { } } -async function selectGroup(docId: string) { - selectedDocId.value = docId - fetchFullDocument(docId) - if (query.value.trim()) { - fetchDocumentParagraphs(docId, query.value) - } else { - selectedParagraphs.value = [] - paragraphsLoading.value = false - } +async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) { + selectedDocId.value = group.docId + selectedHit.value = group.firstHit + fetchFullDocument(group.docId) + fetchDocumentParagraphs(group.docId) } const isPanelOpen = computed({ @@ -313,6 +319,7 @@ const isPanelOpen = computed({ selectedDocId.value = null selectedDocument.value = null selectedParagraphs.value = [] + selectedHit.value = null } } }) @@ -324,6 +331,7 @@ watch(groupedHits, () => { selectedDocId.value = null selectedDocument.value = null selectedParagraphs.value = [] + selectedHit.value = null } }) @@ -435,7 +443,7 @@ function metaLocation(meta: DocMeta | undefined): string { ? 'border-primary bg-primary/10' : 'border-transparent hover:border-primary hover:bg-primary/5' ]" - @click="selectGroup(group.docId)" + @click="selectGroup(group)" >
@@ -502,6 +510,7 @@ function metaLocation(meta: DocMeta | undefined): string { :paragraphs-loading="paragraphsLoading" :collection="FAVORITES_COLLECTION" :query="debouncedQuery" + :selected-hit="selectedHit" @close="isPanelOpen = false" />