Bib editor: add import from Zotero; improve UI

This commit is contained in:
yu-i-i
2026-11-13 05:07:25 +01:00
parent ae07e781ab
commit 566c41c7c9
17 changed files with 559 additions and 163 deletions
@@ -2897,7 +2897,7 @@
"field_is_required": "",
"generate": "",
"import_failed": "",
"import_from_orcid_org": "",
"import_from_provider": "",
"invalid_doi_format": "",
"invalid_response_from_server": "",
"load_works": "",
+1 -1
View File
@@ -2008,7 +2008,7 @@
"field_is_required": "__a__ ist erforderlich",
"generate": "Generieren",
"import_failed": "Import fehlgeschlagen",
"import_from_orcid_org": "Von ORCID.org importieren",
"import_from_provider": "Von __provider__ importieren",
"invalid_doi_format": "Ungültiges DOI-Format (erwartet: 10.xxxx/…)",
"invalid_response_from_server": "Ungültige Antwort vom Server",
"load_works": "Publikationen laden",
+1 -1
View File
@@ -3624,7 +3624,7 @@
"field_is_required": "__a__ is required",
"generate": "Generate",
"import_failed": "Import failed",
"import_from_orcid_org": "Import from ORCID.org",
"import_from_provider": "Import from __provider__",
"invalid_doi_format": "Invalid DOI format (expected 10.xxxx/…)",
"invalid_response_from_server": "Invalid response from server",
"load_works": "Load works",
+1 -1
View File
@@ -976,7 +976,7 @@
"field_is_required": "Поле __a__ обязательно",
"generate": "Генерировать",
"import_failed": "Ошибка импорта",
"import_from_orcid_org": "Импортировать из ORCID.org",
"import_from_provider": "Импортировать из __provider__",
"invalid_doi_format": "Неверный формат DOI (ожидается 10.xxxx/…)",
"invalid_response_from_server": "Некорректный ответ сервера",
"load_works": "Загрузить публикации",
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal, OLModalBody, OLModalFooter, OLModalHeader, OLModalTitle,
} from '@/shared/components/ol/ol-modal'
type Props = {
show: boolean
title: string
message: string
onHide: () => void
onConfirm: () => void
}
export default function BibDeleteModal({ show, title, message, onHide, onConfirm }: Props) {
const { t } = useTranslation()
return (
<OLModal data-bib-editor-dialog backdrop="static" show={show} onHide={onHide} themed>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody>{message}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide}>{t('cancel')}</OLButton>
<OLButton variant="danger" onClick={onConfirm}>{t('delete')}</OLButton>
</OLModalFooter>
</OLModal>
)
}
@@ -17,12 +17,13 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useBibEditorContext } from '../context/bib-editor-context'
import GenericConfirmModal from '@/features/ide-react/components/modals/generic-confirm-modal'
import BibDeleteModal from './bib-delete-modal'
import withErrorBoundary from '@/infrastructure/error-boundary'
import BibEntryList from './bib-entry-list'
import BibManualModal from './bib-manual-modal'
import BibEntryPreview from './bib-entry-preview'
import { useZoteroLibraries } from '../zotero/use-zotero-libraries'
import BibImportModal from './bib-import-modal'
import BibOrcidImportModal from './bib-orcid-import-modal'
@@ -118,6 +119,14 @@ function BibEditorPanel() {
entryId: string
} | null>(null)
const zotero = useZoteroLibraries(!readOnly && showVisual && !isFileView)
const [zoteroDocument, setZoteroDocument] = useState<string | null>(null)
const showZotero = !!zoteroDocument && zoteroDocument === currentDocumentId &&
!!zotero.groups && !readOnly && showVisual && !isFileView
useEffect(() => {
if (!showZotero) setZoteroDocument(null)
}, [showZotero])
const [importOpen, setImportOpen] = useState(false)
const [orcidOpen, setOrcidOpen] = useState(false)
const [manualShow, setManualShow] = useState(false)
@@ -885,6 +894,9 @@ function BibEditorPanel() {
}, [])
const openImport = useCallback(() => setImportOpen(true), [])
const openZotero = useCallback(() => {
if (currentDocumentId) setZoteroDocument(currentDocumentId)
}, [currentDocumentId])
const openManual = useCallback(() => setManualShow(true), [])
const openOrcid = useCallback(() => {
if (currentDocumentId) setOrcidOpen(true)
@@ -951,6 +963,9 @@ function BibEditorPanel() {
onAddPaste={openImport}
onAddManual={openManual}
onAddFromOrcid={openOrcid}
zoteroAvailable={zotero.available}
zoteroLinked={!!zotero.groups}
onAddFromZotero={openZotero}
/>
<BibBulkActionsBar
filteredLength={filtered.length}
@@ -1054,17 +1069,15 @@ function BibEditorPanel() {
)}
</PanelGroup>
<GenericConfirmModal
<BibDeleteModal
show={!readOnly && deleteTarget !== null && deleteTarget.documentId === currentDocumentId}
onHide={() => setDeleteTarget(null)}
title={t('delete_entry')}
message={t('confirm_delete_entry')}
confirmLabel={t('delete')}
primaryVariant="danger"
onConfirm={handleConfirmDelete}
/>
<GenericConfirmModal
<BibDeleteModal
show={bulkDeleteGuard !== null && bulkDeleteGuard.documentId === currentDocumentId && canWriteToView()}
onHide={() => setBulkDeleteGuard(null)}
title={plural(t, bulkDeleteCount, 'delete_entry', 'delete_entries')}
@@ -1074,8 +1087,6 @@ function BibEditorPanel() {
'confirm_delete_entry',
'confirm_delete_references_count'
)}
confirmLabel={t('delete')}
primaryVariant="danger"
onConfirm={handleConfirmBulkDelete}
/>
@@ -1085,6 +1096,15 @@ function BibEditorPanel() {
onHidden={() => setImportOpen(false)}
/>
{showZotero && zotero.groups && (
<BibImportModal
show
zoteroGroups={zotero.groups}
onImport={appendImportedEntries}
onHidden={() => setZoteroDocument(null)}
/>
)}
<BibManualModal
show={manualShow && !readOnly}
existingIds={entryKeys}
@@ -7,7 +7,7 @@ import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } fr
import { BIBTEX_IDENTIFIER_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
import { getAllEntryTypes, getEntryType } from '@shared/bibtex/entry-types.mts'
import { useTranslation } from 'react-i18next'
import OLButton from '@/shared/components/ol/ol-button'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
@@ -88,7 +88,7 @@ type Props = {
}
import { BibEntryTypeCombobox, BibAddFieldCombobox, displayCustomType } from './bib-entry-form-comboboxes'
import BibNameField, { resizeTextarea } from './bib-name-field'
import BibNameField from './bib-name-field'
const ENTRY_TYPES = getAllEntryTypes()
@@ -136,6 +136,7 @@ export default function BibEntryForm({
onOptionalExpandedChange,
}: Props) {
const { t } = useTranslation()
const [draggingName, setDraggingName] = useState(false)
// This set initializes a ref; changing props must not reset validation of
// the user's in-progress name rows. Compute it only on the first render.
const [initiallyInvalidNameFields] = useState(() => NAME_FIELDS.filter(field =>
@@ -687,7 +688,7 @@ export default function BibEntryForm({
sourceEntry.getField(fieldName).toEditableString() === canonical
? textareaSourceValues?.[fieldName] ?? canonical
: canonical
const unbalanced = !hasBalancedCurlyBraces(value)
const unbalanced = !draggingName && !hasBalancedCurlyBraces(value)
const braceFeedback = typeOfField !== 'name' && unbalanced && (
<BibEntryFeedback message={t('curly_braces_must_be_balanced')} />
)
@@ -704,23 +705,21 @@ export default function BibEntryForm({
handleFieldChange(fieldName, val, historyUserEvent, fieldIsValid, nameDraft)
}
error={!!message}
hideBraceErrors={draggingName}
onDraggingChange={setDraggingName}
idPrefix={idPrefix}
/>
) : typeOfField === 'large' ? (
<OLFormControl
as="textarea"
ref={isAbstractPreview ? undefined : resizeTextarea}
id={`${idPrefix}${fieldName}`}
className={`${isOptional ? 'bibtex-form-input ' : ''}${
className={`${isAbstractPreview ? '' : 'bibtex-auto-textarea '}${isOptional ? 'bibtex-form-input ' : ''}${
fieldName === 'abstract' ? 'bibtex-abstract-textarea' : ''
} ${message || unbalanced ? 'is-invalid' : ''}`}
maxLength={fieldName === 'abstract' ? '8192' : '4096'}
autoComplete="off"
value={value}
onChange={e => handleFieldChange(fieldName, e.target.value)}
onInput={isAbstractPreview ? undefined : event => {
resizeTextarea(event.currentTarget)
}}
rows={isAbstractPreview ? 18 : 1}
/>
) : (
@@ -803,7 +802,7 @@ export default function BibEntryForm({
<OLFormLabel htmlFor={`${idPrefix}ref-key`}>
{t('citation_key')}
</OLFormLabel>
<div className="bibtex-field-with-actions">
<div className="bibtex-field-with-actions bibtex-citation-key">
<OLFormControl
className={`bibtex-form-input ${
citationKeyMessage ? 'is-invalid' : ''
@@ -830,20 +829,25 @@ export default function BibEntryForm({
placeholder={id ? 'e.g. Doe0000' : generateAvailableCitationKey(
draft, existingIds, kind === 'new' ? undefined : sourceEntry.key
)}
append={
<OLTooltip id={`${idPrefix}generate-citation-key`} description={t('generate')}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}>
<OLIconButton
type="button"
icon="autorenew"
className="bibtex-generate-key"
accessibilityLabel={t('generate')}
variant="ghost"
size="sm"
onMouseDown={event => event.preventDefault()}
onClick={() => handleGenerateKey(true)}
/>
</OLTooltip>
}
/>
{citationKeyMessage && (
<BibEntryFeedback message={citationKeyMessage} />
)}
<div className="bibtex-form-actions">
<OLButton
variant="secondary"
size="sm"
onMouseDown={event => event.preventDefault()}
onClick={() => handleGenerateKey(true)}
>
{t('generate')}
</OLButton>
</div>
</div>
<OLFormText>{t('citation_key_autogenerate_helper')}</OLFormText>
</OLFormGroup>
@@ -19,9 +19,12 @@ type Props = {
search: string
onSearchChange: (search: string) => void
openDocName: string | null
zoteroAvailable: boolean
zoteroLinked: boolean
onAddFromZotero: () => void
onAddPaste: () => void
onAddManual: () => void
onAddFromOrcid?: () => void
onAddFromOrcid: () => void
}
function BibEntryListPanel({
@@ -32,6 +35,9 @@ function BibEntryListPanel({
onAddPaste,
onAddManual,
onAddFromOrcid,
zoteroAvailable,
zoteroLinked,
onAddFromZotero,
}: Props) {
const { t } = useTranslation()
@@ -96,14 +102,19 @@ function BibEntryListPanel({
{t('enter_manually')}
</OLDropdownItem>
{onAddFromOrcid ? (
<OLDropdownItem
description={t('search_orcid_by_name_or_id')}
onClick={onAddFromOrcid}
>
{t('import_from_orcid_org')}
<OLDropdownItem
description={t('search_orcid_by_name_or_id')}
onClick={onAddFromOrcid}
>
{t('import_from_provider', { provider: 'ORCID.org' })}
</OLDropdownItem>
{zoteroAvailable && (
<OLDropdownItem disabled={!zoteroLinked} onClick={onAddFromZotero}>
{t('import_from_provider', { provider: 'Zotero' })}
</OLDropdownItem>
) : null}
)}
</OLDropdownMenu>
</OLDropdown>
</div>
@@ -29,10 +29,14 @@ import {
type BibImportRow,
} from '../utils/bib-import.ts'
import ZoteroImportSource from '../zotero/zotero-import-source'
import type { ZoteroGroup } from '../zotero/use-zotero-libraries'
type Props = {
zoteroGroups?: ZoteroGroup[]
show: boolean
/** Fired for the Import button; false means the guarded write was rejected. */
onImport: (entries: BibtexEntry[]) => Promise<boolean>
onImport: (entries: BibtexEntry[]) => boolean | Promise<boolean>
onHidden: () => void
/** Library (SaaS): pre-fill the paste step (Upload .bib file → same
* pipeline). Default empty string. */
@@ -44,6 +48,7 @@ export default function BibImportModal({
onImport,
onHidden,
initialText = '',
zoteroGroups,
}: Props) {
const { t } = useTranslation()
const [mode, setMode] = useState<'paste' | 'preview'>('paste')
@@ -199,6 +204,7 @@ export default function BibImportModal({
return (
<OLModal
backdrop="static"
data-bib-editor-dialog
show={show}
onHide={handleHidden}
@@ -224,11 +230,31 @@ export default function BibImportModal({
<OLModalTitle className="visually-hidden">{t('preview')}</OLModalTitle>
</>
) : (
<OLModalTitle>{t('add_reference')}</OLModalTitle>
<OLModalTitle>
{zoteroGroups ? t('import_from_provider', { provider: 'Zotero' }) : t('add_reference')}
</OLModalTitle>
)}
</OLModalHeader>
{mode === 'paste' ? (
{zoteroGroups && (
<ZoteroImportSource
groups={zoteroGroups}
visible={mode === 'paste'}
onCancel={handleHidden}
onPreview={bibliography => {
const items = splitImportText(bibliography)
const previewRows = items.map((item, index) =>
buildImportRow(item, index, 'could_not_parse_this_reference')
)
setRows(previewRows)
setCheckedRowIds(previewRows.filter(row => row.status === 'ok').map(row => row.rowId))
setImportFailed(false)
setPreviewDone(true)
setMode('preview')
}}
/>
)}
{mode === 'paste' && !zoteroGroups && (
<>
<OLModalBody className="bibtex-import-form">
<div className="form-group">
@@ -267,7 +293,8 @@ export default function BibImportModal({
</OLButton>
</OLModalFooter>
</>
) : (
)}
{mode === 'preview' && (
<>
<OLModalBody>
{previewEmpty ? (
@@ -344,7 +371,7 @@ export default function BibImportModal({
disabled={previewEmpty || !previewDone || checkedCount === 0 || importing}
onClick={() => void handleImport()}
>
{importing ? '…' : t('save')}
{importing ? '…' : t(zoteroGroups ? 'import' : 'save')}
</OLButton>
</div>
</div>
@@ -81,6 +81,7 @@ export default function BibManualModal({
return (
<OLModal
backdrop="static"
data-bib-editor-dialog
show={show}
onHide={handleHide}
@@ -1,9 +1,8 @@
/** Name-list editor with local invalid drafts and stable focus behavior. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import OLButton from '@/shared/components/ol/ol-button'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormText from '@/shared/components/ol/ol-form-text'
import BibEntryFeedback from './bib-entry-feedback'
@@ -21,14 +20,6 @@ import {
import type { BibAuthorDraft } from '../utils/bib-author-draft'
import type { BibHistoryUserEvent } from '../context/bib-editor-context'
/** Resize an auto-growing field with a small height allowance. */
export const resizeTextarea = (textarea: HTMLTextAreaElement | null) => {
if (!textarea) return
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight + 4}px`
}
/**
* Name-list inputs and the raw text editor share one draft model. An invalid name
* never blocks writes of valid neighboring rows: each row retains its own last
@@ -41,6 +32,8 @@ export default function BibNameField({
initialDraft,
onChange,
error,
hideBraceErrors,
onDraggingChange,
idPrefix = '',
}: {
fieldName: string
@@ -52,6 +45,8 @@ export default function BibNameField({
fieldIsValid?: boolean,
draft?: BibAuthorDraft
) => void
hideBraceErrors: boolean
onDraggingChange: (dragging: boolean) => void
error?: boolean
idPrefix?: string
}) {
@@ -59,6 +54,15 @@ export default function BibNameField({
const [nameDraft, setNameDraft] = useState<BibAuthorDraft>(
() => initialDraft ?? createAuthorDraft(value)
)
const press = useRef<{ index: number; pointer: number; y: number; centerOffset: number; textOffset: number } | null>(null)
const [drag, setDrag] = useState<{ from: number; to: number; x: number; y: number } | null>(null)
const dragging = drag !== null
useLayoutEffect(() => {
if (!dragging) return
onDraggingChange(true)
return () => onDraggingChange(false)
}, [dragging, onDraggingChange])
const nameDraftRef = useRef(nameDraft)
// The parent supplies canonical BibTeX field text. Track the canonical value
// of our last edit so its echo is not mistaken for an external source change.
@@ -74,9 +78,7 @@ export default function BibNameField({
// Match the label target in both row and textarea modes.
const inputId = (index: number) => `${idPrefix}${fieldName}${index ? `-${index}` : ''}`
const handleTextareaRef = useCallback((element: HTMLInputElement | null) => {
const textarea = element as HTMLTextAreaElement | null
resizeTextarea(textarea)
const handleTextareaMount = useCallback((textarea: HTMLInputElement | null) => {
if (!textarea || !focusTextEndRef.current) return
focusTextEndRef.current = false
textarea.focus()
@@ -88,6 +90,8 @@ export default function BibNameField({
// different source value replaces the local name draft.
useEffect(() => {
if (value === expectedValueRef.current) return
press.current = null
setDrag(null)
const next = createAuthorDraft(value)
if (next.mode === 'rows') {
rowIdsRef.current = next.rows.map((_, index) =>
@@ -132,11 +136,10 @@ export default function BibNameField({
})
}
const addName = () => {
const addName = (after: number) => {
const current = nameDraftRef.current
if (current.mode !== 'rows') return
const filled = current.rows.filter(row => row.value.trim())
if (filled.length >= MAX_AUTHOR_ROWS) {
if (current.rows.length >= MAX_AUTHOR_ROWS) {
// Mode is a presentation choice, not a validation result. Retain the
// writable projection while showing the entire (possibly invalid) text.
focusNameIndexRef.current = null
@@ -154,16 +157,10 @@ export default function BibNameField({
}
const rows = current.rows.slice()
let index = rows.findIndex(row => !row.value.trim())
if (index === -1) {
index = rows.length
rows.push({ value: '', saved: '' })
rowIdsRef.current.push(nextRowIdRef.current++)
focusNameIndexRef.current = index
} else {
focusNameIndexRef.current = null
document.getElementById(inputId(index))?.focus()
}
const index = after + 1
rows.splice(index, 0, { value: '', saved: '' })
rowIdsRef.current.splice(index, 0, nextRowIdRef.current++)
focusNameIndexRef.current = index
update({ mode: 'rows', rows }, 'input.complete')
}
@@ -181,31 +178,47 @@ export default function BibNameField({
}, 'input.complete')
}
const moveName = (index: number, direction: -1 | 1) => {
const moveName = (index: number, target: number) => {
const current = nameDraftRef.current
if (current.mode !== 'rows') return
const target = index + direction
if (target < 0 || target >= current.rows.length) return
if (current.mode !== 'rows' || index < 0 || index === target ||
target < 0 || target >= current.rows.length) return
const rows = current.rows.slice()
const row = rows[index]
rows[index] = rows[target]
rows[target] = row
const rowIds = rowIdsRef.current.slice()
const rowId = rowIds[index]
rowIds[index] = rowIds[target]
rowIds[target] = rowId
rowIdsRef.current = rowIds
const [row] = rows.splice(index, 1)
rows.splice(target, 0, row)
const ids = rowIdsRef.current
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
update({ mode: 'rows', rows }, 'input.complete')
}
function endDrag() {
press.current = null
setDrag(null)
}
function targetIndex(handle: HTMLElement, centerY: number) {
const list = handle.closest('.bibtex-name-rows')!
let nearest = 0
let distance = Infinity
list.querySelectorAll('.bibtex-name-handle').forEach((slot, index) => {
const rect = slot.getBoundingClientRect()
const delta = Math.abs(centerY - (rect.top + rect.height / 2))
if (delta < distance) {
nearest = index
distance = delta
}
})
return nearest
}
if (nameDraft.mode === 'text') {
return (
<>
<OLFormControl
as="textarea"
ref={handleTextareaRef}
ref={handleTextareaMount}
id={`${idPrefix}${fieldName}`}
className={`bibtex-name-textarea bibtex-form-input ${error || !isAuthorDraftValid(nameDraft) ? 'is-invalid' : ''}`}
className={`bibtex-auto-textarea bibtex-name-textarea bibtex-form-input ${error || (!hideBraceErrors && !isAuthorDraftValid(nameDraft)) ? 'is-invalid' : ''}`}
maxLength="4096"
autoComplete="off"
value={nameDraft.value}
@@ -219,9 +232,8 @@ export default function BibNameField({
}}
placeholder="Doe, Jane and Mustermann, Max"
rows={1}
onInput={event => resizeTextarea(event.currentTarget)}
/>
{!isAuthorDraftValid(nameDraft) && (
{!hideBraceErrors && !isAuthorDraftValid(nameDraft) && (
<BibEntryFeedback
message={t('curly_braces_must_be_balanced')}
/>
@@ -233,78 +245,124 @@ export default function BibNameField({
return (
<div className="bibtex-form-names">
{nameDraft.rows.map((name, index) => (
<div key={rowIdsRef.current[index]} className="bibtex-field-with-actions">
<OLFormControl
ref={
focusNameIndexRef.current === index
? element => {
if (!element) return
focusNameIndexRef.current = null
element.focus()
}
: undefined
}
className={`bibtex-form-input ${error || !hasBalancedCurlyBraces(name.value) ? 'is-invalid' : ''}`}
maxLength="128"
type="text"
id={inputId(index)}
value={name.value}
onChange={event => setNameAt(index, event.target.value)}
placeholder="Doe, Jane"
/>
<div className="bibtex-name-rows">
{nameDraft.rows.map((name, index) => {
// Preview the name order in the existing input slots.
let displayed = name
if (drag) {
if (index === drag.to) displayed = { value: '', saved: '' }
else if (index >= drag.from && index < drag.to) displayed = nameDraft.rows[index + 1]
else if (index <= drag.from && index > drag.to) displayed = nameDraft.rows[index - 1]
}
return (
<div key={rowIdsRef.current[index]} className="bibtex-field-with-actions">
<OLFormControl
ref={
focusNameIndexRef.current === index
? element => {
if (!element) return
focusNameIndexRef.current = null
element.focus()
}
: undefined
}
className={`bibtex-form-input ${error || (!hideBraceErrors && !hasBalancedCurlyBraces(displayed.value)) ? 'is-invalid' : ''}`}
maxLength="128"
type="text"
id={inputId(index)}
value={displayed.value}
onChange={event => setNameAt(index, event.target.value)}
placeholder={drag?.to === index ? '' : 'Doe, Jane'}
prepend={
<OLIconButton
type="button"
icon="drag_indicator"
className="bibtex-name-handle"
variant="ghost"
size="sm"
accessibilityLabel={`${t('move_up')} / ${t('move_down')}: ${name.value}`}
disabled={nameDraft.rows.length < 2 || !name.value.trim()}
onPointerDown={event => {
if (!event.isPrimary || event.button !== 0 || event.currentTarget.matches(':disabled')) return
const handle = event.currentTarget
const rect = handle.getBoundingClientRect()
const input = handle.closest('.bibtex-field-with-actions')!.querySelector('input')!
const textLeft = input.getBoundingClientRect().left + input.clientLeft +
parseFloat(getComputedStyle(input).paddingLeft)
press.current = { index, pointer: event.pointerId, y: event.clientY,
centerOffset: rect.top + rect.height / 2 - event.clientY,
textOffset: textLeft - event.clientX }
handle.setPointerCapture(event.pointerId)
}}
onPointerMove={event => {
const start = press.current
if (!start || start.index !== index || start.pointer !== event.pointerId) return
if (!drag && Math.abs(event.clientY - start.y) < 3) return
const y = event.clientY + start.centerOffset
setDrag({ from: index, to: targetIndex(event.currentTarget, y),
x: event.clientX + start.textOffset, y })
}}
onPointerUp={event => {
const start = press.current
if (!start || start.pointer !== event.pointerId) return
const list = event.currentTarget.closest('.bibtex-name-rows')!
if (drag && list.contains(document.elementFromPoint(event.clientX, event.clientY))) {
moveName(index, targetIndex(event.currentTarget, event.clientY + start.centerOffset))
}
endDrag()
}}
onLostPointerCapture={endDrag}
onPointerCancel={endDrag}
onKeyDown={event => {
if (event.key === 'Escape') endDrag()
if (press.current || !['ArrowUp', 'ArrowDown'].includes(event.key)) return
event.preventDefault()
moveName(index, index + (event.key === 'ArrowUp' ? -1 : 1))
}}
/>
}
append={
<OLIconButton
type="button"
icon="add"
className="bibtex-name-add"
variant="ghost"
size="sm"
accessibilityLabel={t('add_name', { name: fieldName })}
onMouseDown={event => event.preventDefault()}
onClick={() => addName(index)}
/>
}
/>
{!hasBalancedCurlyBraces(name.value) && (
<BibEntryFeedback message={t('curly_braces_must_be_balanced')} />
)}
{!hideBraceErrors && !hasBalancedCurlyBraces(displayed.value) && (
<BibEntryFeedback message={t('curly_braces_must_be_balanced')} />
)}
<div className="bibtex-form-actions">
<OLIconButton
icon="arrow_upward"
variant="ghost"
accessibilityLabel={t('move_up')}
onMouseDown={event => event.preventDefault()}
onClick={() => moveName(index, -1)}
disabled={index === 0}
/>
<OLIconButton
icon="arrow_downward"
variant="ghost"
accessibilityLabel={t('move_down')}
onMouseDown={event => event.preventDefault()}
onClick={() => moveName(index, +1)}
disabled={index === nameDraft.rows.length - 1}
/>
<OLIconButton
icon="close"
unfilled
variant="ghost"
accessibilityLabel={fieldName === 'author'
? t('remove_author')
: t('remove_field', { a: fieldLabel(fieldName) })}
onMouseDown={event => event.preventDefault()}
onClick={() => removeName(index)}
disabled={nameDraft.rows.length === 1 && !name.value.trim()}
/>
</div>
</div>
))}
<OLTooltip
key={`tooltip-add-${fieldName}`}
id={`${idPrefix}tooltip-add-${fieldName}`}
description={t('add_name', { name: fieldName })}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<OLButton
leadingIcon="add"
variant="secondary"
size="sm"
className="bibtex-name-add"
onMouseDown={event => event.preventDefault()}
onClick={addName}
aria-label={t('add_name', { name: fieldName })}
/>
</OLTooltip>
<div className="bibtex-form-actions">
<OLIconButton
icon="close"
unfilled
variant="ghost"
accessibilityLabel={fieldName === 'author'
? t('remove_author')
: t('remove_field', { a: fieldLabel(fieldName) })}
onMouseDown={event => event.preventDefault()}
onClick={() => removeName(index)}
disabled={nameDraft.rows.length === 1 && !name.value.trim()}
/>
</div>
</div>
)
})}
{drag && createPortal(
<span aria-hidden="true" className="bibtex-dragged-name"
style={{ left: drag.x, top: drag.y }}>
{nameDraft.rows[drag.from]?.value}
</span>,
document.body
)}
</div>
</div>
)
}
@@ -57,6 +57,7 @@ export default function BibOrcidImportModal({ show, onHide }: Props) {
return (
<OLModal
backdrop="static"
data-bib-editor-dialog
show={show}
onHide={handleClose}
@@ -77,10 +78,10 @@ export default function BibOrcidImportModal({ show, onHide }: Props) {
>
{t('back')}
</OLButton>
<OLModalTitle className="visually-hidden">{t('import_from_orcid_org')}</OLModalTitle>
<OLModalTitle className="visually-hidden">{t('import_from_provider', { provider: 'ORCID.org' })}</OLModalTitle>
</>
) : (
<OLModalTitle>{t('import_from_orcid_org')}</OLModalTitle>
<OLModalTitle>{t('import_from_provider', { provider: 'ORCID.org' })}</OLModalTitle>
)}
</OLModalHeader>
@@ -0,0 +1,35 @@
import { useEffect } from 'react'
import { type FetchError, getJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
export type ZoteroGroup = { id: string; name: string }
export function useZoteroLibraries(active: boolean) {
const { data, error, runAsync } = useAsync<{ groups: ZoteroGroup[] | null }, FetchError>()
useEffect(() => {
if (!active) return
let controller: AbortController
function refresh() {
controller?.abort()
controller = new AbortController()
runAsync(getJSON<ZoteroGroup[] | null>('/user/zotero/groups', {
signal: controller.signal, cache: 'no-store',
}).then(groups => ({ groups })))
.catch(() => {}) // The menu uses the response status below.
}
refresh()
window.addEventListener('focus', refresh)
return () => {
controller.abort()
window.removeEventListener('focus', refresh)
}
}, [active, runAsync])
const response = error?.response
const available = response
? response.status !== 404 && !!response.headers.get('content-type')?.includes('application/json')
: !!data && (data.groups === null || Array.isArray(data.groups))
return { available, groups: available && !error ? data?.groups ?? null : null }
}
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { getJSON } from '@/infrastructure/fetch-json'
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 OLButton from '@/shared/components/ol/ol-button'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
import type { ZoteroGroup } from './use-zotero-libraries'
type ZoteroCollection = {
key: string
name: string
parentCollection: string | null
}
function collectionOptions(collections: ZoteroCollection[]) {
const byKey = new Map(collections.map(c => [c.key, c]))
return collections.map(collection => {
const path = [collection.name]
const visited = new Set([collection.key])
let parentKey = collection.parentCollection
while (parentKey && !visited.has(parentKey)) {
const parent = byKey.get(parentKey)
if (!parent) break
path.unshift(parent.name)
visited.add(parentKey)
parentKey = parent.parentCollection
}
return { key: collection.key, label: path.join(' / ') }
}).sort((a, b) => a.label.localeCompare(b.label))
}
type Props = {
groups: ZoteroGroup[]
visible: boolean
onCancel: () => void
onPreview: (bibliography: string) => void
}
export default function ZoteroImportSource({ groups, visible, onCancel, onPreview }: Props) {
const { t } = useTranslation()
const [groupId, setGroupId] = useState('')
const [collectionKey, setCollectionKey] = useState('')
const [format, setFormat] = useState('bibtex')
const {
data: collections, runAsync: loadCollections, reset,
isLoading: collectionsLoading, isError: collectionsError, isSuccess,
} = useAsync<ZoteroCollection[] | null>({ status: 'pending' })
const { runAsync: loadExport, isLoading: loading, isError: failed } =
useAsync<{ bibliography: string }>()
const collectionsFailed = collectionsError || (isSuccess && collections === null)
const request = useRef<AbortController | null>(null)
const options = useMemo(() => collectionOptions(collections ?? []), [collections])
useEffect(() => {
const controller = new AbortController()
const query = groupId ? `?group_id=${encodeURIComponent(groupId)}` : ''
reset()
loadCollections(getJSON<ZoteroCollection[] | null>(`/user/zotero/collections${query}`, {
signal: controller.signal,
})).catch(() => {}) // useAsync exposes the error to the form.
return () => controller.abort()
}, [groupId, loadCollections, reset])
useEffect(() => () => request.current?.abort(), [])
async function preview() {
if (request.current) return
const controller = new AbortController()
request.current = controller
const query = new URLSearchParams({ format })
if (groupId) query.set('group_id', groupId)
if (collectionKey) query.set('collection_key', collectionKey)
try {
const { bibliography } = await loadExport(getJSON<{ bibliography: string }>(
`/user/zotero/export?${query}`, { signal: controller.signal }
))
if (!controller.signal.aborted) onPreview(bibliography)
} catch {
// useAsync exposes the error to the form.
} finally {
request.current = null
}
}
if (!visible) return null
return (
<>
<OLModalBody>
<OLFormGroup controlId="bib-zotero-library">
<OLFormLabel>{t('library')}</OLFormLabel>
<OLFormSelect value={groupId} disabled={loading} onChange={event => {
setGroupId(event.target.value)
setCollectionKey('')
}}>
<option value="">{t('my_library')}</option>
{groups.map(group => <option key={group.id} value={group.id}>{group.name}</option>)}
</OLFormSelect>
</OLFormGroup>
<OLFormGroup controlId="bib-zotero-collection">
<OLFormLabel>{t('collection')}</OLFormLabel>
<OLFormSelect value={collectionKey} disabled={loading || collectionsLoading}
onChange={event => setCollectionKey(event.target.value)}>
<option value="">{t('all_collections')}</option>
{!groupId && <option value="publications">{t('my_publications')}</option>}
{options.map(option => <option key={option.key} value={option.key}>{option.label}</option>)}
</OLFormSelect>
</OLFormGroup>
{collectionsFailed && <Notification type="error" content={t('zotero_could_not_load_collections')} />}
<OLFormGroup controlId="bib-zotero-format">
<OLFormLabel>{t('format')}</OLFormLabel>
<OLFormSelect value={format} disabled={loading} onChange={event => setFormat(event.target.value)}>
<option value="bibtex">BibTeX</option>
<option value="biblatex">BibLaTeX</option>
</OLFormSelect>
</OLFormGroup>
{failed && <Notification type="error" content={t('could_not_import_this_reference')} />}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onCancel}>{t('cancel')}</OLButton>
<OLButton variant="primary" disabled={loading || collectionsLoading} onClick={() => void preview()}>
{loading ? t('loading') : t('preview')}
</OLButton>
</OLModalFooter>
</>
)
}
@@ -475,6 +475,11 @@
flex-direction: column;
min-height: 0;
}
.bibtex-auto-textarea {
field-sizing: content;
width: 100%;
}
.bibtex-abstract-textarea {
flex: 1;
min-height: 0;
@@ -746,10 +751,6 @@
.bibtex-field-with-actions {
gap: 0rem 0.6rem;
}
.bibtex-name-add {
padding: 0 1em;
align-self: flex-end;
}
}
.bibtex-optional-field-row:has(> .bibtex-form-names) {
@@ -772,7 +773,6 @@
}
.bibtex-optional-field-row .bibtex-abstract-textarea {
height: auto !important;
min-height: calc(4.5em + 0.4rem + 2px) !important;
}
@@ -835,3 +835,56 @@
border-color: var(--bibtex-preview-input-border);
opacity: 1;
}
.bibtex-name-rows {
display: flex;
flex-direction: column;
gap: inherit;
}
.bibtex-dragged-name {
transform: translateY(-50%);
position: fixed;
z-index: 2000;
pointer-events: none;
white-space: nowrap;
color: var(--content-primary-themed);
}
.bibtex-name-rows .bibtex-name-handle {
touch-action: none;
user-select: none;
cursor: grab;
}
.bibtex-name-rows .bibtex-form-input.is-invalid {
background-image: none;
}
.bibtex-field-with-actions > .form-control-wrapper {
grid-area: 1 / 1;
min-width: 0;
}
.bibtex-name-handle, .bibtex-name-add, .bibtex-generate-key {
padding: 0;
pointer-events: auto;
}
body:has(.bibtex-dragged-name),
body:has(.bibtex-dragged-name) * {
cursor: grabbing !important;
}
.bibtex-citation-key {
grid-template-columns: minmax(0, 1fr);
.bibtex-form-input.is-invalid {
padding-inline-end: 56px;
background-position: right 32px center;
}
}
.bibtex-field-with-actions > .form-control-wrapper:has(.is-invalid) ~ .invalid-feedback {
display: block;
}
@@ -158,6 +158,25 @@ async function getCollections(req, res) {
}
}
async function exportReferences(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { group_id: groupId, collection_key: collectionKey, format } = req.query
res.set('Cache-Control', 'private, no-store')
if (!['bibtex', 'biblatex'].includes(format) ||
(groupId !== undefined && (typeof groupId !== 'string' || !/^\d+$/.test(groupId)))) {
return res.status(400).json({ message: 'Invalid Zotero export options' })
}
try {
const bibliography = await ZoteroApiClient.getLibraryBibtex(userId, groupId, format, collectionKey)
return res.json({ bibliography })
} catch (err) {
const info = OError.getFullInfo(err)
logger.error({ info }, 'failed to export Zotero references')
return res.status(info?.status || 500).json({ message: 'Failed to export Zotero references' })
}
}
/**
* DELETE /user/zotero
* Unlinks the user's Zotero account.
@@ -182,5 +201,6 @@ export default {
getConnectionStatus,
getGroups,
getCollections,
exportReferences,
unlink
}
@@ -16,6 +16,12 @@ export default {
ZoteroController.getCollections
)
webRouter.get(
'/user/zotero/export',
AuthenticationController.requireLogin(),
ZoteroController.exportReferences
)
// Unlink Zotero account
webRouter.delete(
'/user/zotero',