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..a389f92 100644 --- a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx +++ b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx @@ -1,13 +1,32 @@ import { useNavigate } from '@tanstack/react-router' import CSL from 'citeproc' -import { Globe, Loader2, Plus, RefreshCw, X } from 'lucide-react' -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + Globe, + Link as LinkIcon, + Loader2, + Plus, + RefreshCw, + X, +} from 'lucide-react' +import { + forwardRef, + useEffect, + useImperativeHandle, + 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, @@ -38,9 +57,60 @@ import { buscar_bibliografia } from '@/data' import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' -type MetodoBibliografia = 'MANUAL' | 'IA' | null +type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | 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', +} + +const MIN_YEAR = 1450 +const MAX_YEAR = new Date().getFullYear() + 1 + type CSLAuthor = { family: string given: string @@ -52,7 +122,8 @@ type CSLItem = { title: string author: Array publisher?: string - issued?: { 'date-parts': Array> } + issued?: { 'date-parts': Array>; circa?: boolean } + status?: string ISBN?: string } @@ -65,11 +136,14 @@ type BibliografiaTipoFuente = NonNullable< type BibliografiaRef = { id: string source: BibliografiaTipoFuente - raw?: GoogleBooksVolume + raw?: GoogleBooksVolume | OpenLibraryDoc title: string + subtitle?: string authors: Array publisher?: string year?: number + yearIsApproximate?: boolean + isInPress?: boolean isbn?: string tipo: BibliografiaTipo @@ -79,12 +153,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 +182,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,20 +222,126 @@ 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 sanitizeYearInput(value: string): string { + return value.replace(/[^\d]/g, '').slice(0, 4) +} + +function tryParseStrictYear(value: string): number | undefined { + const cleaned = sanitizeYearInput(value) + if (!/^\d{4}$/.test(cleaned)) return undefined + const year = Number.parseInt(cleaned, 10) + if (!Number.isFinite(year)) return undefined + if (year < MIN_YEAR || year > MAX_YEAR) return undefined + return year +} + +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 subtitle = + typeof info.subtitle === 'string' ? info.subtitle.trim() : undefined + 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, + subtitle, + authors, + publisher, + year, + isbn, + tipo: 'BASICA', + } + } + + const doc = result.item + const title = + (typeof doc['title'] === 'string' ? doc['title'] : '').trim() || + 'Sin título' + const subtitle = + typeof doc['subtitle'] === 'string' ? doc['subtitle'].trim() : undefined + 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, + subtitle, authors, publisher, year, @@ -162,48 +350,21 @@ function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef { } } -function AutoSizeTextarea({ - value, - disabled, - placeholder, - className, - onChange, -}: { - value: string - disabled?: boolean - placeholder?: string - className?: string - onChange: (next: string) => void -}) { - const ref = useRef(null) - - const autosize = () => { - const el = ref.current - if (!el) return - el.style.height = '0px' - el.style.height = `${el.scrollHeight}px` +function getResultYear(result: EndpointResult): number | undefined { + if (result.endpoint === 'google') { + const info = result.item.volumeInfo ?? {} + return tryParseYear(info.publishedDate) } + return tryParseYearFromOpenLibrary(result.item) +} - useLayoutEffect(() => { - autosize() - }, [value]) - - return ( -