/** * Zotero picker modal (P4 — BIB_ORCID_TEMPLATES_PLAN.md). * * Same UX as the ORCID picker, source = the user's OWN linked Zotero: * step 'library': pick a library (main library or a group) + an * optional collection + Browse * step 'items': list items (newest first), select some, Import * * The modal ends by calling `onInsert(bibtexText)` with the combined * BibTeX of the selected items (one combined Zotero request server-side; * the host surface parses and writes it through its own guarded import * path). "Zotero not linked" (409) renders a link action to the OAuth * flow. */ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { OLModal, OLModalHeader, OLModalTitle, OLModalBody, OLModalFooter, } from '@/shared/components/ol/ol-modal' import OLButton from '@/shared/components/ol/ol-button' import OLFormGroup from '@/shared/components/ol/ol-form-group' import OLFormLabel from '@/shared/components/ol/ol-form-label' import OLFormSelect from '@/shared/components/ol/ol-form-select' import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' import Notification from '@/shared/components/notification' import { getJSON } from '@/infrastructure/fetch-json' type Library = { id: string; kind: 'user' | 'group'; name: string } type Collection = { key: string; name: string } type ZItem = { key: string title: string itemType: string date: string firstCreator: string } type ZoteroPickerModalProps = { show: boolean handleHide: () => void /** Host writes these BibTeX entries (raw text, combined). */ onInsert: (bibtexText: string) => void } type Step = 'library' | 'items' function errorMessage(err: unknown, fallback: string) { if (err && typeof err === 'object') { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message === 'zotero_not_linked' ? 'zotero_not_linked' : data.message if ((err as { message?: string }).message) { return (err as { message: string }).message } } return fallback } const ITEMS_PER_PAGE = 100 export default function ZoteroPickerModal({ show, handleHide, onInsert, }: ZoteroPickerModalProps) { const { t } = useTranslation() const [step, setStep] = useState('library') const [notLinked, setNotLinked] = useState(false) // -- library step -- const [loadingLibraries, setLoadingLibraries] = useState(false) const [libraries, setLibraries] = useState([]) const [libraryIdx, setLibraryIdx] = useState(0) const [collections, setCollections] = useState([]) const [collectionsLoading, setCollectionsLoading] = useState(false) const [collectionKey, setCollectionKey] = useState('') const [libraryError, setLibraryError] = useState(null) // -- items step -- const [items, setItems] = useState([]) const [total, setTotal] = useState(0) const [itemsLoading, setItemsLoading] = useState(false) const [itemsError, setItemsError] = useState(null) const [selectedKeys, setSelectedKeys] = useState>(new Set()) // -- import state -- const [importing, setImporting] = useState(false) const [importError, setImportError] = useState(null) const currentLibrary = libraries[libraryIdx] // ----------------------------------------------------------------------- // Reset when the modal closes // ----------------------------------------------------------------------- const handleClose = useCallback(() => { setStep('library') setNotLinked(false) setLibraries([]) setLibraryIdx(0) setCollections([]) setCollectionKey('') setLibraryError(null) setItems([]) setTotal(0) setItemsError(null) setSelectedKeys(new Set()) setImporting(false) setImportError(null) handleHide() }, [handleHide]) // ----------------------------------------------------------------------- // Load libraries when the modal opens // ----------------------------------------------------------------------- useEffect(() => { if (!show) return let alive = true setLoadingLibraries(true) setLibraryError(null) setNotLinked(false) setLibraries([]) setCollections([]) setCollectionKey('') setLibraryIdx(0) void getJSON('/user/zotero/picker/libraries') .then(data => { if (!alive) return const libs = Array.isArray(data) ? data : [] setLibraries(libs) if (libs.length === 0) { setLibraryError(t('Failed to load your Zotero libraries')) } }) .catch(err => { if (!alive) return const msg = errorMessage(err, t('Failed to load your Zotero libraries')) if (msg === 'zotero_not_linked') setNotLinked(true) else setLibraryError(msg) }) .finally(() => { if (alive) setLoadingLibraries(false) }) return () => { alive = false } }, [show, t]) // ----------------------------------------------------------------------- // Load the collections of the selected library // ----------------------------------------------------------------------- const loadCollections = useCallback( (lib: Library) => { setCollections([]) setCollectionKey('') setCollectionsLoading(true) const qs = new URLSearchParams({ libraryKind: lib.kind }) if (lib.kind === 'group') qs.set('library', lib.id) void getJSON(`/user/zotero/picker/collections?${qs.toString()}`) .then(data => { setCollections(Array.isArray(data) ? data : []) }) .catch(err => { const msg = errorMessage(err, null) if (msg === 'zotero_not_linked') setNotLinked(true) }) .finally(() => setCollectionsLoading(false)) }, [] ) useEffect(() => { if (show && currentLibrary) { loadCollections(currentLibrary) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [show, libraryIdx, libraries.length]) // ----------------------------------------------------------------------- // Browse items // ----------------------------------------------------------------------- const browse = useCallback(() => { if (!currentLibrary) return setStep('items') setItemsError(null) setImportError(null) setItems([]) setSelectedKeys(new Set()) setItemsLoading(true) const qs = new URLSearchParams({ libraryKind: currentLibrary.kind, }) if (currentLibrary.kind === 'group') qs.set('library', currentLibrary.id) if (collectionKey) qs.set('collection', collectionKey) qs.set('limit', String(ITEMS_PER_PAGE)) void getJSON<{ items: ZItem[]; total: number }>( `/user/zotero/picker/items?${qs.toString()}` ) .then(data => { setItems(data.items || []) setTotal(data.total || (data.items || []).length) }) .catch(err => { const msg = errorMessage(err, t('Failed to load items')) if (msg === 'zotero_not_linked') { setNotLinked(true) setStep('library') } else { setItemsError(msg) } }) .finally(() => setItemsLoading(false)) }, [currentLibrary, collectionKey, t]) // ----------------------------------------------------------------------- // Selection // ----------------------------------------------------------------------- const toggleItem = useCallback((key: string) => { setSelectedKeys(prev => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) }, []) const allSelected = items.length > 0 && items.every(i => selectedKeys.has(i.key)) const toggleAll = useCallback(() => { if (allSelected) setSelectedKeys(new Set()) else setSelectedKeys(new Set(items.map(i => i.key))) }, [allSelected, items]) // ----------------------------------------------------------------------- // Import // ----------------------------------------------------------------------- const handleImport = useCallback(async () => { if (!currentLibrary || selectedKeys.size === 0) return setImporting(true) setImportError(null) try { const qs = new URLSearchParams({ libraryKind: currentLibrary.kind, keys: Array.from(selectedKeys).join(','), }) if (currentLibrary.kind === 'group') qs.set('library', currentLibrary.id) const data = await getJSON<{ bibtex: string }>( `/user/zotero/picker/bibtex?${qs.toString()}` ) onInsert(data.bibtex || '') handleClose() } catch (err) { const msg = errorMessage(err, null) if (msg === 'zotero_not_linked') { setNotLinked(true) setImportError(t('Zotero is no longer linked to your account')) } else { setImportError(errorMessage(err, t('Could not import from Zotero'))) } setImporting(false) } }, [currentLibrary, selectedKeys, onInsert, handleClose, t]) // ----------------------------------------------------------------------- // Render // ----------------------------------------------------------------------- return ( {step === 'library' ? t('Import from Zotero') : t('Items', { author: currentLibrary?.name || t('Zotero'), })} {notLinked ? ( <>

