import { useNavigate } from '@tanstack/react-router' import CSL from 'citeproc' 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 { EndpointResult, GoogleBooksVolume, OpenLibraryDoc, } from '@/data/api/subjects.api' import type { TablesInsert } from '@/types/supabase' import { defineStepper } from '@/components/stepper' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { WizardLayout } from '@/components/wizard/WizardLayout' import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader' import { buscar_bibliografia } from '@/data' import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' type BibliotecaOption = { id: string title: string authors: Array publisher?: string year?: number isbn?: string shelf?: string badgeText?: string } type BibliotecaOptionTemplate = Omit // Hardcodeado: 3 conjuntos de coincidencias (0, 2 y 5). const BIBLIOTECA_MATCH_SETS: Array> = [ [], [ { title: 'Coincidencia en biblioteca (Ejemplar 1)', authors: ['Autor A', 'Autor B'], publisher: 'Editorial X', year: 2020, isbn: '9780000000001', shelf: 'QA76.9 .A1 2020', badgeText: 'Coincidencia ISBN', }, { title: 'Coincidencia en biblioteca (Ejemplar 2)', authors: ['Autor C'], publisher: 'Editorial Y', year: 2016, shelf: 'QA76.9 .A2 2016', }, ], [ { title: 'Coincidencia en biblioteca (Ejemplar 1)', authors: ['Autor A', 'Autor B'], publisher: 'Editorial X', year: 2020, isbn: '9780000000001', shelf: 'QA76.9 .A1 2020', badgeText: 'Coincidencia ISBN', }, { title: 'Coincidencia en biblioteca (Ejemplar 2)', authors: ['Autor C'], publisher: 'Editorial Y', year: 2016, shelf: 'QA76.9 .A2 2016', }, { title: 'Coincidencia en biblioteca (Ejemplar 3)', authors: ['Autor D', 'Autor E'], publisher: 'Editorial Z', year: 2014, shelf: 'QA76.9 .A3 2014', }, { title: 'Coincidencia en biblioteca (Ejemplar 4)', authors: ['Autor F'], publisher: 'Editorial W', year: 2011, shelf: 'QA76.9 .A4 2011', }, { title: 'Coincidencia en biblioteca (Ejemplar 5)', authors: ['Autor G'], publisher: 'Editorial V', year: 2009, shelf: 'QA76.9 .A5 2009', }, ], ] export function BookSelectionAccordion({ onlineSourceLabel, online, options, value, onValueChange, }: { onlineSourceLabel: string online: { id: string title: string authorsLine: string year?: number isbn?: string } options: Array value: string | undefined onValueChange: (value: string) => void }) { // Estado inicial indefinido para que nada esté seleccionado por defecto const [selectedBook, setSelectedBook] = useState(value) useEffect(() => { setSelectedBook(value) }, [value]) const onlineValue = `online:${online.id}` const optionBaseClass = 'relative flex items-start space-x-3 rounded-lg border p-4 transition-colors' const optionClass = (isSelected: boolean) => cn( optionBaseClass, isSelected ? 'border-primary bg-primary/5' : 'hover:border-primary/30 hover:bg-accent/50', ) return ( <> {/* Un solo RadioGroup controla ambos lados */} { setSelectedBook(v) onValueChange(v) }} className="flex flex-col gap-6 md:flex-row" > {/* --- LADO IZQUIERDO: Sugerencia Online --- */}

Sugerencia Original ({onlineSourceLabel})

{/* Separador vertical para escritorio, horizontal en móviles */} {/* --- LADO DERECHO: Alternativas de Biblioteca --- */}

Disponibles en Biblioteca

