From ab2510ba1c2449b9a41d0c40d97264b91f8051d0 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Mon, 9 Mar 2026 17:03:47 -0600 Subject: [PATCH 1/6] =?UTF-8?q?Integrada=20la=20b=C3=BAsqueda=20de=20bibli?= =?UTF-8?q?ograf=C3=ADa=20ahora=20tambi=C3=A9n=20con=20Open=20Library=20y?= =?UTF-8?q?=20permitiendo=20obtener=20resultados=20de=20un=20idioma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se actualizó el contrato de búsqueda para enviar términos y parámetros por endpoint (Google y Open Library), y se consumió una respuesta unificada con origen por resultado. - Se reemplazó el control de cantidad por un selector de idioma, y se mapearon los códigos a ISO 639-1 (Google) e ISO 639-2 (Open Library). - Se forzó la obtención de resultados más recientes (orderBy="newest" y sort="new") y se ordenaron los resultados en frontend por año de publicación descendente, sin importar el endpoint. - Se etiquetó cada sugerencia con un badge de origen (Google u Open Library). --- src/data/api/subjects.api.ts | 27 +- .../nueva/NuevaBibliografiaModalContainer.tsx | 381 ++++++-- src/routeTree.gen.ts | 68 +- src/types/supabase.ts | 819 +++++++++--------- 4 files changed, 777 insertions(+), 518 deletions(-) diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 383e506..3fca3f0 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -42,8 +42,19 @@ const EDGE = { export type BuscarBibliografiaRequest = { searchTerms: { q: string - maxResults: number + } + + google: { orderBy?: 'newest' | 'relevance' + langRestrict?: string + startIndex?: number + [k: string]: unknown + } + + openLibrary: { + language?: string + page?: number + sort?: string [k: string]: unknown } } @@ -82,20 +93,22 @@ export type GoogleBooksVolume = { [k: string]: unknown } +export type OpenLibraryDoc = Record + +export type EndpointResult = + | { endpoint: 'google'; item: GoogleBooksVolume } + | { endpoint: 'open_library'; item: OpenLibraryDoc } + export async function buscar_bibliografia( input: BuscarBibliografiaRequest, -): Promise> { +): Promise> { const q = input.searchTerms.q - const maxResults = input.searchTerms.maxResults if (typeof q !== 'string' || q.trim().length < 1) { throw new Error('q es requerido') } - if (!Number.isInteger(maxResults) || maxResults < 0 || maxResults > 40) { - throw new Error('maxResults debe ser entero entre 0 y 40') - } - return await invokeEdge>( + return await invokeEdge>( EDGE.buscar_bibliografia, input, { headers: { 'Content-Type': 'application/json' } }, diff --git a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx index a4c2c75..0a21106 100644 --- a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx +++ b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx @@ -4,10 +4,15 @@ import { Globe, Loader2, Plus, RefreshCw, X } from 'lucide-react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { BuscarBibliografiaRequest } from '@/data' -import type { GoogleBooksVolume } from '@/data/api/subjects.api' +import type { + EndpointResult, + GoogleBooksVolume, + OpenLibraryDoc, +} from '@/data/api/subjects.api' import type { TablesInsert } from '@/types/supabase' import { defineStepper } from '@/components/stepper' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, @@ -41,6 +46,54 @@ import { cn } from '@/lib/utils' type MetodoBibliografia = 'MANUAL' | 'IA' | null export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago' +type IdiomaBibliografia = + | 'ALL' + | 'ES' + | 'EN' + | 'DE' + | 'ZH' + | 'FR' + | 'IT' + | 'JA' + | 'RU' + +const IDIOMA_LABEL: Record = { + ALL: 'Todos', + ES: 'Español', + EN: 'Inglés', + DE: 'Alemán', + ZH: 'Chino', + FR: 'Francés', + IT: 'Italiano', + JA: 'Japonés', + RU: 'Ruso', +} + +const IDIOMA_TO_GOOGLE: Record = { + ALL: undefined, + ES: 'es', + EN: 'en', + DE: 'de', + ZH: 'zh', + FR: 'fr', + IT: 'it', + JA: 'ja', + RU: 'ru', +} + +// ISO 639-2 (bibliographic codes) commonly used by Open Library. +const IDIOMA_TO_OPEN_LIBRARY: Record = { + ALL: undefined, + ES: 'spa', + EN: 'eng', + DE: 'ger', + ZH: 'chi', + FR: 'fre', + IT: 'ita', + JA: 'jpn', + RU: 'rus', +} + type CSLAuthor = { family: string given: string @@ -65,7 +118,7 @@ type BibliografiaTipoFuente = NonNullable< type BibliografiaRef = { id: string source: BibliografiaTipoFuente - raw?: GoogleBooksVolume + raw?: GoogleBooksVolume | OpenLibraryDoc title: string authors: Array publisher?: string @@ -79,12 +132,13 @@ type WizardState = { metodo: MetodoBibliografia ia: { q: string - cantidadDeSugerencias: number | null + idioma: IdiomaBibliografia showConservacionTooltip: boolean sugerencias: Array<{ id: string selected: boolean - volume: GoogleBooksVolume + endpoint: EndpointResult['endpoint'] + item: GoogleBooksVolume | OpenLibraryDoc }> isLoading: boolean errorMessage: string | null @@ -107,6 +161,13 @@ type WizardState = { errorMessage: string | null } +type IASugerencia = WizardState['ia']['sugerencias'][number] +function iaSugerenciaToEndpointResult(s: IASugerencia): EndpointResult { + return s.endpoint === 'google' + ? { endpoint: 'google', item: s.item as GoogleBooksVolume } + : { endpoint: 'open_library', item: s.item as OpenLibraryDoc } +} + const Wizard = defineStepper( { id: 'metodo', title: 'Método', description: 'Manual o Buscar en línea' }, { @@ -140,19 +201,99 @@ function tryParseYear(publishedDate?: string): number | undefined { return Number.isFinite(year) ? year : undefined } -function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef { - const info = volume.volumeInfo ?? {} - const title = (info.title ?? '').trim() || 'Sin título' - const authors = Array.isArray(info.authors) ? info.authors : [] - const publisher = info.publisher - const year = tryParseYear(info.publishedDate) - const isbn = - info.industryIdentifiers?.find((x) => x.identifier)?.identifier ?? undefined +function randomUUID(): string { + try { + const c = (globalThis as any).crypto + if (c && typeof c.randomUUID === 'function') return c.randomUUID() + } catch { + // ignore + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +function tryParseYearFromOpenLibrary(doc: OpenLibraryDoc): number | undefined { + const y1 = doc['first_publish_year'] + if (typeof y1 === 'number' && Number.isFinite(y1)) return y1 + + const years = doc['publish_year'] + if (Array.isArray(years)) { + const numeric = years + .map((x) => (typeof x === 'number' ? x : Number(x))) + .filter((n) => Number.isFinite(n)) + if (numeric.length > 0) return Math.max(...numeric) + } + + const published = doc['publish_date'] + if (typeof published === 'string') return tryParseYear(published) + return undefined +} + +function getEndpointResultId(result: EndpointResult): string { + if (result.endpoint === 'google') { + return `google:${result.item.id}` + } + + const doc = result.item + const key = doc['key'] + if (typeof key === 'string' && key.trim()) return `open_library:${key}` + + const cover = doc['cover_edition_key'] + if (typeof cover === 'string' && cover.trim()) return `open_library:${cover}` + + const editionKey = doc['edition_key'] + if (Array.isArray(editionKey) && typeof editionKey[0] === 'string') { + return `open_library:${editionKey[0]}` + } + + return `open_library:${randomUUID()}` +} + +function endpointResultToRef(result: EndpointResult): BibliografiaRef { + if (result.endpoint === 'google') { + const volume = result.item + const info = volume.volumeInfo ?? {} + const title = (info.title ?? '').trim() || 'Sin título' + const authors = Array.isArray(info.authors) ? info.authors : [] + const publisher = typeof info.publisher === 'string' ? info.publisher : undefined + const year = tryParseYear(info.publishedDate) + const isbn = + info.industryIdentifiers?.find((x) => x?.identifier)?.identifier ?? undefined + + return { + id: getEndpointResultId(result), + source: 'BIBLIOTECA', + raw: volume, + title, + authors, + publisher, + year, + isbn, + tipo: 'BASICA', + } + } + + const doc = result.item + const title = (typeof doc['title'] === 'string' ? doc['title'] : '').trim() || + 'Sin título' + const authors = Array.isArray(doc['author_name']) + ? (doc['author_name'] as Array).filter((a): a is string => typeof a === 'string') + : [] + const publisher = Array.isArray(doc['publisher']) + ? (doc['publisher'] as Array).find( + (p): p is string => typeof p === 'string', + ) + : typeof doc['publisher'] === 'string' + ? doc['publisher'] + : undefined + const year = tryParseYearFromOpenLibrary(doc) + const isbn = Array.isArray(doc['isbn']) + ? (doc['isbn'] as Array).find((x): x is string => typeof x === 'string') + : undefined return { - id: volume.id, - source: 'MANUAL', - raw: volume, + id: getEndpointResultId(result), + source: 'BIBLIOTECA', + raw: doc, title, authors, publisher, @@ -162,6 +303,23 @@ function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef { } } +function getResultYear(result: EndpointResult): number | undefined { + if (result.endpoint === 'google') { + const info = result.item.volumeInfo ?? {} + return tryParseYear(info.publishedDate) + } + return tryParseYearFromOpenLibrary(result.item) +} + +function sortResultsByMostRecent(a: EndpointResult, b: EndpointResult) { + const ya = getResultYear(a) + const yb = getResultYear(b) + if (typeof ya === 'number' && typeof yb === 'number') return yb - ya + if (typeof ya === 'number') return -1 + if (typeof yb === 'number') return 1 + return 0 +} + function AutoSizeTextarea({ value, disabled, @@ -294,7 +452,7 @@ export function NuevaBibliografiaModalContainer({ metodo: null, ia: { q: '', - cantidadDeSugerencias: 10, + idioma: 'ALL', showConservacionTooltip: false, sugerencias: [], isLoading: false, @@ -342,7 +500,7 @@ export function NuevaBibliografiaModalContainer({ wizard.metodo === 'IA' ? wizard.ia.sugerencias .filter((s) => s.selected) - .map((s) => volumeToRef(s.volume)) + .map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s))) : wizard.manual.refs // Mantener `wizard.refs` como snapshot para pasos 3/4. @@ -378,25 +536,8 @@ export function NuevaBibliografiaModalContainer({ async function handleBuscarSugerencias() { const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0 - const cantidad = wizard.ia.cantidadDeSugerencias - if ( - !Number.isFinite(cantidad ?? Number.NaN) || - (cantidad as number) < 1 || - (cantidad as number) > 40 - ) { - setWizard((w) => ({ - ...w, - ia: { - ...w.ia, - errorMessage: - 'La cantidad de sugerencias debe ser un entero entre 1 y 40 (o vacío).', - }, - errorMessage: null, - })) - return - } - - const selected = wizard.ia.sugerencias.filter((s) => s.selected) + const q = wizard.ia.q.trim() + if (!q) return setWizard((w) => ({ ...w, @@ -412,30 +553,58 @@ export function NuevaBibliografiaModalContainer({ })) try { - const selectedCount = selected.length + const idioma = wizard.ia.idioma + const googleLangRestrict = IDIOMA_TO_GOOGLE[idioma] + const openLibraryLanguage = IDIOMA_TO_OPEN_LIBRARY[idioma] + + const google: BuscarBibliografiaRequest['google'] = { + orderBy: 'newest', + startIndex: 0, + } + if (googleLangRestrict) google.langRestrict = googleLangRestrict + + const openLibrary: BuscarBibliografiaRequest['openLibrary'] = { + sort: 'new', + page: 1, + } + if (openLibraryLanguage) openLibrary.language = openLibraryLanguage + const req: BuscarBibliografiaRequest = { - searchTerms: { - q: wizard.ia.q, - maxResults: (cantidad as number) + selectedCount, - // orderBy: ignorado por ahora - }, + searchTerms: { q }, + google, + openLibrary, } - const items = await buscar_bibliografia(req) + const items = (await buscar_bibliografia(req)) + .slice() + .sort(sortResultsByMostRecent) setWizard((w) => { const existingById = new Map(w.ia.sugerencias.map((s) => [s.id, s])) const newOnes = items + .map((r) => ({ + id: getEndpointResultId(r), + selected: false, + endpoint: r.endpoint, + item: r.item, + })) .filter((it) => !existingById.has(it.id)) - .slice(0, cantidad as number) - .map((it) => ({ id: it.id, selected: false, volume: it })) + + const merged = [...w.ia.sugerencias, ...newOnes].slice() + merged.sort( + (a, b) => + sortResultsByMostRecent( + iaSugerenciaToEndpointResult(a), + iaSugerenciaToEndpointResult(b), + ) || a.id.localeCompare(b.id), + ) return { ...w, ia: { ...w.ia, - sugerencias: [...w.ia.sugerencias, ...newOnes], + sugerencias: merged, showConservacionTooltip: hadNoSugerenciasBefore && newOnes.length > 0, isLoading: false, @@ -506,20 +675,22 @@ export function NuevaBibliografiaModalContainer({ const engine = new CSL.Engine(sys as any, xmlStyle) engine.updateItems(Object.keys(cslItems)) const result = engine.makeBibliography() - + // result[0] contiene los metadatos, result[1] las citas formateadas - const meta = result?.[0] as { entry_ids?: string[][] } | undefined + const meta = result?.[0] as + | { entry_ids?: Array> } + | undefined const entries = (result?.[1] ?? []) as Array const citations: Record = {} - + // meta.entry_ids es un arreglo de arreglos: [["id-2"], ["id-1"], ...] const sortedIds = meta?.entry_ids ?? [] for (let i = 0; i < entries.length; i++) { const id = sortedIds[i]?.[0] // Sacamos el ID real de esta posición if (!id) continue - + const cita = citeprocHtmlToPlainText(entries[i] ?? '') citations[id] = cita } @@ -697,7 +868,7 @@ export function NuevaBibliografiaModalContainer({ {wizard.metodo === 'IA' ? ( showConservacionTooltip: boolean onDismissConservacionTooltip: () => void onChange: ( patch: Partial<{ q: string - cantidadDeSugerencias: number | null + idioma: IdiomaBibliografia sugerencias: any }>, ) => void @@ -910,12 +1082,6 @@ function SugerenciasStep({ }) { const selectedCount = sugerencias.filter((s) => s.selected).length - const cantidadIsValid = - typeof cantidad === 'number' && - Number.isFinite(cantidad) && - cantidad >= 1 && - cantidad <= 40 - return (
@@ -936,41 +1102,34 @@ function SugerenciasStep({
-
- - { - if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) { - e.preventDefault() - } - }} - onChange={(e) => { - const raw = e.target.value - if (raw === '') { - onChange({ cantidadDeSugerencias: null }) - return - } - const asNumber = Number(raw) - if (!Number.isFinite(asNumber)) return - const n = Math.floor(Math.abs(asNumber)) - const capped = Math.min(Math.max(n >= 1 ? n : 1, 1), 40) - onChange({ cantidadDeSugerencias: capped }) - }} - /> +
+ +
{sugerencias.map((s) => { - const info = s.volume.volumeInfo ?? {} - const title = (info.title ?? 'Sin título').trim() - const authors = (info.authors ?? []).join(', ') - const year = tryParseYear(info.publishedDate) const selected = s.selected + const badgeLabel = s.endpoint === 'google' ? 'Google' : 'Open Library' + + const title = + s.endpoint === 'google' + ? (((s.item as GoogleBooksVolume).volumeInfo?.title ?? + 'Sin título')).trim() + : (typeof (s.item as OpenLibraryDoc)['title'] === 'string' + ? ((s.item as OpenLibraryDoc)['title'] as string) + : 'Sin título' + ).trim() + + const authors = + s.endpoint === 'google' + ? ((s.item as GoogleBooksVolume).volumeInfo?.authors ?? []).join(', ') + : Array.isArray((s.item as OpenLibraryDoc)['author_name']) + ? ((s.item as OpenLibraryDoc)['author_name'] as Array) + .filter((a): a is string => typeof a === 'string') + .join(', ') + : '' + + const year = + s.endpoint === 'google' + ? tryParseYear( + (s.item as GoogleBooksVolume).volumeInfo?.publishedDate, + ) + : tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc) + return (