{t('Zotero is not linked to your account yet.')}

{t('Link your Zotero account to import references. Create a key at ') + ( zotero.org/settings/keys ) + t(' (library access + read access to all groups).')}

{t('Link Zotero')} {t('Cancel')}
) : ( <> {step === 'library' && ( <> {libraryError && ( setLibraryError(null)} /> )} {loadingLibraries &&

{t('Loading your Zotero libraries…')}

} {!loadingLibraries && libraries.length > 0 && ( <> {t('library')} ) => setLibraryIdx(Number(e.target.value) || 0) } > {libraries.map((lib, i) => ( ))} {t('collection')} ({t('optional')}) ) => setCollectionKey(e.target.value) } > {collections.map(c => ( ))} {t('Browse items')} )} )} {step === 'items' && ( <> { setStep('library') setItemsError(null) setImportError(null) }} style={{ padding: 0, marginBottom: '8px' }} data-testid="zot-back" > {t('Back to libraries')} {itemsLoading &&

{t('Loading items…')}

} {itemsError && ( setItemsError(null)} /> )} {importError && ( setImportError(null)} /> )} {!itemsLoading && items.length > 0 && ( <>

{t('Showing __shown__ of __total__ (newest first)', { shown: items.length, total, })}

{items.map((w, i) => ( ))}
{t('Title')} {t('Type')} {t('Year')}
toggleItem(w.key)} aria-label={w.title || t('Untitled')} /> {w.title || t('Untitled')} {w.firstCreator ? ( {` — ${w.firstCreator}`} ) : null} {w.itemType || '—'} {(w.date || '').slice(0, 4) || '—'}
)} )} )}
{step === 'items' && !notLinked && !itemsLoading && items.length > 0 && ( {t('Cancel')} void handleImport()} disabled={selectedKeys.size === 0 || importing} isLoading={importing} > {t('Import selected (__count__)', { count: selectedKeys.size })} )}
) }