mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
Bib editor: add import from Zotero; improve UI
This commit is contained in:
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user