{options.length === 0 ? (
No se encontraron alternativas.
) : ( options.map((opt) => { const optValue = `biblio:${opt.id}` const authorsLine = opt.authors.join('; ') const isSelected = selectedBook === optValue return (
) }) )}
) } 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 } type CSLItem = { id: string type: 'book' title: string author: Array publisher?: string issued?: { 'date-parts': Array>; circa?: boolean } status?: string ISBN?: string } type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'> type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo'] type BibliografiaRef = { id: string raw?: GoogleBooksVolume | OpenLibraryDoc title: string subtitle?: string authors: Array publisher?: string year?: number yearIsApproximate?: boolean isInPress?: boolean isbn?: string tipo: BibliografiaTipo } type WizardState = { metodo: MetodoBibliografia ia: { q: string idioma: IdiomaBibliografia showConservacionTooltip: boolean sugerencias: Array<{ id: string selected: boolean endpoint: EndpointResult['endpoint'] item: GoogleBooksVolume | OpenLibraryDoc biblioteca?: { options?: Array choiceId?: string } }> isLoading: boolean errorMessage: string | null } manual: { draft: { title: string authorsText: string publisher: string yearText: string isbn: string } refs: Array } formato: FormatoCita | null refs: Array citaEdits: Record> generatingIds: Set isSaving: boolean 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' }, { id: 'paso2', title: 'Datos básicos', description: 'Seleccionar o capturar', }, { id: 'biblioteca', title: 'Biblioteca', description: 'Comparar con alternativas de la biblioteca', }, { id: 'paso3', title: 'Detalles', description: 'Formato y citas' }, { id: 'resumen', title: 'Resumen', description: 'Confirmar' }, ) type BibliotecaStepHandle = { validateBeforeNext: () => boolean } function bibliotecaOptionToRef(opt: BibliotecaOption): BibliografiaRef { return { id: opt.id, raw: undefined, title: opt.title, subtitle: undefined, authors: opt.authors, publisher: opt.publisher, year: opt.year, isbn: opt.isbn, tipo: 'BASICA', } } function getOnlineSuggestionTitle(s: IASugerencia): string { if (s.endpoint === 'google') { const info = (s.item as GoogleBooksVolume).volumeInfo ?? {} return (info.title ?? '').trim() || 'Sin título' } const doc = s.item as OpenLibraryDoc return ( (typeof doc['title'] === 'string' ? doc['title'] : '').trim() || 'Sin título' ) } function getOnlineSuggestionAuthors(s: IASugerencia): Array { if (s.endpoint === 'google') { const info = (s.item as GoogleBooksVolume).volumeInfo ?? {} return Array.isArray(info.authors) ? info.authors : [] } const doc = s.item as OpenLibraryDoc return Array.isArray(doc['author_name']) ? (doc['author_name'] as Array).filter( (a): a is string => typeof a === 'string', ) : [] } function getOnlineSuggestionIsbn(s: IASugerencia): string | undefined { if (s.endpoint === 'google') { const info = (s.item as GoogleBooksVolume).volumeInfo const isbn = info?.industryIdentifiers?.find( (x) => x.identifier, )?.identifier return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined } const doc = s.item as OpenLibraryDoc const isbn = Array.isArray(doc['isbn']) ? (doc['isbn'] as Array).find( (x): x is string => typeof x === 'string', ) : undefined return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined } function getOnlineSuggestionYear(s: IASugerencia): number | undefined { return s.endpoint === 'google' ? tryParseYear((s.item as GoogleBooksVolume).volumeInfo?.publishedDate) : tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc) } function iaSugerenciaToChosenRef(s: IASugerencia): BibliografiaRef { const choiceId = s.biblioteca?.choiceId const options = s.biblioteca?.options if (choiceId && choiceId !== 'online' && Array.isArray(options)) { const chosen = options.find((o) => o.id === choiceId) if (chosen) return bibliotecaOptionToRef(chosen) } return endpointResultToRef(iaSugerenciaToEndpointResult(s)) } function parsearAutor(nombreCompleto: string): CSLAuthor { if (nombreCompleto.includes(',')) { return { family: nombreCompleto.split(',')[0]?.trim() ?? '', given: nombreCompleto.split(',')[1]?.trim() ?? '', } } const partes = nombreCompleto.trim().split(/\s+/).filter(Boolean) if (partes.length === 1) return { family: partes[0] ?? '', given: '' } const family = partes.pop() ?? '' const given = partes.join(' ') return { family, given } } function tryParseYear(publishedDate?: string): number | undefined { if (!publishedDate) return undefined const match = String(publishedDate).match(/\d{4}/) if (!match) return undefined const year = Number.parseInt(match[0], 10) return Number.isFinite(year) ? year : 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), 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: getEndpointResultId(result), raw: doc, title, subtitle, authors, publisher, year, isbn, tipo: 'BASICA', } } 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 citeprocHtmlToPlainText(value: string) { const input = value if (!input) return '' // citeproc suele devolver HTML + entidades (`&`, `&`, etc.). // Convertimos a texto plano usando el parser del navegador. try { const doc = new DOMParser().parseFromString(input, 'text/html') return (doc.body.textContent || '').replace(/\s+/g, ' ').trim() } catch { // Fallback ultra simple (por si DOMParser no existe en algún entorno). return input .replace(/<[^>]*>/g, ' ') .replace(/&?/g, '&') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim() } } async function fetchTextCached(url: string, cache: Map) { const cached = cache.get(url) if (cached) return cached const res = await fetch(url) if (!res.ok) throw new Error(`No se pudo cargar recurso: ${url}`) const text = await res.text() // En dev (SPA), una ruta inexistente puede devolver `index.html` con 200. // Eso rompe citeproc con errores poco claros. const trimmed = text.trim().toLowerCase() const looksLikeHtml = trimmed.startsWith(' = { apa: publicUrl('csl/styles/apa.csl'), ieee: publicUrl('csl/styles/ieee.csl'), chicago: publicUrl('csl/styles/chicago-author-date.csl'), vancouver: publicUrl('csl/styles/nlm-citation-sequence.csl'), } const CSL_LOCALE_URL = publicUrl('csl/locales/locales-es-MX.xml') export function NuevaBibliografiaModalContainer({ planId, asignaturaId, }: { planId: string asignaturaId: string }) { const navigate = useNavigate() const createBibliografia = useCreateBibliografia() const formatoStepRef = useRef(null) const bibliotecaStepRef = useRef(null) const [wizard, setWizard] = useState({ metodo: null, ia: { q: '', idioma: 'ALL', showConservacionTooltip: false, sugerencias: [], isLoading: false, errorMessage: null, }, manual: { draft: { title: '', authorsText: '', publisher: '', yearText: '', isbn: '', }, refs: [], }, formato: null, refs: [], citaEdits: { apa: {}, ieee: {}, chicago: {}, vancouver: {}, }, generatingIds: new Set(), isSaving: false, errorMessage: null, }) const styleCacheRef = useRef(new Map()) const localeCacheRef = useRef(new Map()) const titleOverrides: Record = wizard.metodo === 'EN_LINEA' ? { paso2: 'Sugerencias', biblioteca: 'Biblioteca', paso3: 'Estructura' } : { paso2: 'Datos básicos', paso3: 'Detalles' } const handleClose = () => { navigate({ to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/`, resetScroll: false, }) } const refsForStep3: Array = wizard.metodo === 'EN_LINEA' ? wizard.ia.sugerencias .filter((s) => s.selected) .map((s) => iaSugerenciaToChosenRef(s)) : wizard.manual.refs // Mantener `wizard.refs` como snapshot para pasos 3/4. useEffect(() => { setWizard((w) => ({ ...w, refs: refsForStep3 })) // eslint-disable-next-line react-hooks/exhaustive-deps }, [wizard.metodo, wizard.ia.sugerencias, wizard.manual.refs]) const citationsForFormato = useMemo(() => { if (!wizard.formato) return {} return wizard.citaEdits[wizard.formato] }, [wizard.citaEdits, wizard.formato]) const allCitationsReady = useMemo(() => { if (!wizard.formato) return false if (wizard.refs.length === 0) return false const map = wizard.citaEdits[wizard.formato] return wizard.refs.every( (r) => typeof map[r.id] === 'string' && map[r.id].trim().length > 0, ) }, [wizard.citaEdits, wizard.formato, wizard.refs]) const canContinueDesdeMetodo = wizard.metodo === 'MANUAL' || wizard.metodo === 'EN_LINEA' const canContinueDesdePaso2 = wizard.metodo === 'EN_LINEA' ? wizard.ia.sugerencias.some((s) => s.selected) : wizard.manual.refs.length > 0 const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady async function handleBuscarSugerencias() { const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0 if (wizard.ia.sugerencias.filter((s) => s.selected).length >= 20) return const q = wizard.ia.q.trim() if (!q) return setWizard((w) => ({ ...w, ia: { ...w.ia, // Conservar únicamente las sugerencias seleccionadas. sugerencias: w.ia.sugerencias.filter((s) => s.selected), showConservacionTooltip: false, isLoading: true, errorMessage: null, }, errorMessage: null, })) try { 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 }, google, openLibrary, } 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)) 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: merged, showConservacionTooltip: hadNoSugerenciasBefore && newOnes.length > 0, isLoading: false, errorMessage: null, }, } }) } catch (e: any) { setWizard((w) => ({ ...w, ia: { ...w.ia, isLoading: false, errorMessage: typeof e?.message === 'string' ? e.message : 'Error al buscar bibliografía', }, })) } } async function generateCitasForFormato( formato: FormatoCita, refs: Array, options?: { force?: boolean }, ) { const force = Boolean(options?.force) setWizard((w) => { const nextIds = new Set(w.generatingIds) refs.forEach((r) => nextIds.add(r.id)) return { ...w, generatingIds: nextIds, } }) try { const xmlStyle = await fetchTextCached( CSL_STYLE_URL[formato], styleCacheRef.current, ) const xmlLocale = await fetchTextCached( CSL_LOCALE_URL, localeCacheRef.current, ) const cslItems: Record = {} for (const r of refs) { const trimmedTitle = r.title.trim() cslItems[r.id] = { id: r.id, type: 'book', title: trimmedTitle || 'Sin título', author: r.authors.map(parsearAutor), publisher: r.publisher, issued: r.isInPress || !r.year ? undefined : { 'date-parts': [[r.year]], circa: r.yearIsApproximate ? true : undefined, }, status: r.isInPress ? 'in press' : undefined, ISBN: r.isbn, } } const sys = { retrieveLocale: (_lang: string) => xmlLocale, retrieveItem: (id: string) => cslItems[id], } 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?: 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 } setWizard((w) => { const nextEdits = { ...w.citaEdits } const existing = nextEdits[formato] const merged: Record = { ...existing } for (const id of Object.keys(citations)) { merged[id] = force || !merged[id] || merged[id].trim().length === 0 ? (citations[id] ?? '') : merged[id] } nextEdits[formato] = merged const nextIds = new Set(w.generatingIds) refs.forEach((r) => nextIds.delete(r.id)) return { ...w, citaEdits: nextEdits, generatingIds: nextIds, } }) } catch (e: any) { setWizard((w) => { const nextIds = new Set(w.generatingIds) refs.forEach((r) => nextIds.delete(r.id)) return { ...w, generatingIds: nextIds, errorMessage: typeof e?.message === 'string' ? e.message : 'Error al generar citas', } }) } } useEffect(() => { if (!wizard.formato) return if (wizard.refs.length === 0) return const map = wizard.citaEdits[wizard.formato] const missing = wizard.refs.some( (r) => !map[r.id] || map[r.id].trim().length === 0, ) if (!missing) return if (wizard.generatingIds.size > 0) return void generateCitasForFormato(wizard.formato, wizard.refs) // eslint-disable-next-line react-hooks/exhaustive-deps }, [wizard.formato, wizard.refs]) async function handleCreate() { setWizard((w) => ({ ...w, isSaving: true, errorMessage: null })) try { if (!wizard.formato) throw new Error('Selecciona un formato') const map = wizard.citaEdits[wizard.formato] if (wizard.refs.length === 0) throw new Error('No hay referencias') await Promise.all( wizard.refs.map((r) => createBibliografia.mutateAsync({ asignatura_id: asignaturaId, tipo: r.tipo, cita: map[r.id] ?? '', // tipo_fuente: r.source, // biblioteca_item_id: null, }), ), ) setWizard((w) => ({ ...w, isSaving: false })) handleClose() } catch (e: any) { setWizard((w) => ({ ...w, isSaving: false, errorMessage: typeof e?.message === 'string' ? e.message : 'Error al guardar bibliografía', })) } } const WizardDef = Wizard as any return ( {({ methods }: any) => { const idx = WizardDef.utils.getIndex(methods.current.id) const isLast = idx >= WizardDef.steps.length - 1 const currentId = methods.current.id as string return ( } footerSlot={
{isLast ? ( ) : ( )}
} >
{wizard.errorMessage ? ( {wizard.errorMessage} ) : null} {currentId === 'metodo' && ( setWizard((w) => ({ ...w, metodo, formato: null, errorMessage: null, })) } /> )} {currentId === 'paso2' && ( {wizard.metodo === 'EN_LINEA' ? ( setWizard((w) => ({ ...w, ia: { ...w.ia, showConservacionTooltip: false }, })) } onChange={(patch) => setWizard((w) => ({ ...w, ia: { ...w.ia, ...patch, }, errorMessage: null, })) } onGenerate={handleBuscarSugerencias} /> ) : ( setWizard((w) => ({ ...w, manual: { ...w.manual, draft }, errorMessage: null, })) } onAddRef={(ref) => setWizard((w) => ({ ...w, manual: { ...w.manual, refs: [...w.manual.refs, ref], }, errorMessage: null, })) } onRemoveRef={(id) => setWizard((w) => ({ ...w, manual: { ...w.manual, refs: w.manual.refs.filter((r) => r.id !== id), }, })) } /> )} )} {currentId === 'biblioteca' && wizard.metodo === 'EN_LINEA' && ( s.selected, )} onPatchSugerencia={(id, patch) => setWizard((w) => ({ ...w, ia: { ...w.ia, sugerencias: w.ia.sugerencias.map((s) => s.id === id ? { ...s, ...patch } : s, ), }, })) } /> )} {currentId === 'paso3' && ( { setWizard((w) => ({ ...w, formato, errorMessage: null })) if (formato) { void generateCitasForFormato(formato, wizard.refs) } }} onRegenerate={() => { if (!wizard.formato) return void generateCitasForFormato( wizard.formato, wizard.refs, { force: true, }, ) }} onChangeRef={(id, patch) => setWizard((w) => ({ ...w, refs: w.refs.map((r) => r.id === id ? { ...r, ...patch } : r, ), })) } onChangeTipo={(id, tipo) => setWizard((w) => ({ ...w, refs: w.refs.map((r) => r.id === id ? { ...r, tipo } : r, ), })) } /> )} {currentId === 'resumen' && ( )}
) }}
) } function MetodoStep({ metodo, onChange, }: { metodo: MetodoBibliografia onChange: (metodo: MetodoBibliografia) => void }) { const isSelected = (m: Exclude) => metodo === m return (
onChange('MANUAL')} > Manual Captura referencias y edita la cita. onChange('EN_LINEA')} > Buscar en línea Busca sugerencias y selecciona las mejores.
) } function SugerenciasStep({ q, idioma, isLoading, errorMessage, sugerencias, showConservacionTooltip, onDismissConservacionTooltip, onChange, onGenerate, }: { q: string idioma: IdiomaBibliografia isLoading: boolean errorMessage: string | null sugerencias: Array<{ id: string selected: boolean endpoint: EndpointResult['endpoint'] item: GoogleBooksVolume | OpenLibraryDoc }> showConservacionTooltip: boolean onDismissConservacionTooltip: () => void onChange: ( patch: Partial<{ q: string idioma: IdiomaBibliografia sugerencias: any }>, ) => void onGenerate: () => void }) { const selectedCount = sugerencias.filter((s) => s.selected).length return (
Buscar sugerencias Conserva las seleccionadas y agrega nuevas.
onChange({ q: e.target.value.slice(0, 200) })} placeholder="Ej: ingeniería de software, bases de datos..." />
{!isLoading && q.trim().length < 3 ? (

El query debe ser de al menos 3 caracteres

) : ( )}
{errorMessage ? (
{errorMessage}
) : null}

Sugerencias

📌 {selectedCount} seleccionadas
Al generar más sugerencias, se conservarán las referencias seleccionadas.
{sugerencias.map((s) => { 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 subtitle = s.endpoint === 'google' ? (typeof (s.item as GoogleBooksVolume).volumeInfo?.subtitle === 'string' ? ((s.item as GoogleBooksVolume).volumeInfo ?.subtitle as string) : '' ).trim() : (typeof (s.item as OpenLibraryDoc)['subtitle'] === 'string' ? ((s.item as OpenLibraryDoc)['subtitle'] as string) : '' ).trim() const browserHref = (() => { if (s.endpoint === 'google') { const info = (s.item as GoogleBooksVolume).volumeInfo const previewLink = typeof info?.previewLink === 'string' ? info.previewLink : undefined const infoLink = typeof info?.infoLink === 'string' ? info.infoLink : undefined return previewLink || infoLink } const key = (s.item as OpenLibraryDoc)['key'] if (typeof key === 'string' && key.trim()) { return `https://openlibrary.org/${key}` } return undefined })() 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 ( ) })}
) } type BibliotecaStepProps = { sugerencias: Array onPatchSugerencia: (id: string, patch: Partial) => void } const BibliotecaStep = forwardRef( function BibliotecaStep({ sugerencias, onPatchSugerencia }, ref) { const [openIds, setOpenIds] = useState>([]) const anchorRefs = useRef>({}) const initializedRef = useRef(new Set()) const scrollToAccordion = (id: string) => { const el = anchorRefs.current[id] if (!el) return el.scrollIntoView({ behavior: 'smooth', block: 'center' }) } useEffect(() => { for (const s of sugerencias) { const b = s.biblioteca const hasOptions = Array.isArray(b?.options) if (hasOptions) continue if (initializedRef.current.has(s.id)) continue initializedRef.current.add(s.id) const setIdx = Math.floor(Math.random() * 3) const templates = BIBLIOTECA_MATCH_SETS[setIdx] ?? [] const options: Array = templates.map((t, i) => ({ id: `biblio:${s.id}:${i + 1}`, ...t, })) onPatchSugerencia(s.id, { biblioteca: { options, choiceId: options.length === 0 ? 'online' : undefined, }, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sugerencias]) const validateBeforeNext = () => { const unresolved = sugerencias.find((s) => { const b = s.biblioteca if (!b || !Array.isArray(b.options)) return true if (b.options.length === 0) return false return !b.choiceId }) if (!unresolved) return true setOpenIds((prev) => prev.includes(unresolved.id) ? prev : [...prev, unresolved.id], ) requestAnimationFrame(() => scrollToAccordion(unresolved.id)) return false } useImperativeHandle(ref, () => ({ validateBeforeNext })) return (
Comparar con alternativas de la biblioteca Conserva la sugerencia original o sustitúyela por una coincidencia. {sugerencias.map((s) => { const title = getOnlineSuggestionTitle(s) const authors = getOnlineSuggestionAuthors(s) const authorsLine = authors.join('; ') || '—' const year = getOnlineSuggestionYear(s) const isbn = getOnlineSuggestionIsbn(s) const sourceLabel = s.endpoint === 'google' ? 'Google Books' : 'Open Library' const b = s.biblioteca const options = b?.options ?? [] const badgeState: 'por_revisar' | 'sustituido' | 'mantenido' = !b || !Array.isArray(b.options) ? 'por_revisar' : options.length === 0 ? 'mantenido' : !b.choiceId ? 'por_revisar' : b.choiceId === 'online' ? 'mantenido' : 'sustituido' const badge = badgeState === 'por_revisar' ? ( Por revisar ) : badgeState === 'sustituido' ? ( Sustituido ) : ( Mantenido ) const radioValue = b?.choiceId === 'online' || (options.length === 0 && !b?.choiceId) ? `online:${s.id}` : typeof b?.choiceId === 'string' ? `biblio:${b.choiceId}` : undefined return (
{ anchorRefs.current[s.id] = el }} />
{title} {badge}
{ const nextChoiceId = v.startsWith('online:') ? 'online' : v.startsWith('biblio:') ? v.slice('biblio:'.length) : undefined if (!nextChoiceId) return onPatchSugerencia(s.id, { biblioteca: { options, choiceId: nextChoiceId, }, }) }} />
) })}
) }, ) function DatosBasicosManualStep({ draft, refs, onChangeDraft, onAddRef, onRemoveRef, }: { draft: WizardState['manual']['draft'] refs: Array onChangeDraft: (draft: WizardState['manual']['draft']) => void onAddRef: (ref: BibliografiaRef) => void onRemoveRef: (id: string) => void }) { const canAdd = draft.title.trim().length > 0 return (
Agregar referencia Captura los datos y agrégala a la lista.
onChangeDraft({ ...draft, title: e.target.value.slice(0, 500), }) } onBlur={() => { const trimmed = draft.title.trim() if (trimmed !== draft.title) { onChangeDraft({ ...draft, title: trimmed }) } }} />