Bib Editor: the initial commit

This commit is contained in:
yu-i-i
2026-11-04 17:51:41 +01:00
parent 01d5002ea9
commit ca8fd03a2e
63 changed files with 12854 additions and 15 deletions
@@ -0,0 +1,29 @@
/**
* Visual editor provider for the bib-editor module.
* Registered via overleafModuleImports.visualEditorProviders in settings.
*
* This enables the Code/Visual toggle for .bib files so that clicking
* "Visual" opens the bibliography editor inside the main editor window
* instead of a separate sidebar rail panel.
*/
import React from 'react'
import { getFileExtension } from '@/features/source-editor/utils/file'
/**
* Stable lazy-loaded reference to the visual editor component.
* Must be created at module level so the reference is stable across renders.
*/
const BibEditorVisual = React.lazy(() => import('./components/bib-editor-panel'))
/** Unique id used for the per-file editor mode storage key. */
export const id = 'bib-editor'
export function isVisualEditorAvailable(filename: string): boolean {
return getFileExtension(filename) === 'bib'
}
export function getVisualEditorComponent(
filename: string
): typeof BibEditorVisual | null {
return isVisualEditorAvailable(filename) ? BibEditorVisual : null
}
@@ -0,0 +1,118 @@
/**
* Toolbar for selecting and acting on the entries currently visible in the list.
* Selection itself is owned by BibEditorPanel; this component only presents the
* state and sends user actions back to the panel.
*/
import React, { useId } from 'react'
import classnames from 'classnames'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLButton from '@/shared/components/ol/ol-button'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import { plural } from '../utils/plural'
import '../../stylesheets/bib-editor-panel.css'
type Props = {
filteredLength: number
allSelected: boolean
selectedIds: string[]
onToggleSelectAll?: (kind: 'all' | 'none') => void
onBulkDelete?: () => void
onBulkDownload?: () => void
}
function BibBulkActionsBar({
filteredLength,
allSelected,
selectedIds,
onToggleSelectAll,
onBulkDelete,
onBulkDownload,
}: Props) {
const { t } = useTranslation()
const selectAllId = useId()
const count = selectedIds.length || filteredLength
return (
<div
className={classnames('bibtex-bulk-actions-bar', {
'bibtex-bulk-actions-bar-selected': selectedIds.length > 0,
})}
>
<div className="wide-target-checkbox bibtex-bulk-actions-select-all">
<span className="wide-target-checkbox-box">
<OLFormCheckbox
id={selectAllId}
onClick={e => e.stopPropagation()}
onChange={e =>
onToggleSelectAll?.(e.target.checked ? 'all' : 'none')
}
checked={allSelected}
autoComplete="off"
aria-label={t('select_all_entries')}
/>
<label
className="wide-target-checkbox-target"
htmlFor={selectAllId}
aria-hidden="true"
/>
</span>
</div>
<div className="bibtex-bulk-actions-count">
{`${count} ${plural(t, count, 'reference', 'references')}`}
</div>
{selectedIds.length > 0 && onBulkDelete ? (
<OLButton
size="sm"
variant="ghost"
leadingIcon={<MaterialIcon type="delete" unfilled />}
onClick={e => {
e.stopPropagation()
onBulkDelete()
}}
aria-label={t('delete')}
>
<span className="bibtex-bulk-actions-btn-label">
{t('delete')}
</span>
</OLButton>
) : null}
{selectedIds.length > 0 && onBulkDownload ? (
<OLButton
size="sm"
variant="ghost"
leadingIcon="download"
onClick={e => {
e.stopPropagation()
onBulkDownload()
}}
aria-label={t('download')}
>
<span className="bibtex-bulk-actions-btn-label">
{t('download')}
</span>
</OLButton>
) : null}
{selectedIds.length > 0 ? (
<OLIconButton
className="bibtex-bulk-actions-close"
size="sm"
variant="ghost"
icon="close"
onClick={e => {
e.stopPropagation()
onToggleSelectAll?.('none')
}}
aria-label={t('close')}
/>
) : null}
</div>
)
}
export default React.memo(BibBulkActionsBar)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
/**
* One bibliography row in the entry list. The row shows compact metadata and
* delegates selection/detail editing to the surrounding list/panel.
* Each source occurrence has its own id, including duplicate citation keys.
*/
import React, { useCallback, useMemo, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { plural } from '../utils/plural'
import type { PositionedBibtexEntry } from '../utils/bib-types'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import type { BibSourceDiagnostic } from '../utils/bib-source-diagnostics'
import {
getBibEntryDiagnostics,
translateBibSourceDiagnostic,
} from '../utils/bib-entry-diagnostics'
import { missingRequiredLabels, splitHighlighted } from '../utils/preview-model'
import { formatWarningLabels } from '../utils/bib-validate'
import { citationAuthorSurnames, humanizeCitationHeading } from '../utils/overleaf-type-map.ts'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import BibEntryWarningSummary from './bib-entry-warning-summary'
function Highlighted({ text, search }: { text: string; search: string }) {
const segments = splitHighlighted(text, search)
if (segments.length <= 1 && !segments[0]?.match) {
return <>{text}</>
}
return (
<>
{segments.map((s, i) =>
s.match ? (
<mark key={i}>{s.text}</mark>
) : (
<React.Fragment key={i}>{s.text}</React.Fragment>
)
)}
</>
)
}
type EntryStatusTooltipProps = {
id: string
description: React.ReactNode
ariaLabel: string
icon: 'error' | 'warning'
className?: string
}
function EntryStatusTooltip({
id,
description,
ariaLabel,
icon,
className = 'bibtex-entry-error-icon',
}: EntryStatusTooltipProps) {
return (
<OLTooltip
id={id}
description={description}
tooltipProps={{ className: 'bibtex-tooltip-errors' }}
overlayProps={{ placement: 'right', trigger: ['hover', 'focus'] }}
>
<button type="button" className={className} aria-label={ariaLabel}>
<span className="material-symbols" aria-hidden="true" translate="no">
{icon}
</span>
</button>
</OLTooltip>
)
}
// The parsed entry object is unchanged by position-only CodeMirror edits.
// Keep metadata, warning computation and tooltip rendering independent of the
// outer row's current source coordinates and selection state.
const BibEntryCardContent = React.memo(function BibEntryCardContent({
bibEntry,
displayKey,
missingFields,
diagnostics,
search,
rowId,
duplicate,
}: {
bibEntry: BibtexEntry
displayKey: string
missingFields: string[]
diagnostics: BibSourceDiagnostic[]
search: string
rowId: string
duplicate?: boolean
}) {
const { t } = useTranslation()
const title = bibEntry.getField('title').toDisplayString()
const badlyFormattedFields = formatWarningLabels(bibEntry)
const hasErrors = missingFields.length > 0
const hasWarnings = hasErrors || badlyFormattedFields.length > 0
const warningDescription = (
<BibEntryWarningSummary
missingFields={missingFields}
badlyFormattedFields={badlyFormattedFields}
requiredFieldsTitle={plural(
t,
missingFields.length,
'missing_field_for_entry_type',
'missing_fields_for_entry_type',
{ entryType: bibEntry.type.toLowerCase() }
)}
badlyFormattedFieldsTitle={plural(
t,
badlyFormattedFields.length,
'badly_formatted_field',
'badly_formatted_fields'
)}
listFields
/>
)
const syntaxErrorMessages = diagnostics.map(diagnostic =>
translateBibSourceDiagnostic(diagnostic, t)
)
const syntaxErrorDescription = (
<>
<strong>
{plural(t, syntaxErrorMessages.length, 'entry_has_syntax_error', 'entry_has_syntax_errors')}:
</strong>
<ul>
{syntaxErrorMessages.map((message, index) => (
<li key={`${index}-${message}`}>{message}</li>
))}
</ul>
</>
)
const duplicateKeyMessage = t('bibtex_duplicates_keys')
const citationHeading = useMemo(
() => humanizeCitationHeading(bibEntry),
[bibEntry]
)
const year = bibEntry.getYear().trim()
const yearSuffix = year ? ` (${year})` : ''
const citationAuthor =
yearSuffix && citationHeading.endsWith(yearSuffix)
? citationHeading.slice(0, -yearSuffix.length)
: citationHeading
const hasCitationPerson = citationAuthorSurnames(bibEntry).surnames.length > 0
return (
<div className="bibtex-entry-card-content">
<div className="bibtex-entry-card-header">
<div className="bibtex-entry-card-key">
<Highlighted text={displayKey} search={search} />
</div>
{syntaxErrorMessages.length > 0 && (
<EntryStatusTooltip
id={`tooltip-entry-syntax-errors-${rowId}`}
description={syntaxErrorDescription}
ariaLabel={syntaxErrorMessages.join(' + ')}
icon="error"
/>
)}
{duplicate && (
<EntryStatusTooltip
id={`tooltip-duplicate-key-${rowId}`}
description={duplicateKeyMessage}
ariaLabel={duplicateKeyMessage}
icon="warning"
/>
)}
{hasWarnings && (
<EntryStatusTooltip
id={`tooltip-missed-field-${rowId}`}
description={warningDescription}
ariaLabel={t('entry_has_warnings')}
icon="warning"
className="bibtex-entry-error-icon bibtex-entry-error-icon-warning"
/>
)}
</div>
<div className="bibtex-entry-card-details">
{title && (
<div className="bibtex-entry-card-title">
<span>
<Highlighted text={title} search={search} />
</span>
</div>
)}
<div className="bibtex-entry-card-meta">
{hasCitationPerson && (
<div className="bibtex-entry-card-author">
<span>
<Highlighted text={citationAuthor} search={search} />
</span>
</div>
)}
{year && (
<div className="bibtex-entry-card-year">
<span>
<Highlighted text={year} search={search} />
</span>
</div>
)}
</div>
</div>
</div>
)
})
function BibEntryCard({
entry,
displayKey,
onSelect,
previewing,
checked,
onToggleSelect,
search,
rowId,
duplicate,
}: {
entry: PositionedBibtexEntry
displayKey: string
onSelect: (entry: PositionedBibtexEntry) => void
previewing: boolean
checked: boolean
onToggleSelect?: (id: string) => void
/** Raw search text used to highlight matching pieces of the card. */
search: string
rowId: string
/** True when another entry has the same citation key. */
duplicate?: boolean
}) {
const t = useTranslation().t
const bibEntry = entry.entry
// Cache labels for both the row status and its memoized warning tooltip.
const missingFields = useMemo(
() => missingRequiredLabels(bibEntry, t('or')),
[bibEntry, t]
)
const handleCardKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const target = e.target as Element | null
if (target?.closest('button, input, label')) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(entry)
}
},
[entry, onSelect]
)
const toggleId = rowId
const checkboxId = useId()
return (
<div
id={`bibtex-entry-card-${toggleId}`}
className={[
'bibtex-entry-card',
'bibtex-entry-card-compact',
'bibtex-entry-card-clickable',
checked ? 'bibtex-entry-card-selected' : '',
missingFields.length > 0 ? 'bibtex-entry-card-errors' : '',
previewing ? 'bibtex-entry-card-previewing' : '',
]
.filter(Boolean)
.join(' ')}
role="button"
tabIndex={0}
onClick={e => {
const target = e.target as Element | null
if (target?.closest('input, label')) return
onSelect(entry)
}}
onKeyDown={handleCardKeyDown}
>
<div className="wide-target-checkbox bibtex-entry-card-checkbox">
<span className="wide-target-checkbox-box">
<OLFormCheckbox
id={checkboxId}
onClick={e => e.stopPropagation()}
onChange={() => onToggleSelect?.(toggleId)}
checked={checked}
autoComplete="off"
aria-label={t('select_entry')}
/>
<label
className="wide-target-checkbox-target"
htmlFor={checkboxId}
aria-hidden="true"
/>
</span>
</div>
<BibEntryCardContent
bibEntry={bibEntry}
displayKey={displayKey}
missingFields={missingFields}
diagnostics={getBibEntryDiagnostics(entry)}
search={search}
rowId={rowId}
duplicate={duplicate}
/>
</div>
)
}
export default React.memo(BibEntryCard)
@@ -0,0 +1,35 @@
type BibEntryFeedbackProps = {
message: string
variant?: 'error' | 'warning'
}
const variantOptions = {
error: {
className: 'text-danger',
icon: 'error',
},
warning: {
className: 'text-warning',
icon: 'warning',
},
} as const
export default function BibEntryFeedback({
message,
variant = 'error',
}: BibEntryFeedbackProps) {
const { className, icon } = variantOptions[variant]
return (
<div className="invalid-feedback">
<small className={`${className} form-text`}>
<span className="form-text-inner">
<span className="material-symbols" aria-hidden="true" translate="no">
{icon}
</span>
<span>{message}</span>
</span>
</small>
</div>
)
}
@@ -0,0 +1,301 @@
/** Entry-type and optional-field pickers, independent of draft and save state. */
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { BIBTEX_IDENTIFIER_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
import { getAllEntryTypes } from '@shared/bibtex/entry-types.mts'
import OLButton from '@/shared/components/ol/ol-button'
import OLAutocomplete from '@/shared/components/ol/ol-autocomplete'
import MaterialIcon from '@/shared/components/material-icon'
import { NON_BIBLIOGRAPHY_ENTRY_TYPES } from '../utils/bib-constants'
import { OPTIONAL_FIELD_TAXONOMY } from '../utils/overleaf-type-map.ts'
import { fieldGroupLabel } from '../utils/bib-field-labels'
export const displayCustomType = (type: string) =>
type.charAt(0).toUpperCase() + type.slice(1)
/** Keep a fixed-position combobox aligned with its trigger while open. */
function useTrackMenuPosition(
open: boolean,
updatePosition: () => void,
menuRef?: RefObject<HTMLDivElement | null>
) {
useEffect(() => {
if (!open) return
const onScroll = (event: Event) => {
// Scrolling the dropdown itself should not reposition its trigger.
if (event.target instanceof Node && menuRef?.current?.contains(event.target)) return
updatePosition()
}
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', onScroll, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', onScroll, true)
}
}, [open, updatePosition, menuRef])
}
/** Searchable entry-type picker using the same autocomplete as Add field. */
export function BibEntryTypeCombobox({
id,
selected,
currentType,
entryTypes,
onSelect,
}: {
id: string
selected: string
currentType: string
entryTypes: ReturnType<typeof getAllEntryTypes>
onSelect: (type: string) => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [position, setPosition] = useState<{
left: number
width: number
top?: number
bottom?: number
} | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const updatePosition = useCallback(() => {
const rect = buttonRef.current?.getBoundingClientRect()
if (!rect) return
const spaceBelow = window.innerHeight - rect.bottom
const expandUp = spaceBelow < 360 && rect.top > spaceBelow
setPosition({
left: rect.left,
width: rect.width,
...(expandUp
? { bottom: window.innerHeight - rect.top + 4 }
: { top: rect.bottom + 4 }),
})
}, [])
const close = useCallback(() => {
setOpen(false)
setPosition(null)
}, [])
useLayoutEffect(() => {
if (open && position) inputRef.current?.focus()
}, [open])
useTrackMenuPosition(open, updatePosition, menuRef)
const select = useCallback((name: string) => {
const type = name.trim().toLowerCase()
if (!BIBTEX_IDENTIFIER_REGEX.test(type)) return
if (NON_BIBLIOGRAPHY_ENTRY_TYPES.has(type)) return
onSelect(type)
close()
}, [close, onSelect])
const canCreate = useCallback((name: string) => {
const type = name.trim().toLowerCase()
return BIBTEX_IDENTIFIER_REGEX.test(type) &&
!NON_BIBLIOGRAPHY_ENTRY_TYPES.has(type) &&
type !== currentType &&
!entryTypes.some(entryType => entryType.key === type)
}, [currentType, entryTypes])
return (
<div
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
close()
}
}}
>
<button
id={id}
ref={buttonRef}
type="button"
className="form-control text-start d-flex justify-content-between align-items-center w-100 entry-type-selector-btn"
onClick={() => {
if (open) close()
else {
updatePosition()
setOpen(true)
}
}}
>
<span>{selected || t('select')}</span>
<MaterialIcon type="keyboard_arrow_down" />
</button>
{open && position && (
<div
ref={menuRef}
className={`button-autocomplete-container position-fixed rounded-1 shadow p-2${position.bottom !== undefined ? ' expand-up' : ''}`}
style={position}
>
<OLAutocomplete
items={[
...entryTypes.map(entryType => ({
value: entryType.key,
label: entryType.label,
})),
...(currentType && !entryTypes.some(entryType => entryType.key === currentType)
? [{ value: currentType, label: displayCustomType(currentType) }]
: []),
]}
onChange={select}
placeholder={t('enter_type')}
label={t('entry_type')}
showLabel={false}
isOpen={open}
allowCreate={canCreate}
createOptionPrefix="+ Create"
expandUp={position.bottom !== undefined}
ref={inputRef}
/>
</div>
)}
</div>
)
}
/**
* Upward-opening field picker. OLAutocomplete owns filtering, keyboard navigation,
* highlighted-item state and create-item behavior; this wrapper owns the trigger,
* fixed viewport positioning and which fields are offered for the current entry.
*/
export function BibAddFieldCombobox({
placeholder,
offered,
standardFields,
existingFields,
onSelect,
}: {
placeholder: string
offered: string[]
standardFields: string[]
existingFields: string[]
onSelect: (field: string) => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState<{
left: number
bottom: number
} | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const filterInputRef = useRef<HTMLInputElement>(null)
const items = useMemo(() => {
const byField = new Set(offered)
return OPTIONAL_FIELD_TAXONOMY.flatMap(group =>
group.fields
.filter(field => byField.has(field.field))
.map(field => ({
value: field.field,
label: field.label,
group: fieldGroupLabel(group.label, key => t(key)),
}))
)
}, [offered, t])
const standardFieldSet = useMemo(
() => new Set(standardFields.map(field => field.toLowerCase())),
[standardFields]
)
const existingFieldSet = useMemo(
() => new Set(existingFields.map(field => field.toLowerCase())),
[existingFields]
)
const canCreate = useCallback(
(input: string) => {
const name = input.trim()
if (!BIBTEX_IDENTIFIER_REGEX.test(name)) return false
const normalized = name.toLowerCase()
return !standardFieldSet.has(normalized) && !existingFieldSet.has(normalized)
},
[existingFieldSet, standardFieldSet]
)
const updateMenuPosition = useCallback(() => {
const button = buttonRef.current
if (!button) return
const rect = button.getBoundingClientRect()
setMenuPosition({
left: rect.left,
bottom: Math.max(8, window.innerHeight - rect.top + 4),
})
}, [])
const close = useCallback(() => {
setOpen(false)
setMenuPosition(null)
}, [])
const openMenu = useCallback(() => {
updateMenuPosition()
setOpen(true)
}, [updateMenuPosition])
useTrackMenuPosition(open, updateMenuPosition)
const handleSelect = useCallback((field: string) => {
if (!field) {
filterInputRef.current?.focus()
return
}
onSelect(field)
close()
}, [close, onSelect])
return (
<div
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
close()
}
}}
>
<OLButton
ref={buttonRef}
type="button"
variant="secondary"
size="sm"
leadingIcon="add"
className="bibtex-add-field-button"
onClick={() => (open ? close() : openMenu())}
>
{t('add_field')}
</OLButton>
{open && menuPosition !== null && (
<div
className="button-autocomplete-container position-fixed rounded-1 shadow p-2 expand-up"
style={{
top: 'auto',
bottom: menuPosition.bottom,
left: menuPosition.left,
}}
>
<OLAutocomplete
key="optional-field-autocomplete"
items={items}
onChange={handleSelect}
placeholder={placeholder}
label={t('add_optional_field')}
showLabel={false}
allowCreate={canCreate}
createOptionPrefix="+ Create"
expandUp
isOpen={open}
ref={filterInputRef}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,917 @@
/**
* Shared form for editing a parsed entry or creating a new one. It emits draft
* changes (including temporarily invalid input); the host decides when and
* whether to commit them to CodeMirror. Abstract uses the same field model.
*/
import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from 'react'
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 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'
import OLFormText from '@/shared/components/ol/ol-form-text'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import BibEntryFeedback from './bib-entry-feedback'
import {
formRowsFor,
getFieldType,
isNameField,
NAME_FIELDS,
missingRequiredFieldGroups,
optionalVisibleFor,
} from '../utils/bib-types'
import {
OPTIONAL_FIELD_NAMES,
offeredOptionalFields,
requiredFieldNames,
} from '../utils/overleaf-type-map.ts'
import {
hasBalancedCurlyBraces,
hasBlockingValidationErrors,
validateEntry,
hasUnbalancedCurlyBraces,
isFormatWarning,
isWritableCitationKey,
} from '../utils/bib-validate'
import { helperKeyForField } from '../utils/bib-form-helper'
import { fieldLabel } from '../utils/bib-field-labels'
import { fromBibFormString, sameBibFormValue, toBibFormString } from '../utils/bib-field-edit'
import {
createAuthorDraft,
isAuthorDraftValid,
} from '../utils/bib-author-draft'
import type { BibAuthorDraft, BibNameDrafts } from '../utils/bib-author-draft'
import { generateAvailableCitationKey } from '../utils/bib-key-generation'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import type {
BibFieldWriteTarget,
BibFormChangeHandler,
BibHistoryUserEvent,
} from '../context/bib-editor-context'
type Props = {
/** The entry values the form starts from, including any unsaved local draft. */
entry: BibtexEntry
/** Invalid, source-preserving draft for each name-list field. */
initialNameDrafts?: BibNameDrafts
/** Authoritative parsed source value; differs from `entry` while a draft is invalid. */
sourceEntry?: BibtexEntry
/** Original editable spelling for multiline fields in the current source. */
textareaSourceValues?: Record<string, string>
kind: 'existing' | 'new'
/** IDs of all existing entries (citation-key collision hints) */
existingIds?: string[]
/** Receives every draft edit, including locally retained invalid values. */
onFormChange: BibFormChangeHandler
/** Called when the host's primary action is pressed after validation. */
onChecked?: (
entry: BibtexEntry,
kind: 'existing' | 'new'
) => void | Promise<boolean>
/** In previews, render Abstract in its own tab using the same field editor. */
previewTab?: 'details' | 'abstract'
/** Switch the preview host to its dedicated Abstract tab. */
onSelectAbstract?: () => void
/** Prefix for DOM ids when more than one form can be mounted. */
idPrefix?: string
/** Optional section shared across entries while the current BibTeX file is open. */
optionalExpanded?: boolean
onOptionalExpandedChange?: (expanded: boolean) => void
/** Incremented when undo/redo requires discarding this entry's local draft. */
draftReset?: { version: number }
/** Disable the primary action while the host is saving. */
submitDisabled?: boolean
/** Supplies the modal host with the current submit action and disabled state. */
onFooterStateChange?: (state: { disabled: boolean; submit: () => void }) => void
}
import { BibEntryTypeCombobox, BibAddFieldCombobox, displayCustomType } from './bib-entry-form-comboboxes'
import BibNameField, { resizeTextarea } from './bib-name-field'
const ENTRY_TYPES = getAllEntryTypes()
type SubmissionBlockers = {
kind: Props['kind']
submitDisabled: boolean
unbalancedCurlyBraces: boolean
hasPendingStructuralErrors: boolean
validationResult: ReturnType<typeof validateEntry>
}
function shouldBlockSubmission({
kind,
submitDisabled,
unbalancedCurlyBraces,
hasPendingStructuralErrors,
validationResult,
}: SubmissionBlockers): boolean {
return (
submitDisabled ||
unbalancedCurlyBraces ||
(kind === 'new' && (
hasPendingStructuralErrors ||
hasBlockingValidationErrors(validationResult)
))
)
}
export default function BibEntryForm({
entry,
initialNameDrafts,
sourceEntry = entry,
textareaSourceValues,
kind,
existingIds = [],
onFormChange,
onChecked,
submitDisabled = false,
onFooterStateChange,
onSelectAbstract,
previewTab,
idPrefix = '',
draftReset,
optionalExpanded: sharedOptionalExpanded,
onOptionalExpandedChange,
}: Props) {
const { t } = useTranslation()
// 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 =>
!isAuthorDraftValid(
initialNameDrafts?.[field] ??
createAuthorDraft(toBibFormString(entry.getField(field), field))
)
))
const [draft, setDraft] = useState(entry)
const draftRef = useRef(entry)
// BibtexFieldValue.toEditableString() uses canonical escaping (e.g. # -> ##).
// Do not replace the user's input mid-edit: an incomplete #name can become a
// named string once its closing # is typed. Keep raw text while the parsed
// field value still matches what this input last produced.
const fieldInputRef = useRef(
new Map<string, { text: string; canonical: string }>()
)
const [entryTypeSelected, setEntryTypeSelected] = useState(kind !== 'new')
const type = draft.type || 'article'
const id = draft.key || ''
// Previews share this state per open file; standalone forms own it locally.
const [localOptionalExpanded, setLocalOptionalExpanded] = useState(false)
const optionalExpanded = sharedOptionalExpanded ?? localOptionalExpanded
const setOptionalExpanded = onOptionalExpandedChange ?? setLocalOptionalExpanded
const [checked, setChecked] = useState(false)
// Optional controls remain mounted after their value is cleared. They disappear
// only when this form instance is recreated (for example, after reopening).
const [visibleOptionals, setVisibleOptionals] = useState<string[]>(() =>
optionalVisibleFor(type, entry)
)
const [removedOptionals, setRemovedOptionals] = useState<string[]>([])
// Each name-list row is validated separately: concatenating two malformed
// rows could otherwise accidentally produce balanced braces.
const pendingInvalidFieldsRef = useRef(new Set<string>(initiallyInvalidNameFields))
const nameDraftValid = (field: string) =>
!pendingInvalidFieldsRef.current.has(field)
// `draft` is the form's editable projection, not a second document. CodeMirror
// remains authoritative. A local draft can temporarily
// contain an invalid key or unmatched braces while the user is typing, but such
// a draft must not be serialized over the original source because doing so would
// discard text the visual parser could not represent.
const reportDraft = useCallback(
(
next: BibtexEntry,
historyUserEvent: BibHistoryUserEvent = 'input.type',
fieldName?: string,
fieldIsValid = true,
fieldTarget?: BibFieldWriteTarget,
nameDraft?: BibAuthorDraft
) => {
draftRef.current = next
setDraft(next)
// Validate the edited target separately from the whole draft: a field edit
// is allowed while the citation key or another field is temporarily invalid.
const citationKeyIsWritable = isWritableCitationKey(next.key)
const draftHasUnbalancedBraces = hasUnbalancedCurlyBraces(next)
// Keep invalid state keyed by a stable field name so it can be merged back
// into the parsed source entry when CodeMirror updates behind this form.
let fieldKey = fieldName ?? 'entry'
let targetValueIsValid: boolean
switch (fieldTarget?.kind) {
case 'field':
fieldKey = fieldTarget.name
targetValueIsValid = hasBalancedCurlyBraces(
next.getField(fieldTarget.name).toEditableString()
)
break
case 'citation-key':
fieldKey = 'id'
targetValueIsValid = citationKeyIsWritable
break
case 'entry-type':
fieldKey = 'entry-type'
targetValueIsValid = true
break
default:
targetValueIsValid = citationKeyIsWritable && !draftHasUnbalancedBraces
}
const targetIsValid = fieldIsValid && targetValueIsValid
if (targetIsValid) pendingInvalidFieldsRef.current.delete(fieldKey)
else pendingInvalidFieldsRef.current.add(fieldKey)
// The draft is safe to materialize when its key is either empty or valid,
// braces are balanced, and no structurally invalid field edits remain.
const draftIsValid =
citationKeyIsWritable &&
!draftHasUnbalancedBraces &&
pendingInvalidFieldsRef.current.size === 0
// Keep the entire draft in the parent even when it cannot yet be written
// to CodeMirror. The parent uses the target validity to gate this source
// mutation while retaining the full draft across remounts.
onFormChange(
next,
historyUserEvent,
targetIsValid,
fieldTarget,
draftIsValid,
nameDraft
)
},
[onFormChange]
)
// The preview remounts per entry identity, so selecting another entry
// creates a fresh form instance. Within one occurrence, the
// parsed entry remains the source of truth: if the document changes behind
// the form and the local draft no longer matches it, restore the parsed entry.
const parsedSignatureRef = useRef(entry.toString())
const draftResetVersionRef = useRef(draftReset?.version ?? 0)
useEffect(() => {
const nextSignature = entry.toString()
const parsedChanged = nextSignature !== parsedSignatureRef.current
const resetDraft =
draftReset != null && draftReset.version !== draftResetVersionRef.current
parsedSignatureRef.current = nextSignature
if (draftReset) draftResetVersionRef.current = draftReset.version
if (!parsedChanged && !resetDraft) return
// Invalid local values were never written to CodeMirror, so they cannot be
// represented in its undo history. On undo/redo, reset this form to the
// authoritative source entry instead of letting those values mask history.
if (resetDraft) {
pendingInvalidFieldsRef.current.clear()
fieldInputRef.current.clear()
draftRef.current = entry
setDraft(entry)
setChecked(false)
setRemovedOptionals([])
return
}
// A source edit from outside this form takes precedence over locally
// preserved input spelling. Local writes retain the same canonical value.
for (const [field, input] of fieldInputRef.current) {
if (!sameBibFormValue(input.canonical, toBibFormString(entry.getField(field), field))) {
fieldInputRef.current.delete(field)
}
}
if (draftRef.current.toString() === nextSignature) {
return
}
let merged = entry
const localDraft = draftRef.current
for (const field of pendingInvalidFieldsRef.current) {
if (field === 'id') {
merged = merged.setKey(localDraft.key)
} else if (field === 'entry-type') {
merged = merged.setType(localDraft.type)
} else if (field !== 'entry' && localDraft.hasField(field)) {
merged = merged.setField(field, localDraft.getField(field))
} else if (field !== 'entry') {
merged = merged.deleteField(field)
}
}
draftRef.current = merged
setDraft(merged)
setChecked(false)
// Intentionally exclude `draft`: it is local form state and must not make
// this synchronization effect run after every keystroke.
}, [entry, sourceEntry, kind, draftReset])
const entryTypeDef = getEntryType(type.toLowerCase())
// Pure validation feeds live format warnings in the preview and submit-time
// errors in the manual-entry modal. Required-field stars are independent.
const starMembers = new Set(missingRequiredFieldGroups(draft).flat())
const validationResult = useMemo(() => validateEntry(draft), [draft])
const sourceValidationResult = useMemo(() => validateEntry(sourceEntry), [sourceEntry])
const checkResult = checked ? validationResult : null
const unbalancedCurlyBraces =
hasUnbalancedCurlyBraces(draft) ||
NAME_FIELDS.some(field => !nameDraftValid(field))
// Preserve the order defined by the selected shared entry type.
const fieldRows = useMemo(() => formRowsFor(type), [type])
// Source updates may add fields while the form stays mounted. Merge those
// into the openable set without closing optionals the user previously added.
useEffect(() => {
const currentlyPopulated = optionalVisibleFor(type, entry).filter(
field => !removedOptionals.includes(field)
)
if (currentlyPopulated.length === 0) return
setVisibleOptionals(previous => {
const next = [...previous]
for (const field of currentlyPopulated) {
if (!next.includes(field)) next.push(field)
}
return next.length === previous.length ? previous : next
})
}, [type, entry, removedOptionals])
const optionalRows = useMemo(
() => optionalVisibleFor(type, draft, visibleOptionals),
[type, draft, visibleOptionals]
)
const pendingOptionalFocusRef = useRef<string | null>(null)
// The input for an added optional field exists only after React renders it.
useEffect(() => {
const field = pendingOptionalFocusRef.current
if (!field || !optionalRows.includes(field)) return
pendingOptionalFocusRef.current = null
const raf = requestAnimationFrame(() => {
document.getElementById(`${idPrefix}${field}`)?.focus()
})
return () => cancelAnimationFrame(raf)
}, [optionalRows])
// Expand Optional to expose structural errors or format warnings. A syntax
// error takes precedence over a format warning in the same field.
const hasInvalidOptionalField = optionalRows.some(fieldName => {
if (!hasBalancedCurlyBraces(toBibFormString(draft.getField(fieldName), fieldName))) {
return true
}
const sourceMessage = !draft.hasField(fieldName)
? sourceValidationResult.byField[fieldName]
: undefined
return isFormatWarning(validationResult.byField[fieldName] ?? sourceMessage)
})
useEffect(() => {
if (hasInvalidOptionalField) setOptionalExpanded(true)
}, [hasInvalidOptionalField])
const offeredFields = useMemo(
() =>
offeredOptionalFields(entryTypeDef).filter(
field =>
!optionalRows.includes(field) &&
!fieldRows.includes(field)
),
[entryTypeDef, optionalRows, fieldRows]
)
const standardFieldNames = useMemo(() => {
const names = new Set<string>([
...fieldRows,
...OPTIONAL_FIELD_NAMES,
...requiredFieldNames(entryTypeDef),
])
return Array.from(names)
}, [fieldRows, entryTypeDef.requiredFields])
const existingFieldNames = useMemo(
() => draft.getFieldsArray().map(({ key }) => key),
[draft]
)
const handleFieldChange = useCallback(
(
name: string,
value: string,
historyUserEvent: BibHistoryUserEvent = 'input.type',
fieldIsValid = true,
nameDraft?: BibAuthorDraft
) => {
const current = draftRef.current
const next = (getFieldType(name) === 'large' ? value !== '' : value.trim() !== '')
? current.setField(
name,
fromBibFormString(value, name, current.getField(name))
)
: current.deleteField(name)
// Validate the exact input text before conversion to BibtexFieldValue.
// The editable and serialized representations can differ, so checking
// only the converted value can miss malformed braces.
// Name-list controls validate individual rows themselves.
const isName = isNameField(name)
const rawValueIsValid =
fieldIsValid && (isName || hasBalancedCurlyBraces(value))
if (!isName) {
const canonical = toBibFormString(next.getField(name), name)
if (canonical === value) fieldInputRef.current.delete(name)
else fieldInputRef.current.set(name, { text: value, canonical })
}
reportDraft(
next, historyUserEvent, name, rawValueIsValid,
{ kind: 'field', name }, nameDraft
)
},
[reportDraft]
)
const handleGenerateKey = useCallback((focusAfter: boolean) => {
const current = draftRef.current
// Existing entries may reuse their own source key; new entries may not.
const key = generateAvailableCitationKey(
current, existingIds, kind === 'new' ? undefined : sourceEntry.key
)
reportDraft(current.setKey(key), 'input.complete', 'id', true, { kind: 'citation-key' })
if (focusAfter) {
requestAnimationFrame(() => {
document.getElementById(`${idPrefix}ref-key`)?.focus()
})
}
}, [existingIds, idPrefix, kind, reportDraft, sourceEntry.key])
const hasPendingStructuralErrors = pendingInvalidFieldsRef.current.size > 0
const submissionBlocked =
(kind === 'new' && !entryTypeSelected) ||
shouldBlockSubmission({
kind,
submitDisabled,
unbalancedCurlyBraces,
hasPendingStructuralErrors,
validationResult,
})
const handleCheck = useCallback(() => {
const current = draftRef.current
setChecked(true)
// Use the same blockers as the disabled Save button. Format warnings do
// not block submission; the host generates a key if the new entry has none.
const blocked =
(kind === 'new' && !entryTypeSelected) ||
shouldBlockSubmission({
kind,
submitDisabled,
unbalancedCurlyBraces:
hasUnbalancedCurlyBraces(current) ||
NAME_FIELDS.some(field => pendingInvalidFieldsRef.current.has(field)),
hasPendingStructuralErrors: pendingInvalidFieldsRef.current.size > 0,
validationResult: validateEntry(current),
})
if (blocked) return Promise.resolve(false)
return Promise.resolve(onChecked?.(current, kind)).then(result =>
result === undefined ? true : result
)
}, [entryTypeSelected, kind, onChecked, submitDisabled])
// The modal footer is outside this form. Publish the current submit action
// only after validation or the save-disabled state changes.
useEffect(() => {
onFooterStateChange?.({
disabled: submissionBlocked,
submit: () => { void handleCheck() },
})
}, [handleCheck, onFooterStateChange, submissionBlocked])
const handleTypeChange = useCallback(
(name: string) => {
const nextEntry = draftRef.current.setType(name)
reportDraft(nextEntry, 'input.complete', 'type', true, { kind: 'entry-type' })
setEntryTypeSelected(true)
setChecked(false)
setVisibleOptionals([])
},
[reportDraft]
)
const handleAddOptional = useCallback((field: string) => {
const normalized = field.trim().toLowerCase()
if (!BIBTEX_IDENTIFIER_REGEX.test(normalized)) return
setRemovedOptionals(previous => previous.filter(name => name !== normalized))
pendingOptionalFocusRef.current = normalized
setVisibleOptionals(prev =>
prev.includes(normalized) ? prev : [...prev, normalized]
)
}, [])
const handleRemoveOptional = useCallback((field: string) => {
fieldInputRef.current.delete(field)
setRemovedOptionals(previous =>
previous.includes(field) ? previous : [...previous, field]
)
setVisibleOptionals(previous => previous.filter(name => name !== field))
reportDraft(
draftRef.current.deleteField(field),
'delete',
field,
true,
{ kind: 'field', name: field }
)
}, [reportDraft])
const selectedType = ENTRY_TYPES.find(et => et.key === type)
const validationMessageText = (
fieldName: string,
msg: ReturnType<typeof validateEntry>['byField'][string]
): string | null => {
switch (msg.kind) {
case 'required-missing': {
if (msg.group && msg.labelFields.length > 1) {
const labels = msg.labelFields.map(field => fieldLabel(field))
return t('either_a_or_b_is_required', {
a: labels.slice(0, -1).join(', ') + (labels.length > 2 ? ',' : ''),
b: labels[labels.length - 1],
})
}
return t('field_is_required', { a: fieldLabel(fieldName) })
}
case 'id-invalid':
return t('citation_key_contains_invalid_characters')
case 'year-format':
return t('year_format_hint')
case 'date-format':
return t('date_format_hint')
case 'doi-format':
return t('doi_format_hint')
case 'url-invalid':
return t('url_format_hint')
default:
return null
}
}
// Invalid citation-key characters are an immediate field error, like
// unbalanced braces.
const invalidCitationKeyMessage =
validationResult.byField.id?.kind === 'id-invalid'
? validationResult.byField.id
: undefined
const keyMessage =
invalidCitationKeyMessage ??
(kind === 'new' ? checkResult?.byField.id : undefined)
const citationKeyMessage = keyMessage
? validationMessageText('id', keyMessage)
: null
// Format warnings are live in both hosts. The modal reveals missing-field
// messages after a submit attempt, while invalid citation-key characters are
// reported immediately.
// standalone missing → "<Label> is required"
// OR-group missing → "Either A or B is required" (one per empty member)
const messageFor = (fieldName: string): string | null => {
// Syntax errors take priority only within their own field. Citation keys
// are validated as identifiers and must never receive brace/backslash errors.
const fieldHasSyntaxError =
isNameField(fieldName)
? !nameDraftValid(fieldName)
: !hasBalancedCurlyBraces(
draft.getField(fieldName).toEditableString()
)
if (fieldHasSyntaxError) return null
let msg: ReturnType<typeof validateEntry>['byField'][string] | undefined
if (kind === 'existing') {
msg = validationResult.byField[fieldName]
// Keep a source warning visible if deleting a field was rejected.
if (!msg && !draft.hasField(fieldName)) {
msg = sourceValidationResult.byField[fieldName]
}
if (msg?.kind !== 'required-missing' && !isFormatWarning(msg)) return null
} else {
msg = validationResult.byField[fieldName]
if (!checkResult && !isFormatWarning(msg)) return null
}
if (!msg) {
return null
}
return validationMessageText(fieldName, msg)
}
// Helper text is deliberately centralized in bib-form-helper.ts. Some fields
// have no helper at all; in particular, Author/Editor stay visually quiet.
const rowHelper = (fieldName: string): string | null => {
const key = helperKeyForField(fieldName)
return key ? t(key) : null
}
// Focus structural field errors before citation-key problems, then other
// validation warnings. Clean existing entries must not steal focus.
const focusOnceRef = useRef(false)
useLayoutEffect(() => {
if (focusOnceRef.current || (kind === 'new' && !entryTypeSelected)) return
const fields = [...fieldRows, ...optionalRows]
const firstStructuralField = fields.find(fieldName =>
isNameField(fieldName)
? !nameDraftValid(fieldName)
: !hasBalancedCurlyBraces(
toBibFormString(draft.getField(fieldName), fieldName)
)
)
const key = sourceEntry.key.trim()
const duplicateKey =
kind === 'existing' &&
key !== '' &&
existingIds.filter(existingKey => existingKey.trim() === key).length > 1
const keyNeedsFocus = duplicateKey || validationResult.byField.id != null
const firstWrongField =
firstStructuralField ??
(keyNeedsFocus
? undefined
: fields.find(fieldName =>
validationResult.byField[fieldName] != null ||
(!draft.hasField(fieldName) &&
sourceValidationResult.byField[fieldName] != null)
))
if (firstWrongField && optionalRows.includes(firstWrongField) && !optionalExpanded) {
setOptionalExpanded(true)
return
}
const targetId = firstWrongField
? `${idPrefix}${firstWrongField}`
: keyNeedsFocus || kind === 'new'
? `${idPrefix}ref-key`
: null
// Focus is decided only once per form instance. Later edits must not
// move focus away from the field the user is editing.
focusOnceRef.current = true
if (targetId) document.getElementById(targetId)?.focus()
}, [
draft,
entryTypeSelected,
fieldRows,
idPrefix,
existingIds,
optionalExpanded,
optionalRows,
sourceEntry.key,
sourceValidationResult,
validationResult,
kind,
])
// Ordinary fields show their own brace warning. Author and Editor validate
// their name inputs individually, so they render feedback inside the control.
const renderField = (fieldName: string) => {
const isAbstractPreview = previewTab !== undefined && fieldName === 'abstract'
const isStarred = starMembers.has(fieldName)
const isOptional = optionalRows.includes(fieldName)
const message = messageFor(fieldName)
const helper = rowHelper(fieldName)
const canonical = toBibFormString(draft.getField(fieldName), fieldName)
const typeOfField = getFieldType(fieldName)
const input = fieldInputRef.current.get(fieldName)
const value = input && sameBibFormValue(input.canonical, canonical)
? input.text
: typeOfField === 'large' &&
sourceEntry.getField(fieldName).toEditableString() === canonical
? textareaSourceValues?.[fieldName] ?? canonical
: canonical
const unbalanced = !hasBalancedCurlyBraces(value)
const braceFeedback = typeOfField !== 'name' && unbalanced && (
<BibEntryFeedback message={t('curly_braces_must_be_balanced')} />
)
const messageFeedback = message && (
<BibEntryFeedback message={message} variant="warning" />
)
const fieldControl = typeOfField === 'name' ? (
<BibNameField
key={draftReset?.version ?? 0}
fieldName={fieldName}
value={value}
initialDraft={initialNameDrafts?.[fieldName]}
onChange={(val, historyUserEvent, fieldIsValid, nameDraft) =>
handleFieldChange(fieldName, val, historyUserEvent, fieldIsValid, nameDraft)
}
error={!!message}
idPrefix={idPrefix}
/>
) : typeOfField === 'large' ? (
<OLFormControl
as="textarea"
ref={isAbstractPreview ? undefined : resizeTextarea}
id={`${idPrefix}${fieldName}`}
className={`${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}
/>
) : (
<OLFormControl
id={`${idPrefix}${fieldName}`}
className={`${isOptional ? 'bibtex-form-input ' : ''}${
message || unbalanced ? 'is-invalid' : ''
}`}
maxLength="512"
type="text"
value={value}
onChange={e => handleFieldChange(fieldName, e.target.value)}
/>
)
return (
<div
className={isAbstractPreview ? 'form-group bibtex-abstract-form-group' : 'form-group'}
key={fieldName}
>
<OLFormLabel
className={isAbstractPreview ? 'visually-hidden' : undefined}
htmlFor={`${idPrefix}${fieldName}`}
>
{fieldLabel(fieldName)}
{isStarred && (
<span className="bibtex-form-required"> *</span>
)}
</OLFormLabel>
{isOptional ? (
<div className="bibtex-field-with-actions bibtex-optional-field-row">
{fieldControl}
{braceFeedback}
{messageFeedback}
<div className="bibtex-form-actions">
<OLIconButton
icon="close"
variant="ghost"
unfilled
accessibilityLabel={t('remove_field', { a: fieldLabel(fieldName) })}
onMouseDown={event => event.preventDefault()}
onClick={() => handleRemoveOptional(fieldName)}
/>
</div>
</div>
) : (
<>
{fieldControl}
{braceFeedback}
</>
)}
{helper && <OLFormText>{helper}</OLFormText>}
{!isOptional && messageFeedback}
</div>
)
}
const detailsContent = (
<>
<OLFormGroup>
<OLFormLabel htmlFor={`${idPrefix}ref-type`}>
{t('entry_type')}
</OLFormLabel>
<BibEntryTypeCombobox
id={`${idPrefix}ref-type`}
selected={
entryTypeSelected ? selectedType?.label ?? displayCustomType(type) : ''
}
currentType={entryTypeSelected ? type : ''}
entryTypes={ENTRY_TYPES}
onSelect={handleTypeChange}
/>
</OLFormGroup>
{kind === 'new' && !entryTypeSelected ? (
<OLFormText>{t('select_a_type_to_see_the_required_fields')}</OLFormText>
) : (
<div>
<OLFormGroup>
<OLFormLabel htmlFor={`${idPrefix}ref-key`}>
{t('citation_key')}
</OLFormLabel>
<div className="bibtex-field-with-actions">
<OLFormControl
className={`bibtex-form-input ${
citationKeyMessage ? 'is-invalid' : ''
}`}
maxLength="128"
autoComplete="off"
type="text"
id={`${idPrefix}ref-key`}
value={id}
onChange={e => {
// Existing Visual entries keep an empty source key until the
// preview closes. The displayed suggestion is only a placeholder.
const key = kind === 'new' && !e.target.value.trim()
? generateAvailableCitationKey(draftRef.current, existingIds)
: e.target.value
reportDraft(
draftRef.current.setKey(key),
'input.type',
'id',
true,
{ kind: 'citation-key' }
)
}}
placeholder={id ? 'e.g. Doe0000' : generateAvailableCitationKey(
draft, existingIds, kind === 'new' ? undefined : sourceEntry.key
)}
/>
{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>
{/* Keep the entry type's defined field order; it is not alphabetical. */}
{fieldRows.filter(field => previewTab === undefined || field !== 'abstract').map(renderField)}
{/* Optional fields are hidden until requested, except validation can force the
section open when a hidden field contains unbalanced braces. */}
<div className="ms-2">
<button
type="button"
className="bibtex-collapsible-heading"
aria-expanded={optionalExpanded}
aria-label={
optionalExpanded ? t('collapse_optional') : t('expand_optional')
}
onClick={() => setOptionalExpanded(!optionalExpanded)}
>
<span className="mb-0 form-label">{t('optional')}</span>
<span className="material-symbols bibtex-collapsible-heading-icon" aria-hidden="true" translate="no">
{optionalExpanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
</span>
</button>
{optionalExpanded && (
<div className="position-relative">
{optionalRows.filter(field => previewTab === undefined || field !== 'abstract').map(renderField)}
<BibAddFieldCombobox
placeholder={t('enter_field_name')}
offered={offeredFields}
standardFields={standardFieldNames}
existingFields={existingFieldNames}
onSelect={field => {
if (field === 'abstract' && onSelectAbstract) {
onSelectAbstract()
return
}
handleAddOptional(field)
}}
/>
</div>
)}
</div>
</div>
)}
</>
)
if (!previewTab) return detailsContent
return (
<>
<div
hidden={previewTab !== 'details'}
role="tabpanel"
id={`${idPrefix}panel-details`}
aria-labelledby={`${idPrefix}tab-details`}
>
{detailsContent}
</div>
<div
hidden={previewTab !== 'abstract'}
role="tabpanel"
id={`${idPrefix}panel-abstract`}
aria-labelledby={`${idPrefix}tab-abstract`}
>
{renderField('abstract')}
</div>
</>
)
}
@@ -0,0 +1,113 @@
/**
* Left-hand list controls: search and the Add menu. The actual entry rows are
* rendered by BibEntryList, so this component stays independent of entry data.
*/
import '../../stylesheets/bib-editor-panel.css'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
OLDropdown,
OLDropdownItem,
OLDropdownMenu,
OLDropdownToggle,
} from '@/shared/components/ol/ol-dropdown-menu'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
type Props = {
readOnly?: boolean
search: string
onSearchChange: (search: string) => void
openDocName: string | null
onAddPaste: () => void
onAddManual: () => void
onAddFromOrcid?: () => void
}
function BibEntryListPanel({
readOnly = false,
search,
onSearchChange,
openDocName,
onAddPaste,
onAddManual,
onAddFromOrcid,
}: Props) {
const { t } = useTranslation()
return (
<div className="bibtex-entry-list-panel">
<div className="bibtex-search">
<OLFormControl
id="bibtex-list-search"
type="text"
size="sm"
value={search}
onKeyDown={event => {
if (event.key === 'Escape') onSearchChange('')
}}
onChange={e => onSearchChange(e.target.value)}
placeholder={t('search_bib_file', { fileName: openDocName || '.bib' })}
aria-label={t('search')}
prepend={<MaterialIcon type="search" />}
append={
search.length > 0 && (
<button
type="button"
onClick={() => onSearchChange('')}
className="form-control-search-clear-btn"
aria-label={t('clear_search')}
>
<MaterialIcon type="clear" />
</button>
)
}
/>
</div>
<OLDropdown className="bibtex-add-button">
<OLDropdownToggle
disabled={readOnly}
variant="secondary"
size="sm"
className="d-inline-grid custom-toggle"
>
<span className="button-content" aria-hidden="false">
<span
className="material-symbols"
aria-hidden="true"
translate="no"
>
add
</span>
{t('add')}
</span>
</OLDropdownToggle>
<OLDropdownMenu>
<OLDropdownItem
description={t('bibtex_doi_description')}
onClick={onAddPaste}
>
{t('paste_references')}
</OLDropdownItem>
<OLDropdownItem onClick={onAddManual}>
{t('enter_manually')}
</OLDropdownItem>
{onAddFromOrcid ? (
<OLDropdownItem
description={t('search_orcid_by_name_or_id')}
onClick={onAddFromOrcid}
>
{t('import_from_orcid_org')}
</OLDropdownItem>
) : null}
</OLDropdownMenu>
</OLDropdown>
</div>
)
}
export default React.memo(BibEntryListPanel)
@@ -0,0 +1,137 @@
/**
* Filtered entry-list view. It derives the visible rows from the parsed source
* list and passes each row to BibEntryCard; it does not own bibliography state.
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { PositionedBibtexEntry } from '../utils/bib-types'
import BibEntryCard from './bib-entry-card'
import { displayCitationKey } from '../utils/bib-key-generation'
import { captureBibListPosition, restoreBibListPosition } from '../utils/bib-list-position'
import type { BibListPosition } from '../utils/bib-list-position'
type Props = {
entries: PositionedBibtexEntry[]
existingIds: string[]
onSelect: (entry: PositionedBibtexEntry) => void
previewId?: string | null
restoreKey?: string | null
getRestorePosition?: () => BibListPosition | null
onPositionChange?: (position: BibListPosition) => void
selectedIds?: string[]
onToggleSelect?: (id: string) => void
search: string
totalEntries?: number
duplicateIds?: Set<string>
}
function BibEntryList({
entries,
existingIds,
onSelect,
previewId = null,
restoreKey = null,
getRestorePosition,
onPositionChange,
selectedIds = [],
onToggleSelect,
search,
totalEntries = entries.length,
duplicateIds,
}: Props) {
const { t } = useTranslation()
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
const listRef = useRef<HTMLDivElement>(null)
const restoredKeyRef = useRef<string | null>(null)
const visibleRef = useRef(false)
const restoreRef = useRef<() => void>(() => {})
// The editor can remain mounted while a binary-file tab covers it. Restore
// only when the list is actually visible, not merely when its session loads.
const restore = useCallback(() => {
const list = listRef.current
if (!restoreKey || !list || restoredKeyRef.current === restoreKey) return
const rect = list.getBoundingClientRect()
if (!visibleRef.current || rect.width === 0 || rect.height === 0) return
const restorePosition = getRestorePosition?.()
if (restorePosition && !restoreBibListPosition(list, restorePosition)) return
restoredKeyRef.current = restoreKey
}, [restoreKey, getRestorePosition])
useLayoutEffect(() => {
restoreRef.current = restore
if (!restoreKey) restoredKeyRef.current = null
restore()
}, [restore, restoreKey, entries])
useEffect(() => {
const list = listRef.current
if (!list) return
const observer = new IntersectionObserver(([entry]) => {
const visible = entry.isIntersecting
if (visible !== visibleRef.current) {
visibleRef.current = visible
// A tab may hide and re-show the same document without changing its
// session key or remounting the list. It needs a fresh restoration.
restoredKeyRef.current = null
}
if (visible) restoreRef.current()
})
observer.observe(list)
return () => observer.disconnect()
}, [])
return (
<div
ref={listRef}
className="bibtex-entry-list"
onScroll={event => {
if (!visibleRef.current) return
const list = event.currentTarget
// Capture geometry now. Approximating from a previous scrollTop gives
// a wrong anchor when the browser adjusts scroll for resized rows.
const position = captureBibListPosition(list)
if (position) onPositionChange?.(position)
}}
>
{entries.length === 0 && (
<div className="bibtex-list-empty">
{totalEntries === 0
? t('no_bibliography_entries_yet_add_one')
: t('no_entries_match_your_search')}
</div>
)}
<div className="bibtex-entry-list-body" role="list">
{entries.map((entry, i) => {
const rowId = entry.entry.id
return (
<div
key={rowId}
data-index={i}
data-entry-id={rowId}
className="bibtex-entry-card-row"
role="listitem"
>
<BibEntryCard
entry={entry}
displayKey={displayCitationKey(entry.entry, existingIds)}
onSelect={onSelect}
previewing={previewId === rowId}
checked={selectedIdSet.has(rowId)}
onToggleSelect={onToggleSelect}
search={search}
rowId={rowId}
duplicate={duplicateIds?.has(rowId) ?? false}
/>
</div>
)
})}
</div>
</div>
)
}
export default React.memo(BibEntryList)
@@ -0,0 +1,337 @@
import React, { useEffect, useId, useMemo, useState } from 'react'
import classnames from 'classnames'
import { useTranslation } from 'react-i18next'
import { plural } from '../utils/plural'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLForm from '@/shared/components/ol/ol-form'
import Notification from '@/shared/components/notification'
import {
OLDropdown,
OLDropdownMenu,
OLDropdownItem,
OLDropdownToggle,
} from '@/shared/components/ol/ol-dropdown-menu'
import BibEntryForm from './bib-entry-form'
import type { BibProjectedEntry } from '../utils/bib-entry-position'
import { textareaValueFromSource, toBibFormString } from '../utils/bib-field-edit'
import { getFieldType } from '../utils/bib-types'
import type { PositionedBibtexEntry } from '../utils/bib-types'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import {
getBibEntryDiagnostics,
translateBibSourceDiagnostic,
} from '../utils/bib-entry-diagnostics'
import type { BibFormChangeHandler } from '../context/bib-editor-context'
import { missingRequiredLabels } from '../utils/preview-model.ts'
import { humanizeCitationHeading } from '../utils/overleaf-type-map.ts'
import { displayCitationKey } from '../utils/bib-key-generation'
import type { BibNameDrafts } from '../utils/bib-author-draft'
import MaterialIcon from '@/shared/components/material-icon'
import BibEntryWarningSummary from './bib-entry-warning-summary'
type BibPreviewProps = {
/** The current parsed entries (file order — the preview walks this) */
entries: PositionedBibtexEntry[]
/** The previewed entry (full parsed entry, from the current parse) */
entry: BibProjectedEntry
/** Exact source slice for this occurrence; upstream normalizes literal newlines. */
sourceText?: string
/** Index into `entries` (file order) */
previewIndex: number
onPrev: () => void
onNext: () => void
/** Close the preview; existing-entry edits have already been written. */
onClose: () => void
/** Download callback for the currently previewed entry. */
onDownload: () => void
/** Single-entry delete (confirm modal in the panel) */
onDelete: () => void
/** Receives the reusable form's current draft so the host can commit it. */
onFormChange: BibFormChangeHandler
existingIds: string[]
canDelete: boolean
readOnly?: boolean
draftEntry?: BibtexEntry
pendingNameDrafts?: BibNameDrafts
/** Incremented when undo/redo requires discarding this entry's local draft. */
draftReset?: { version: number; entry: BibtexEntry }
optionalExpanded?: boolean
onOptionalExpandedChange?: (expanded: boolean) => void
}
function BibEntryPreview({
entries,
entry,
sourceText,
previewIndex,
onPrev,
onNext,
onClose,
onDownload,
onDelete,
onFormChange,
existingIds,
canDelete,
readOnly = false,
draftEntry,
pendingNameDrafts,
draftReset,
optionalExpanded,
onOptionalExpandedChange,
}: BibPreviewProps) {
const { t } = useTranslation()
const bibEntry = entry.entry
const syntaxErrors = getBibEntryDiagnostics(entry).map(diagnostic =>
translateBibSourceDiagnostic(diagnostic, t)
)
const hasSyntaxErrors = syntaxErrors.length > 0
const idPrefix = `bib-${useId()}-`
const abstractId = `${idPrefix}abstract`
const textareaSourceValues = useMemo(() => {
const result: Record<string, string> = {}
if (sourceText === undefined) return result
// Trusted source-relative ranges come from the existing Lezer projection.
// Malformed entries have no trusted ranges and are not editable.
const ranges = entry.sourceFieldRanges ?? []
for (const range of ranges) {
if (getFieldType(range.name) !== 'large') continue
const text = textareaValueFromSource(
sourceText.slice(range.valueFrom, range.valueTo),
toBibFormString(bibEntry.getField(range.name), range.name)
)
if (text !== null) result[range.name] = text
}
return result
}, [sourceText, entry])
const [tab, setTab] = useState<'details' | 'abstract'>('details')
const selectAbstract = React.useCallback(() => setTab('abstract'), [])
// Keep a single form mounted across tabs. Abstract uses the same change,
// validation, draft and source-write path as every other field.
useEffect(() => {
if (tab !== 'abstract') return
const raf = requestAnimationFrame(() => {
document.getElementById(abstractId)?.focus()
})
return () => cancelAnimationFrame(raf)
}, [tab, abstractId])
// Short labels are derived directly from the current parsed entry.
const summaryTitle = bibEntry.getField('title').toDisplayString().trim()
const summaryHeading = humanizeCitationHeading(bibEntry)
const missing = useMemo(
() => missingRequiredLabels(bibEntry, t('or')),
[entry, t]
)
// Handle Escape at the preview container so either tab returns to the list.
const handleBodyKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Escape') return
const el = e.target
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
return
}
e.preventDefault()
e.stopPropagation()
onClose()
}
const tabs = [
{ id: 'details', label: t('details') },
{ id: 'abstract', label: t('abstract') },
] as const
// The form retains its local draft while the two tabs toggle visibility.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
return (
<div
tabIndex={-1}
className="bibtex-entry-preview-panel bibtex-entry-preview-panel-open"
role="region"
aria-label={t('edit_reference')}
onKeyDown={handleBodyKeyDown}
>
<div className="bibtex-entry-preview-header">
<div className="bibtex-entry-preview-header-nav">
<OLIconButton
icon="chevron_left"
variant="ghost"
unfilled
aria-label={t('previous_reference')}
disabled={previewIndex <= 0}
onClick={onPrev}
/>
<OLIconButton
icon="chevron_right"
variant="ghost"
unfilled
aria-label={t('next_reference')}
disabled={previewIndex >= entries.length - 1}
onClick={onNext}
/>
{/* List collapse is controlled by the list pane, not this preview header. */}
</div>
<OLIconButton
icon="close"
variant="ghost"
unfilled
aria-label={t('close')}
onClick={onClose}
/>
</div>
<div className="bibtex-entry-preview-content">
{hasSyntaxErrors && (
<div className="bibtex-entry-preview-summary">
<Notification
type="error"
customIcon={(<MaterialIcon unfilled type="error" />)}
content={
<>
<p>
<b>{plural(t, syntaxErrors.length, 'entry_has_syntax_error', 'entry_has_syntax_errors')}:</b>
</p>
<ul>{syntaxErrors.map((message, index) => <li key={`${index}-${message}`}>{message}</li>)}</ul>
<p>
{plural(t, syntaxErrors.length, 'correct_syntax_error_in_code_mode', 'correct_syntax_errors_in_code_mode')}
</p>
</>
}
/>
</div>
)}
{/* Compact summary shown above the detailed fields. */}
<div className="bibtex-entry-preview-summary">
<div className="bibtex-entry-preview-summary-content">
<div className="bibtex-entry-preview-summary-key-row">
<div className="bibtex-entry-preview-summary-key">
{displayCitationKey(bibEntry, existingIds)}
</div>
</div>
{summaryTitle && (
<div className="bibtex-entry-preview-summary-title">
{summaryTitle}
</div>
)}
{summaryHeading && summaryHeading !== bibEntry.key.trim() && (
<div className="bibtex-entry-preview-summary-meta">
<span>{summaryHeading}</span>
</div>
)}
</div>
{/* Actions for the currently previewed entry. */}
<OLDropdown className="bibtex-entry-preview-summary-actions">
<OLDropdownToggle
className="d-inline-grid custom-toggle"
size="sm"
variant="ghost"
aria-label={t('actions')}
>
<span className="button-content" aria-hidden="false">
<span className="material-symbols unfilled" aria-hidden="true">
more_vert
</span>
</span>
</OLDropdownToggle>
<OLDropdownMenu>
<OLDropdownItem leadingIcon="download" onClick={onDownload}>
{t('download')}
</OLDropdownItem>
<OLDropdownItem
leadingIcon="delete"
onClick={onDelete}
disabled={!canDelete}
>
{t('delete')}
</OLDropdownItem>
</OLDropdownMenu>
</OLDropdown>
</div>
{missing.length > 0 && !hasSyntaxErrors && (
<div className="bibtex-entry-preview-summary">
<Notification
type="warning"
className="unfilled"
customIcon={(<MaterialIcon unfilled type="warning" />)}
content={
<BibEntryWarningSummary
missingFields={missing}
badlyFormattedFields={[]}
requiredFieldsTitle={plural(
t,
missing.length,
'required_field_missing',
'required_fields_missing'
)}
badlyFormattedFieldsTitle={t('badly_formatted_fields')}
/>
}
/>
</div>
)}
<div
className={classnames('bibtex-entry-preview-body', {
'bibtex-entry-preview-body-abstract': tab === 'abstract',
})}
>
<div className="bibtex-entry-preview-tabs" role="tablist" tabIndex={-1}>
{tabs.map(({ id, label }) => (
<button
key={id}
type="button"
role="tab"
id={`${idPrefix}tab-${id}`}
aria-controls={`${idPrefix}panel-${id}`}
aria-selected={tab === id}
tabIndex={tab === id ? 0 : -1}
className={classnames('bibtex-entry-preview-tab', {
'bibtex-entry-preview-tab-active': tab === id,
})}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
<OLForm
id="bibtex-entry-form"
className={classnames('bibtex-entry-form', {
'bibtex-entry-syntax-disabled': hasSyntaxErrors && !readOnly,
'bibtex-entry-read-only': readOnly,
})}
>
<fieldset
className="bibtex-entry-preview-fields"
disabled={hasSyntaxErrors || readOnly}
>
<BibEntryForm
key={readOnly ? 'read-only' : 'editable'}
entry={readOnly ? bibEntry : draftEntry ?? bibEntry}
initialNameDrafts={pendingNameDrafts}
sourceEntry={bibEntry}
textareaSourceValues={textareaSourceValues}
draftReset={draftReset}
optionalExpanded={readOnly || optionalExpanded}
onOptionalExpandedChange={onOptionalExpandedChange}
kind="existing"
previewTab={tab}
existingIds={existingIds}
onFormChange={onFormChange}
onSelectAbstract={selectAbstract}
idPrefix={idPrefix}
/>
</fieldset>
</OLForm>
</div>
</div>
</div>
)
}
export default React.memo(BibEntryPreview)
@@ -0,0 +1,50 @@
type BibEntryWarningSummaryProps = {
missingFields: string[]
badlyFormattedFields: string[]
requiredFieldsTitle: string
badlyFormattedFieldsTitle: string
listFields?: boolean
}
export default function BibEntryWarningSummary({
missingFields,
badlyFormattedFields,
requiredFieldsTitle,
badlyFormattedFieldsTitle,
listFields = false,
}: BibEntryWarningSummaryProps) {
const renderFields = (fields: string[]) =>
listFields ? (
<ul>
{fields.map(field => <li key={field}>{field}</li>)}
</ul>
) : (
<>
<br />
{fields.join(', ')}
</>
)
return (
<>
{missingFields.length > 0 && (
<>
<strong>{requiredFieldsTitle}</strong>
{renderFields(missingFields)}
</>
)}
{!listFields && missingFields.length > 0 && badlyFormattedFields.length > 0 && (
<>
<br />
<br />
</>
)}
{badlyFormattedFields.length > 0 && (
<>
<strong>{badlyFormattedFieldsTitle}</strong>
{renderFields(badlyFormattedFields)}
</>
)}
</>
)
}
@@ -0,0 +1,441 @@
/**
* Two-stage BibTeX/DOI import. Pasted entries appear immediately; DOI lookups
* fill their rows concurrently without changing paste order. Closing or
* restarting the preview invalidates outstanding results through a generation
* counter. Import commits the selected valid entries in one guarded write.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { plural } from '../utils/plural'
import OLButton from '@/shared/components/ol/ol-button'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import { fetchEntryFromDoi } from '../utils/doi-fetcher'
import { generateCitationKey } from '../utils/bib-key-generation'
import { translateBibSourceDiagnostic } from '../utils/bib-entry-diagnostics'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import {
splitImportText,
buildImportRow,
buildImportRows,
type BibImportRow,
} from '../utils/bib-import.ts'
type Props = {
show: boolean
/** Fired for the Import button; false means the guarded write was rejected. */
onImport: (entries: BibtexEntry[]) => Promise<boolean>
onHidden: () => void
/** Library (SaaS): pre-fill the paste step (Upload .bib file → same
* pipeline). Default empty string. */
initialText?: string
}
export default function BibImportModal({
show,
onImport,
onHidden,
initialText = '',
}: Props) {
const { t } = useTranslation()
const [mode, setMode] = useState<'paste' | 'preview'>('paste')
const [text, setText] = useState('')
const [rows, setRows] = useState<BibImportRow[]>([])
const [checkedRowIds, setCheckedRowIds] = useState<string[]>([])
const [previewDone, setPreviewDone] = useState(false)
const [importing, setImporting] = useState(false)
const [importFailed, setImportFailed] = useState(false)
const previewGenerationRef = useRef(0)
// Reset to a fresh "Add reference" → paste step whenever the modal opens.
// `show` is the only remount signal (the component stays mounted to
// follow the OLModal show/hide contract).
useEffect(() => {
// Invalidate queued DOI work whenever the modal becomes hidden. Requests
// already in flight may finish, but their results will no longer update UI.
previewGenerationRef.current++
if (!show) return
setMode('paste')
setText(initialText)
setRows([])
setCheckedRowIds([])
setPreviewDone(false)
setImporting(false)
setImportFailed(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show])
const handleHidden = useCallback(() => {
previewGenerationRef.current++
onHidden()
}, [onHidden])
const handlePreview = useCallback(async () => {
if (!text.trim()) return
const generation = ++previewGenerationRef.current
setMode('preview')
setImportFailed(false)
setPreviewDone(false)
setCheckedRowIds([])
setRows([])
const items = splitImportText(text)
type DoiItem = Extract<(typeof items)[number], { kind: 'doi' }>
const doiItems: Array<{ item: DoiItem; rowIndex: number }> = []
items.forEach((item, rowIndex) => {
if (item.kind === 'doi') {
doiItems.push({ item, rowIndex })
}
})
const doiResults: (string | BibtexEntry | undefined)[] = doiItems.map(() => undefined)
// Prepare ordinary BibTeX rows once. DOI completions only rebuild their own
// row instead of reparsing every pasted BibTeX entry on each network result.
const previewRows = buildImportRows(items, doiResults)
setRows([...previewRows])
// Select available BibTeX rows by default, once. Subsequent DOI results
// must never replace choices the user has already made.
setCheckedRowIds(previewRows
.filter(row => row.status === 'ok' || row.status === 'doi-ok')
.map(row => row.rowId))
// DOI lookups are independent. Run a small bounded worker pool rather than
// serial requests, while writing results at their original DOI index so
// preview rows remain in paste order.
const DOI_FETCH_CONCURRENCY = 4
let nextDoiIndex = 0
const fetchNextDoi = async () => {
while (generation === previewGenerationRef.current) {
const workIndex = nextDoiIndex++
if (workIndex >= doiItems.length) return
const { item, rowIndex } = doiItems[workIndex]
let result: string | BibtexEntry
try {
const fetched = await fetchEntryFromDoi(item.raw)
const fetchedEntry = fetched.entry
result = fetchedEntry.key
? fetchedEntry
: fetchedEntry.setKey(generateCitationKey(fetchedEntry))
} catch (err) {
result = err instanceof Error ? err.message : 'failed_to_fetch_doi'
}
if (generation !== previewGenerationRef.current) return
const row = buildImportRow(item, rowIndex, result)
previewRows[rowIndex] = row
setRows([...previewRows])
if (row.status === 'doi-ok') {
setCheckedRowIds(previous => previous.includes(row.rowId)
? previous
: [...previous, row.rowId])
}
}
}
await Promise.all(
Array.from(
{ length: Math.min(DOI_FETCH_CONCURRENCY, doiItems.length) },
() => fetchNextDoi()
)
)
if (generation !== previewGenerationRef.current) return
setPreviewDone(true)
}, [text])
const importable = useMemo(
() => rows.filter(r => r.status === 'ok' || r.status === 'doi-ok'),
[rows]
)
const checkedRowIdsSet = useMemo(() => new Set(checkedRowIds), [checkedRowIds])
const checkedCount = checkedRowIds.length
const toggleRow = useCallback((rowId: string) => {
setCheckedRowIds(prev =>
prev.includes(rowId) ? prev.filter(id => id !== rowId) : [...prev, rowId]
)
}, [])
const handleToggleSelectAll = useCallback((checked: boolean) => {
setCheckedRowIds(checked ? importable.map(r => r.rowId) : [])
}, [importable])
const handleImport = useCallback(async () => {
if (importing) return
const entries = importable
.filter(r => checkedRowIdsSet.has(r.rowId))
.map(r => r.entry as BibtexEntry)
if (entries.length === 0) return
setImporting(true)
setImportFailed(false)
try {
const saved = await onImport(entries)
if (saved) handleHidden()
else setImportFailed(true)
} catch (error) {
console.error('BibTeX import failed', error)
setImportFailed(true)
} finally {
setImporting(false)
}
}, [checkedRowIdsSet, importable, importing, handleHidden, onImport])
// Navigation controls for the two import stages:
// - empty state (`bibtex-import-preview-empty`) when the paste yields
// nothing importable (previewDone + zero importable rows)
// - footer-warning when nothing is checked (Import shows why it's idle)
const previewEmpty = previewDone && rows.length === 0
return (
<OLModal
data-bib-editor-dialog
show={show}
onHide={handleHidden}
themed
scrollable
>
<OLModalHeader>
{mode === 'preview' ? (
<>
{/* Keep the stage heading available to screen readers. */}
<OLButton
className="ps-1"
variant="ghost"
leadingIcon="arrow_back"
aria-label={t('back')}
onClick={() => {
previewGenerationRef.current++
setMode('paste')
}}
>
{t('back')}
</OLButton>
<OLModalTitle className="visually-hidden">{t('preview')}</OLModalTitle>
</>
) : (
<OLModalTitle>{t('add_reference')}</OLModalTitle>
)}
</OLModalHeader>
{mode === 'paste' ? (
<>
<OLModalBody className="bibtex-import-form">
<div className="form-group">
<OLFormLabel htmlFor="bib-import-textarea">
{t('reference')}
</OLFormLabel>
<OLFormControl
as="textarea"
id="bib-import-textarea"
className="bibtex-import-textarea form-control"
rows={6}
value={text}
onChange={e => setText(e.target.value)}
aria-describedby="bib-import-help"
/>
<div className="form-text" id="bib-import-help">
<span className="form-text-inner">
<span>
{t('paste_bibtex_or_dois_here')}
</span>
</span>
</div>
</div>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleHidden}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={text.trim() === ''}
onClick={() => void handlePreview()}
type="button"
>
{t('preview')}
</OLButton>
</OLModalFooter>
</>
) : (
<>
<OLModalBody>
{previewEmpty ? (
<div className="bibtex-import-preview-empty">
{t('no_references_found')}
</div>
) : (
<>
<div className="bibtex-import-preview-header">
<div
className="wide-target-checkbox bibtex-import-preview-check-all"
>
<span className="wide-target-checkbox-box">
<div className="form-checkbox">
<input
type="checkbox"
className="form-check-input"
aria-label={t('select_all')}
checked={
importable.length > 0 &&
importable.every(r => checkedRowIdsSet.has(r.rowId))
}
disabled={importable.length === 0 || !previewDone}
onChange={e => handleToggleSelectAll(e.target.checked)}
/>
</div>
<label className="wide-target-checkbox-target" aria-hidden="true"></label>
</span>
</div>
<div className="bibtex-import-preview-count">
{`${importable.length} ${plural(t, importable.length, 'reference', 'references')}`}
</div>
</div>
<div className="bibtex-import-preview-list">
{rows.map(row => (
<BibImportCard
key={row.rowId}
row={row}
checked={checkedRowIdsSet.has(row.rowId)}
onToggle={toggleRow}
t={t}
/>
))}
</div>
</>
)}
</OLModalBody>
<OLModalFooter>
<div className="bibtex-import-preview-footer">
{importFailed && (
<div className="bibtex-import-preview-footer-warning" role="alert">
<span aria-hidden="true" className="material-symbols unfilled">error</span>
<span>{t('could_not_import_this_reference')}</span>
</div>
)}
{previewDone && checkedCount === 0 && importable.length > 0 && !previewEmpty && (
<div className="bibtex-import-preview-footer-warning" role="alert">
<span aria-hidden="true" className="material-symbols unfilled">error</span>
<span>{t('select_reference')}</span>
</div>
)}
<div className="bibtex-import-preview-footer-actions">
{!previewEmpty && (
<div className="bibtex-import-preview-footer-count">
{`${checkedCount} ${plural(t, checkedCount, 'reference', 'references')}`}
</div>
)}
<div className="bibtex-import-preview-footer-buttons">
<OLButton variant="secondary" onClick={handleHidden}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={previewEmpty || !previewDone || checkedCount === 0 || importing}
onClick={() => void handleImport()}
>
{importing ? '…' : t('save')}
</OLButton>
</div>
</div>
</div>
</OLModalFooter>
</>
)}
</OLModal>
)
}
function BibImportCard({
row,
checked,
onToggle,
t,
}: {
row: BibImportRow
checked: boolean
onToggle: (rowId: string) => void
t: TFunction
}) {
const selectable = row.status === 'ok' || row.status === 'doi-ok'
return (
<div className="bibtex-import-preview-card">
{selectable && (
<div className="wide-target-checkbox bibtex-import-preview-card-check">
<span className="wide-target-checkbox-box">
<div className="form-checkbox">
<input
type="checkbox"
autoComplete="off"
className="form-check-input"
aria-label={row.entry ? row.entry.key : t('untitled')}
checked={checked}
onChange={() => onToggle(row.rowId)}
/>
</div>
<label className="wide-target-checkbox-target" aria-hidden="true"></label>
</span>
</div>
)}
<div className="bibtex-import-preview-card-content">
<div className="bibtex-import-preview-card-key">
{row.status === 'empty'
? t('resolving')
: row.status === 'error' && !row.syntaxError
? t('could_not_import_this_reference')
: row.entry?.key || (row.syntaxError ? row.raw.split(/\r?\n/, 1)[0] : t('untitled'))}
</div>
<div className="bibtex-import-preview-card-details">
{row.heading && (
<div className="bibtex-import-preview-card-heading">
{row.heading}
</div>
)}
{row.title && (
<div className="bibtex-import-preview-card-field">
{row.title}
</div>
)}
{row.status === 'error' && (
<>
<div className="bibtex-import-preview-card-error" role="alert">
{row.syntaxError && row.errors?.length ? (
<>
<strong>
{plural(t, row.errors.length, 'entry_has_syntax_error', 'entry_has_syntax_errors')}:
</strong>
<ul>
{row.errors.map((diagnostic, index) => (
<li key={index}>
{translateBibSourceDiagnostic(diagnostic, t)}
</li>
))}
</ul>
</>
) : row.error
? t(row.error)
: row.raw}
</div>
</>
)}
{row.status === 'empty' && (
<div className="bibtex-import-preview-card-resolving">
{row.raw}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,137 @@
/**
* Modal for creating a new bibliography entry. The same BibEntryForm is reused
* here as in the inline preview, but the host controls when the new entry is
* committed to the document.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
OLModal,
OLModalBody,
OLModalHeader,
OLModalTitle,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import BibEntryForm from './bib-entry-form'
import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
const EMPTY_FOOTER_STATE = { disabled: true, submit: () => {} }
type Props = {
show: boolean
existingIds: string[]
/** Save a new entry (the panel routes it through writeEntry(new)). */
onSave: (entry: BibtexEntry) => Promise<boolean>
onHide: () => void
optionalExpanded?: boolean
onOptionalExpandedChange?: (expanded: boolean) => void
}
export default function BibManualModal({
show,
existingIds,
onSave,
onHide,
optionalExpanded,
onOptionalExpandedChange,
}: Props) {
const { t } = useTranslation()
const [saving, setSaving] = useState(false)
const [saveFailed, setSaveFailed] = useState(false)
const [footerState, setFooterState] = useState<{ disabled: boolean; submit: () => void }>(
EMPTY_FOOTER_STATE
)
useEffect(() => {
if (show) {
setSaving(false)
setSaveFailed(false)
} else {
// Discard the previous form's Save callback before the next opening.
setFooterState(EMPTY_FOOTER_STATE)
}
}, [show])
const initialEntry = useMemo(
() => new BibtexEntry({ type: 'article', key: '', fields: new Map() }),
[]
)
const handleHide = useCallback(() => {
if (!saving) onHide()
}, [onHide, saving])
const handleFormChecked = useCallback(async (entry: BibtexEntry, kind: 'existing' | 'new') => {
if (kind !== 'new' || saving) return false
setSaving(true)
let saved = false
try {
saved = await onSave(entry)
} catch (error) {
console.error('BibTeX manual entry save failed', error)
} finally {
setSaving(false)
}
setSaveFailed(!saved)
if (saved) handleHide()
return saved
}, [onSave, saving, handleHide])
return (
<OLModal
data-bib-editor-dialog
show={show}
onHide={handleHide}
themed
scrollable
>
<OLModalHeader>
<OLModalTitle>{t('add_reference')}</OLModalTitle>
</OLModalHeader>
{show && (
<OLModalBody>
{saveFailed && (
<div className="bibtex-write-failure-banner" role="alert">
<span className="bibtex-write-failure-banner-text">
{t('could_not_save_bib_file')}
</span>
</div>
)}
<OLForm
id="bibtex-manual-entry-form"
className="bibtex-entry-form"
>
<BibEntryForm
key="open"
entry={initialEntry}
optionalExpanded={optionalExpanded}
onOptionalExpandedChange={onOptionalExpandedChange}
kind="new"
existingIds={existingIds}
idPrefix="bib-manual-"
onFormChange={() => setSaveFailed(false)}
onChecked={handleFormChecked}
submitDisabled={saving}
onFooterStateChange={setFooterState}
/>
</OLForm>
</OLModalBody>
)}
<OLModalFooter>
<OLButton variant="secondary" onClick={handleHide} disabled={saving}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={!show || footerState.disabled || saving}
onClick={footerState.submit}
type="button"
>
{saving ? '…' : t('save')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
@@ -0,0 +1,310 @@
/** Name-list editor with local invalid drafts and stable focus behavior. */
import { useCallback, useEffect, useRef, useState } from 'react'
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'
import { fieldLabel } from '../utils/bib-field-labels'
import { hasBalancedCurlyBraces } from '../utils/bib-validate'
import { fromBibFormString, toBibFormString } from '../utils/bib-field-edit'
import {
authorDraftSavedValue,
authorDraftValue,
authorSourceValue,
createAuthorDraft,
isAuthorDraftValid,
MAX_AUTHOR_ROWS,
} from '../utils/bib-author-draft'
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
* writable value. Switching to text mode preserves the last writable combined
* value without trying to split malformed BibTeX back into rows.
*/
export default function BibNameField({
fieldName,
value,
initialDraft,
onChange,
error,
idPrefix = '',
}: {
fieldName: string
value: string
initialDraft?: BibAuthorDraft
onChange: (
value: string,
historyUserEvent?: BibHistoryUserEvent,
fieldIsValid?: boolean,
draft?: BibAuthorDraft
) => void
error?: boolean
idPrefix?: string
}) {
const { t } = useTranslation()
const [nameDraft, setNameDraft] = useState<BibAuthorDraft>(
() => initialDraft ?? createAuthorDraft(value)
)
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.
const expectedValueRef = useRef(value)
const focusTextEndRef = useRef(false)
const focusNameIndexRef = useRef<number | null>(null)
// Keep DOM row identity with the person when names are reordered, rather
// than with the array index. These IDs are local UI state, not BibTeX data.
const rowIdsRef = useRef<number[]>(
nameDraft.mode === 'rows' ? nameDraft.rows.map((_, index) => index) : []
)
const nextRowIdRef = useRef(rowIdsRef.current.length)
// 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)
if (!textarea || !focusTextEndRef.current) return
focusTextEndRef.current = false
textarea.focus()
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
}, [])
// Keep the user's exact spelling (including a single, unfinished '#') when
// CodeMirror returns the canonical representation of our own edit. Only a
// different source value replaces the local name draft.
useEffect(() => {
if (value === expectedValueRef.current) return
const next = createAuthorDraft(value)
if (next.mode === 'rows') {
rowIdsRef.current = next.rows.map((_, index) =>
rowIdsRef.current[index] ?? nextRowIdRef.current++
)
} else {
rowIdsRef.current = []
}
expectedValueRef.current = value
nameDraftRef.current = next
setNameDraft(next)
}, [value])
const update = (
next: BibAuthorDraft,
historyUserEvent: BibHistoryUserEvent = 'input.type'
) => {
nameDraftRef.current = next
setNameDraft(next)
const text = authorDraftValue(next)
expectedValueRef.current = text.trim()
? toBibFormString(fromBibFormString(text, fieldName), fieldName)
: ''
onChange(text, historyUserEvent, isAuthorDraftValid(next), next)
}
const setNameAt = (index: number, value: string) => {
const current = nameDraftRef.current
if (current.mode !== 'rows') return
update({
mode: 'rows',
rows: current.rows.map((row, i) =>
i === index
? {
value,
saved: hasBalancedCurlyBraces(value)
? authorSourceValue(value, row.saved)
: row.saved,
}
: row
),
})
}
const addName = () => {
const current = nameDraftRef.current
if (current.mode !== 'rows') return
const filled = current.rows.filter(row => row.value.trim())
if (filled.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
focusTextEndRef.current = true
rowIdsRef.current = []
update({
mode: 'text',
value: authorDraftValue(current),
saved: authorDraftSavedValue(current),
invalidRow: current.rows.find(
row => !hasBalancedCurlyBraces(row.value)
)?.value,
}, 'input.complete')
return
}
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()
}
update({ mode: 'rows', rows }, 'input.complete')
}
const removeName = (index: number) => {
const current = nameDraftRef.current
if (current.mode !== 'rows') return
rowIdsRef.current = current.rows.length > 1
? rowIdsRef.current.filter((_, i) => i !== index)
: [rowIdsRef.current[0]]
update({
mode: 'rows',
rows: current.rows.length > 1
? current.rows.filter((_, i) => i !== index)
: [{ value: '', saved: '' }],
}, 'input.complete')
}
const moveName = (index: number, direction: -1 | 1) => {
const current = nameDraftRef.current
if (current.mode !== 'rows') return
const target = index + direction
if (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
update({ mode: 'rows', rows }, 'input.complete')
}
if (nameDraft.mode === 'text') {
return (
<>
<OLFormControl
as="textarea"
ref={handleTextareaRef}
id={`${idPrefix}${fieldName}`}
className={`bibtex-name-textarea bibtex-form-input ${error || !isAuthorDraftValid(nameDraft) ? 'is-invalid' : ''}`}
maxLength="4096"
autoComplete="off"
value={nameDraft.value}
onChange={event => {
const text = event.target.value
update({
mode: 'text',
value: text,
saved: hasBalancedCurlyBraces(text) ? text : nameDraft.saved,
})
}}
placeholder="Doe, Jane and Mustermann, Max"
rows={1}
onInput={event => resizeTextarea(event.currentTarget)}
/>
{!isAuthorDraftValid(nameDraft) && (
<BibEntryFeedback
message={t('curly_braces_must_be_balanced')}
/>
)}
<OLFormText>{t('separate_multiple_names_with_and')}</OLFormText>
</>
)
}
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"
/>
{!hasBalancedCurlyBraces(name.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>
)
}
@@ -0,0 +1,396 @@
/** ORCID search and import interface for the BibTeX editor. */
import { useId } from 'react'
import type React 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 OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import Notification from '@/shared/components/notification'
import { useOrcidPicker } from '../orcid/hooks/use-orcid-picker'
import { isValidOrcid } from '../orcid/orcid-api'
import { useBibOrcidImport } from '../orcid/hooks/use-bib-orcid-import'
import { plural } from '../utils/plural'
import '../../stylesheets/bib-orcid-import.css'
type Props = { show: boolean; onHide: () => void }
export default function BibOrcidImportModal({ show, onHide }: Props) {
const { t } = useTranslation()
const searchModeId = useId()
const orcidModeId = useId()
const selectAllId = useId()
const bridge = useBibOrcidImport(show)
const {
step, searchMode, nameQuery, orcidInput, searchLoading, searchError, authors,
selectedAuthorName, works, worksLoading, worksError, selectedPutCodes,
importing, importError, importSummary, crossrefFailures, unimportedTitles, allSelected,
setOrcidInput, setSearchError, setWorksError, setImportError,
handleClose, goBack, handleSearchModeChange, changeNameQuery,
handleSearchByName, handleGoToWorksByOrcid, handlePickAuthor,
toggleWork, toggleAll, handleImport,
} = useOrcidPicker({ show, onHide, ...bridge, t })
const isReport = step === 'works' && importSummary !== null
// Presentation-only handlers
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Enter') return
e.preventDefault()
void handleSearchByName()
}
const handleOrcidKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Enter') return
e.preventDefault()
void handleGoToWorksByOrcid()
}
const importWarning = importError && (
<Notification
type="warning"
content={importError}
isDismissible
onDismiss={() => setImportError(null)}
/>
)
return (
<OLModal
data-bib-editor-dialog
show={show}
onHide={handleClose}
data-testid="orcid-picker-modal"
scrollable
themed
>
<OLModalHeader closeButton>
{step === 'works' && !isReport ? (
<>
<OLButton
variant="ghost"
className="ps-1"
leadingIcon="arrow_back"
disabled={importing}
onClick={goBack}
data-testid="orcid-back"
>
{t('back')}
</OLButton>
<OLModalTitle className="visually-hidden">{t('import_from_orcid_org')}</OLModalTitle>
</>
) : (
<OLModalTitle>{t('import_from_orcid_org')}</OLModalTitle>
)}
</OLModalHeader>
<OLModalBody className={step === 'works' && !isReport ? 'orcid-picker-works-body' : undefined}>
{step === 'search' && (
<>
{/* Reuse the upstream Code/Visual editor toggle styling. */}
<div
className="editor-toggle-switch mb-3"
data-testid="orcid-mode-toggle"
>
<fieldset className="toggle-switch">
<legend className="visually-hidden">
{t('search_orcid_by_name_or_id')}
</legend>
<input
type="radio"
name={searchModeId}
id={searchModeId}
className="toggle-switch-input"
value="name"
checked={searchMode === 'name'}
onChange={() => handleSearchModeChange('name')}
/>
<label htmlFor={searchModeId} className="toggle-switch-label">
<span>{t('search_by_name')}</span>
</label>
<span>
<input
type="radio"
name={searchModeId}
id={orcidModeId}
className="toggle-switch-input"
value="orcid"
checked={searchMode === 'orcid'}
onChange={() => handleSearchModeChange('orcid')}
/>
<label htmlFor={orcidModeId} className="toggle-switch-label">
<span>{t('enter_orcid_id')}</span>
</label>
</span>
</fieldset>
</div>
{searchError && (
<Notification
type="error"
content={searchError}
isDismissible
onDismiss={() => setSearchError(null)}
/>
)}
{searchMode === 'name' && (
<>
<OLFormGroup>
<OLFormLabel htmlFor="orcid-name-input">
{t('author_name')}
</OLFormLabel>
<div className="d-flex gap-2">
<OLFormControl
id="orcid-name-input"
className="flex-grow-1"
type="text"
placeholder="Jane Doe"
value={nameQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
changeNameQuery(e.target.value)
}}
onKeyDown={handleNameKeyDown}
disabled={searchLoading}
/>
<OLButton
variant="primary"
className="flex-shrink-0"
onClick={() => void handleSearchByName()}
disabled={searchLoading || !nameQuery.trim()}
isLoading={searchLoading}
>
{t('search')}
</OLButton>
</div>
</OLFormGroup>
{authors.length > 0 && (
<div
className="orcid-picker-authors bibtex-import-preview-list"
data-testid="orcid-authors"
>
{authors.map(a => {
const display = [a.givenNames, a.familyNames]
.filter(Boolean)
.join(' ')
return (
<button
key={a.orcid}
type="button"
className="bibtex-import-preview-card orcid-picker-author-card"
onClick={() => void handlePickAuthor(a.orcid, display || a.orcid)}
>
<span className="bibtex-import-preview-card-content">
<span className="bibtex-import-preview-card-heading">
{display || t('no_name')}
</span>
<span className="bibtex-import-preview-card-details">
<span className="font-monospace small">{a.orcid}</span>
<span className="bibtex-import-preview-card-field">
{a.institutionNames.join(', ') || '—'}
</span>
</span>
</span>
</button>
)
})}
</div>
)}
</>
)}
{searchMode === 'orcid' && (
<OLFormGroup>
<OLFormLabel htmlFor="orcid-id-input">{t('orcid_id')}</OLFormLabel>
<div className="d-flex gap-2">
<OLFormControl
id="orcid-id-input"
className="flex-grow-1"
type="text"
placeholder="0000-0000-0000-0000"
value={orcidInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setOrcidInput(e.target.value)
}
onKeyDown={handleOrcidKeyDown}
disabled={worksLoading}
/>
<OLButton
variant="primary"
className="flex-shrink-0"
onClick={() => void handleGoToWorksByOrcid()}
disabled={worksLoading || !isValidOrcid(orcidInput.trim())}
isLoading={worksLoading}
>
{t('load_works')}
</OLButton>
</div>
</OLFormGroup>
)}
</>
)}
{step === 'works' && !isReport && (
<>
<div className="orcid-picker-works-summary">
<div className="d-flex align-items-center gap-2">
<h3 className="h5 mb-0">{t('works_by_author', { author: selectedAuthorName })}</h3>
{worksLoading && <span className="spinner-border spinner-border-sm" role="status" aria-label={t('loading')} />}
</div>
{works.length > 0 && (
<div className="small text-muted">
{`${works.length} ${plural(t, works.length, 'reference', 'references')}`}
</div>
)}
</div>
{importWarning}
{unimportedTitles.length > 0 && (
<ul className="orcid-picker-enrichment-titles">
{unimportedTitles.map((title, index) => <li key={index}>{title}</li>)}
</ul>
)}
<div className="orcid-picker-works-scroll">
{worksError && (
<Notification
type="error"
content={worksError}
isDismissible
onDismiss={() => setWorksError(null)}
/>
)}
{works.length > 0 && (
<div className="bibtex-import-preview-header orcid-picker-select-all-row">
<div className="wide-target-checkbox bibtex-import-preview-check-all">
<span className="wide-target-checkbox-box">
<div className="form-checkbox">
<input
type="checkbox"
className="form-check-input"
aria-label={t('select_all')}
id={selectAllId}
checked={allSelected}
disabled={importing || worksLoading}
onChange={toggleAll}
/>
</div>
<label className="wide-target-checkbox-target" htmlFor={selectAllId} aria-hidden="true" />
</span>
</div>
<label className="bibtex-import-preview-count" htmlFor={selectAllId}>
{t('select_all')}
</label>
</div>
)}
{works.length > 0 && (
<div className="orcid-picker-works bibtex-import-preview-list" data-testid="orcid-works">
{works.map(w => (
<div className="bibtex-import-preview-card" key={w.putCode}>
<div className="wide-target-checkbox bibtex-import-preview-card-check">
<span className="wide-target-checkbox-box">
<div className="form-checkbox">
<input
type="checkbox"
className="form-check-input"
autoComplete="off"
checked={selectedPutCodes.has(w.putCode)}
disabled={importing}
onChange={() => toggleWork(w.putCode)}
aria-label={w.title || t('untitled')}
/>
</div>
<label className="wide-target-checkbox-target" aria-hidden="true" />
</span>
</div>
<div className="bibtex-import-preview-card-content">
<div className="bibtex-import-preview-card-heading">
{w.title || t('untitled')}
</div>
{w.year && <div className="bibtex-import-preview-card-field">{w.year}</div>}
</div>
</div>
))}
</div>
)}
</div>
</>
)}
{isReport && importSummary && (
<>
<div className="orcid-picker-import-summary" role="status" data-testid="orcid-import-summary">
<div>
{plural(t, importSummary.imported, 'orcid_imported_reference', 'orcid_imported_references')}
</div>
{importSummary.enriched > 0 && (
<div>{plural(t, importSummary.enriched, 'orcid_enriched_reference', 'orcid_enriched_references')}</div>
)}
{crossrefFailures.length > 0 && (
<div className="orcid-picker-enrichment-warning" role="alert">
<div className="orcid-picker-enrichment-danger">
{plural(t, crossrefFailures.length, 'orcid_enrichment_failed_reference', 'orcid_enrichment_failed_references')}
</div>
<ul className="orcid-picker-enrichment-titles">
{crossrefFailures.map((failure, index) => (
<li key={index}>{failure.title}</li>
))}
</ul>
{crossrefFailures.some(failure => failure.reason === 'rate-limit') && (
<div className="orcid-picker-enrichment-danger">{t('orcid_crossref_rate_limited')}</div>
)}
</div>
)}
</div>
{importWarning}
{unimportedTitles.length > 0 && (
<ul className="orcid-picker-enrichment-titles">
{unimportedTitles.map((title, index) => <li key={index}>{title}</li>)}
</ul>
)}
</>
)}
</OLModalBody>
{step === 'works' && (works.length > 0 || isReport) && (
<OLModalFooter>
<div className="bibtex-import-preview-footer-actions">
{!isReport && (
<div className="bibtex-import-preview-footer-count">
{`${selectedPutCodes.size} ${plural(t, selectedPutCodes.size, 'reference', 'references')}`}
</div>
)}
<div className="bibtex-import-preview-footer-buttons orcid-picker-footer-buttons">
<OLButton
variant="secondary"
onClick={handleClose}
disabled={importing}
>
{t(isReport ? 'close' : 'cancel')}
</OLButton>
{!isReport && (
<OLButton
variant="primary"
className="orcid-picker-import-button"
onClick={() => void handleImport()}
disabled={selectedPutCodes.size === 0 || importing}
>
<span className="orcid-picker-import-content">
<span className={importing ? 'invisible' : undefined} aria-hidden={importing}>
{t('import')}
</span>
{importing && (
<span className="spinner-border spinner-border-sm orcid-picker-import-spinner"
role="status" aria-label={t('importing')} />
)}
</span>
</OLButton>
)}
</div>
</div>
</OLModalFooter>
)}
</OLModal>
)
}
@@ -0,0 +1,32 @@
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type VerticalTogglerProps = {
id: string
onClick: () => void
tooltip: string
}
export function VerticalToggler({
id,
onClick,
tooltip,
}: VerticalTogglerProps) {
return (
<OLTooltip
id={id}
description={tooltip}
overlayProps={{ placement: 'top' }}
>
<button
type="button"
className="custom-toggler custom-toggler-vertical"
aria-label={tooltip}
title=""
onClick={onClick}
>
<MaterialIcon type="keyboard_arrow_up" />
</button>
</OLTooltip>
)
}
@@ -0,0 +1,303 @@
import { createContext, FC, useCallback, useContext, useMemo, useRef, useState } from 'react'
import type { EditorView } from '@codemirror/view'
import type { BibFieldWriteTarget } from '../utils/bib-write'
import type { BibAuthorDraft } from '../utils/bib-author-draft'
import type { PositionedBibtexEntry } from '../utils/bib-types'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import {
BIB_DELETE_EVENT,
BIB_IMPORT_EVENT,
BIB_WRITE_EVENT,
dispatchBibMutation,
} from '../utils/bib-editor-events'
import type {
BibEntryIdRemap,
BibHistoryUserEvent,
BibWriteRequest,
BibDeleteRequest,
BibImportRequest,
} from '../utils/bib-editor-events'
export type {
BibEntryIdRemap,
BibHistoryUserEvent,
BibWriteRequest,
BibDeleteRequest,
BibImportRequest,
} from '../utils/bib-editor-events'
export type { BibFieldWriteTarget } from '../utils/bib-write'
const EMPTY_ENTRY_ID_REMAPS: BibEntryIdRemap[] = []
const EMPTY_REMOVED_ENTRY_IDS: string[] = []
export type BibSelection = null | {
entryId: string
from: number
// Stable while the same preview is open, even if the citation key changes.
previewId: number
}
/** Shared contract for visual form edits across the details and Abstract controls. */
export type BibFormChangeHandler = (
entry: BibtexEntry,
historyUserEvent?: BibHistoryUserEvent,
fieldIsValid?: boolean,
fieldTarget?: BibFieldWriteTarget,
draftIsValid?: boolean,
nameDraft?: BibAuthorDraft
) => void
type BibEditorActions = {
setEditorState: (
entries: PositionedBibtexEntry[],
revision: number,
entryIdRemaps?: BibEntryIdRemap[],
removedEntryIds?: string[],
documentId?: string | null
) => void
selectEntry: (entry: PositionedBibtexEntry) => void
bindEditorView: (view: EditorView | null, canWrite?: () => boolean) => void
setRestoredSessionKey: (key: string | null) => void
deselect: () => void
writeEntry: (request: BibWriteRequest) => boolean
importMany: (request: BibImportRequest) => boolean
deleteEntry: (request: BibDeleteRequest) => void
setWriteFailure: (reason: string | null) => void
clearWriteFailure: () => void
setHistoryState: (canUndo: boolean, canRedo: boolean) => void
isCurrentEditorView: (view: EditorView | undefined) => boolean
}
export type BibEditorState = {
entries: PositionedBibtexEntry[]
entriesDocumentId: string | null
restoredSessionKey: string | null
revision: number
entryIdRemaps: BibEntryIdRemap[]
removedEntryIds: string[]
selection: BibSelection
writeFailure: string | null
canUndo: boolean
canRedo: boolean
}
type BibEditorContextValue = BibEditorState & BibEditorActions
const BibEditorContext = createContext<BibEditorContextValue | undefined>(
undefined
)
export const BibEditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [entries, setEntries] = useState<PositionedBibtexEntry[]>([])
const [entriesDocumentId, setEntriesDocumentId] = useState<string | null>(null)
// View and eligibility have one lifetime. Departure commits remain separate.
const editorBindingRef = useRef<{ view: EditorView; canWrite: () => boolean } | null>(null)
const [revision, setRevision] = useState(0)
const [entryIdRemaps, setEntryIdRemaps] = useState(EMPTY_ENTRY_ID_REMAPS)
const [removedEntryIds, setRemovedEntryIds] = useState(EMPTY_REMOVED_ENTRY_IDS)
const [selection, setSelection] = useState<BibSelection>(null)
// Session initialization survives Visual-panel unmounts, just like selection.
const [restoredSessionKey, setRestoredSessionKey] = useState<string | null>(null)
const projectedEntriesRef = useRef<PositionedBibtexEntry[]>([])
const projectionDocumentIdRef = useRef<string | null>(null)
const nextPreviewId = useRef(0)
const [writeFailure, setWriteFailureState] = useState<string | null>(null)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
// Entry ids are derived from citation key plus document-order occurrence index.
// The extension reports both ID remaps and removed source occurrences.
const setEditorState = useCallback(
(
newEntries: PositionedBibtexEntry[],
newRevision: number,
nextEntryIdRemaps?: BibEntryIdRemap[],
nextRemovedEntryIds?: string[],
documentId?: string | null
) => {
const nextDocumentId = documentId ?? null
if (projectionDocumentIdRef.current !== nextDocumentId) {
projectionDocumentIdRef.current = nextDocumentId
setRestoredSessionKey(null)
setSelection(null)
}
setEntriesDocumentId(nextDocumentId)
setRevision(newRevision)
setEntryIdRemaps(
nextEntryIdRemaps?.length ? nextEntryIdRemaps : EMPTY_ENTRY_ID_REMAPS
)
setRemovedEntryIds(
nextRemovedEntryIds?.length
? nextRemovedEntryIds
: EMPTY_REMOVED_ENTRY_IDS
)
const previousEntries = projectedEntriesRef.current
projectedEntriesRef.current = newEntries
setEntries(newEntries)
setWriteFailureState(null)
setSelection(prev => {
if (!prev) return prev
const remap = nextEntryIdRemaps?.find(
item => item.previousEntryId === prev.entryId
)
const identityWasUpdated =
nextEntryIdRemaps !== undefined || nextRemovedEntryIds !== undefined
// Code-mode cut/paste can temporarily remove the current entry, and
// whole-range swaps can report positional renames. Follow a unique key.
const key = prev.entryId.slice(0, prev.entryId.lastIndexOf('#'))
const followKey = !editorBindingRef.current && key &&
previousEntries.filter(item => item.entry.key === key).length <= 1 &&
newEntries.filter(item => item.entry.key === key).length <= 1
const movedEntry = followKey
? newEntries.find(item => item.entry.id === prev.entryId)
: undefined
const selectedEntry = movedEntry ?? (remap
? newEntries.find(entry => entry.from === remap.from)
: identityWasUpdated && nextRemovedEntryIds?.includes(prev.entryId)
? undefined
: newEntries.find(entry => entry.entry.id === prev.entryId))
if (!selectedEntry) return followKey ? prev : null
if (
selectedEntry.entry.id === prev.entryId &&
selectedEntry.from === prev.from
) {
return prev
}
return {
...prev,
entryId: selectedEntry.entry.id,
from: selectedEntry.from,
}
})
},
[]
)
const bindEditorView = useCallback((view: EditorView | null, canWrite?: () => boolean) => {
editorBindingRef.current = view && canWrite ? { view, canWrite } : null
}, [])
const isCurrentEditorView = useCallback((view: EditorView | undefined) => {
return !!view && editorBindingRef.current?.view === view
}, [])
const setWriteFailure = useCallback((reason: string | null) => {
setWriteFailureState(reason)
}, [])
const clearWriteFailure = useCallback(() => {
setWriteFailureState(null)
}, [])
const setHistoryState = useCallback((nextUndo: boolean, nextRedo: boolean) => {
setCanUndo(prev => (prev === nextUndo ? prev : nextUndo))
setCanRedo(prev => (prev === nextRedo ? prev : nextRedo))
}, [])
const selectEntry = useCallback((entry: PositionedBibtexEntry) => {
setSelection({
entryId: entry.entry.id,
from: entry.from,
previewId: ++nextPreviewId.current,
})
}, [])
const deselect = useCallback(() => {
setSelection(null)
}, [])
// The extension owns CodeMirror writes. React requires its explicit
// acknowledgement: a DOM event without a listener is not a successful write.
const writeEntry = useCallback((request: BibWriteRequest) => {
const binding = editorBindingRef.current
if (!binding?.canWrite()) return false
const { view } = binding
return dispatchBibMutation(BIB_WRITE_EVENT, request, view)
}, [])
const importMany = useCallback((request: BibImportRequest) => {
const binding = editorBindingRef.current
if (!binding?.canWrite()) return false
const { view } = binding
return dispatchBibMutation(BIB_IMPORT_EVENT, request, view)
}, [])
const deleteEntry = useCallback((request: BibDeleteRequest) => {
const binding = editorBindingRef.current
if (!binding?.canWrite()) return
const { view } = binding
document.dispatchEvent(
new CustomEvent(BIB_DELETE_EVENT, { detail: { ...request, view } })
)
}, [])
const value = useMemo<BibEditorContextValue>(
() => ({
entries,
entriesDocumentId,
restoredSessionKey,
setRestoredSessionKey,
revision,
entryIdRemaps,
removedEntryIds,
selection,
writeFailure,
canUndo,
canRedo,
setEditorState,
selectEntry,
bindEditorView,
deselect,
writeEntry,
importMany,
deleteEntry,
setWriteFailure,
clearWriteFailure,
setHistoryState,
isCurrentEditorView,
}),
[
entries,
entriesDocumentId,
restoredSessionKey,
setRestoredSessionKey,
revision,
entryIdRemaps,
removedEntryIds,
selection,
writeFailure,
canUndo,
canRedo,
setEditorState,
selectEntry,
bindEditorView,
deselect,
writeEntry,
importMany,
deleteEntry,
setWriteFailure,
clearWriteFailure,
setHistoryState,
isCurrentEditorView,
]
)
return (
<BibEditorContext.Provider value={value}>
{children}
</BibEditorContext.Provider>
)
}
export function useBibEditorContext() {
const context = useContext(BibEditorContext)
if (!context) {
throw new Error(
'useBibEditorContext is only available inside BibEditorProvider'
)
}
return context
}
@@ -0,0 +1,104 @@
/**
* Bridge between the CodeMirror extension (DOM events) and the BibEditor
* React context.
*
* The extension emits parsed entries, undo/redo state and
* guarded-write failures. Projections carry both the CodeMirror view and the
* owning OT document ID, since the same view is reused across file switches.
*/
import React, { useLayoutEffect, useRef } from 'react'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import type { BibEntriesDetail, BibHistoryStateDetail, BibWriteFailureDetail } from '../utils/bib-editor-events'
import {
BibEditorProvider,
useBibEditorContext,
} from './bib-editor-context'
import {
BIB_ENTRIES_EVENT,
BIB_WRITE_FAILED_EVENT,
BIB_HISTORY_STATE_EVENT,
} from '../utils/bib-editor-events'
function BibEditorBridge({ children }: { children: React.ReactNode }) {
const {
setEditorState,
setWriteFailure,
setHistoryState,
isCurrentEditorView,
} = useBibEditorContext()
const { currentDocumentId, openDocName } = useEditorOpenDocContext()
// A newly attached CodeMirror plugin may publish its initial projection
// during the document-switch commit. Keep the listener installed while the
// active document changes, and identify events by document ID, not by a
// view that the Visual panel has not yet bound.
const activeDocumentRef = useRef({ currentDocumentId, openDocName })
activeDocumentRef.current = { currentDocumentId, openDocName }
useLayoutEffect(() => {
// The active document ID is authoritative even before the Visual panel binds a view.
const handler = (ev: Event) => {
const detail = (ev as CustomEvent<BibEntriesDetail>).detail
if (
!detail ||
detail.documentId !== activeDocumentRef.current.currentDocumentId ||
!activeDocumentRef.current.openDocName?.toLowerCase().endsWith('.bib') ||
!detail.entries ||
typeof detail.revision !== 'number' ||
detail.isBibFile !== true
) return
setEditorState(
detail.entries,
detail.revision,
detail.entryIdRemaps,
detail.removedEntryIds,
detail.documentId
)
}
const historyHandler = (ev: Event) => {
const detail = (ev as CustomEvent<BibHistoryStateDetail>).detail
if (detail && isCurrentEditorView(detail.view) && (typeof detail.canUndo === 'boolean' || typeof detail.canRedo === 'boolean')) {
setHistoryState(!!detail.canUndo, !!detail.canRedo)
}
}
const failedHandler = (ev: Event) => {
const detail = (ev as CustomEvent<BibWriteFailureDetail>).detail
// A failed guard is not a local form-validation error: the source changed
// underneath the request, so the UI keeps the warning until a fresh parse
// arrives or the user dismisses it.
if (!isCurrentEditorView(detail?.view)) return
setWriteFailure(detail?.reason ?? 'unknown')
}
document.addEventListener(BIB_HISTORY_STATE_EVENT, historyHandler)
document.addEventListener(BIB_ENTRIES_EVENT, handler)
document.addEventListener(BIB_WRITE_FAILED_EVENT, failedHandler)
return () => {
document.removeEventListener(BIB_HISTORY_STATE_EVENT, historyHandler)
document.removeEventListener(BIB_ENTRIES_EVENT, handler)
document.removeEventListener(
BIB_WRITE_FAILED_EVENT,
failedHandler
)
}
}, [setEditorState, setWriteFailure, setHistoryState, isCurrentEditorView])
return <>{children}</>
}
/**
* Composite provider: BibEditorProvider + BibEditorBridge.
* Registered in rootContextProviders (settings).
*/
export default function BibEditorContextProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<BibEditorProvider>
<BibEditorBridge>{children}</BibEditorBridge>
</BibEditorProvider>
)
}
@@ -0,0 +1,986 @@
/**
* CodeMirror integration for the visual BibTeX editor.
*
* CodeMirror remains authoritative. Visual edits are applied as small changes
* to the affected field/header, while this plugin owns the current source range
* for every entry occurrence. Entry ids are derived from citation key and
* occurrence index, with explicit remaps for targeted visual edits.
*/
import { Annotation, Extension, Facet, Transaction } from '@codemirror/state'
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { undoDepth, redoDepth } from '@codemirror/commands'
import { parseBibFile, parseBibEntryAt, parseBibEntryInRange } from '../utils/bib-entry-position'
import { keyToCommitOnLeave } from '../utils/bib-key-generation'
import type { BibProjectedEntry } from '../utils/bib-entry-position'
import { validateBibSource } from '../utils/bib-source-diagnostics'
import type { BibSourceDiagnostic } from '../utils/bib-source-diagnostics'
import {
getBibEntryDiagnostics,
setBibEntryDiagnostics,
} from '../utils/bib-entry-diagnostics'
import { PositionedBibtexEntry } from '@/features/bibtex/positioned-bibtex-entry'
import {
planBibWrite,
planBibFieldWrite,
planBibBulkDelete,
planBibImport,
type BibFieldWriteTarget,
} from '../utils/bib-write'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { updateOccurrenceIndexes, nearestEntryAt, getEntryIdentityChanges } from '../utils/bib-editor-identity'
import type { PreviousBibEntryIdentity, BibEntryIdentityChanges } from '../utils/bib-editor-identity'
import {
BIB_ENTRIES_EVENT,
BIB_WRITE_EVENT,
BIB_DELETE_EVENT,
BIB_IMPORT_EVENT,
BIB_SCROLL_TO_EVENT,
BIB_WRITE_FAILED_EVENT,
BIB_HISTORY_STATE_EVENT,
BIB_HISTORY_APPLIED_EVENT,
BIB_REQUEST_STATE_EVENT,
} from '../utils/bib-editor-events'
import type { BibEntryIdRemap, BibHistoryUserEvent } from '../utils/bib-editor-events'
export {
BIB_ENTRIES_EVENT,
BIB_WRITE_EVENT,
BIB_DELETE_EVENT,
BIB_IMPORT_EVENT,
BIB_SCROLL_TO_EVENT,
BIB_WRITE_FAILED_EVENT,
BIB_HISTORY_STATE_EVENT,
BIB_HISTORY_APPLIED_EVENT,
BIB_REQUEST_STATE_EVENT,
} from '../utils/bib-editor-events'
type BibVisualTarget = {
mode: 'existing' | 'new'
entryId?: string
sourceStart?: number
/** Guarded field/header write; only this path can use the known entry range. */
local?: boolean
}
export const BIB_VISUAL_TARGET = Annotation.define<BibVisualTarget>()
// Bind each projection to the OT document that created this CodeMirror state.
const bibDocumentId = Facet.define<string | null, string | null>({
combine: values => values[0] ?? null,
})
/**
* Bridges one CodeMirror bibliography document and the React visual editor.
*
* The central invariant is that `this.view.state.doc` is authoritative. The
* cached `entries` array is only a parsed projection for rendering and for
* preserving entry identity across CodeMirror transactions.
*/
class BibEditorPlugin {
private entries: PositionedBibtexEntry[] = []
private dispatchHandler: ((ev: Event) => void) | null = null
private scrollHandler: ((ev: Event) => void) | null = null
private cancelScrollAlignment: (() => void) | null = null
private requestStateHandler: ((ev: Event) => void) | null = null
private destroyed = false
private readonly documentId: string | null
private revision = 0
private diagnostics: BibSourceDiagnostic[] = []
private validationTimer: ReturnType<typeof setTimeout> | null = null
constructor(private view: EditorView) {
this.documentId = view.state.facet(bibDocumentId)
this.setupDispatchListener()
this.setupScrollListener()
const source = this.view.state.doc.toString()
this.entries = this.parseEntries(source)
updateOccurrenceIndexes(this.entries)
this.diagnostics = validateBibSource(source)
this.emitState()
this.emitHistoryState()
}
/** Write a departing preview's key against the current plugin projection.
* Called only after the host verifies that this document still owns OT.
* Never invoked by ordinary Code-mode edits or source parsing.
*/
commitKeyOnExit(entryId: string, draft?: BibtexEntry): void {
if (this.destroyed || this.view.state.readOnly) return
const current = this.entries.find(item => item.entry.id === entryId)
if (!current) return
const key = keyToCommitOnLeave(
current,
this.entries.map(item => item.entry.key),
draft
)
if (!key) return
const source = this.view.state.doc.toString()
const guard = planBibFieldWrite(
source.slice(current.from, current.to),
current.entry.setKey(key),
{ kind: 'citation-key' }
)
if (!guard.ok) return
this.view.dispatch({
changes: {
from: current.from + guard.plan.from,
to: current.from + guard.plan.to,
insert: guard.plan.insert,
},
annotations: [
Transaction.userEvent.of('input.complete'),
BIB_VISUAL_TARGET.of({ mode: 'existing', entryId, local: true }),
],
})
}
/**
* Reparse visual edits locally and try incremental projection for undo/redo.
* Arbitrary structural changes fall back to a full parse. All three paths
* reconcile occurrence identities and emit the resulting state once.
*/
update(update: ViewUpdate) {
if (!update.docChanged) return
this.revision++
const oldEntries = this.entries
// Save ids before recalculating duplicate-key occurrence indexes, since the
// parsed entries can be reused across projections.
const previousIdentities: PreviousBibEntryIdentity[] = oldEntries.map(entry => ({
entryId: entry.entry.id,
from: entry.from,
to: entry.to,
}))
const visualTarget = update.transactions
.map(transaction => transaction.annotation(BIB_VISUAL_TARGET))
.find(target => target !== undefined)
const visualUpdate = visualTarget
? this.applyVisualChange(update, oldEntries, visualTarget)
: null
const historyEvent = update.transactions.find(transaction =>
transaction.isUserEvent('undo') || transaction.isUserEvent('redo')
)
if (visualUpdate) {
this.entries = visualUpdate.entries
} else {
const incremental = historyEvent
? this.applyIncrementalChange(update, oldEntries)
: null
this.entries = incremental ?? this.parseEntries(this.view.state.doc.toString())
}
this.remapDiagnostics(update, oldEntries)
updateOccurrenceIndexes(this.entries)
if (visualUpdate) {
const changedEntry = this.entries.find(
entry => entry.from === visualUpdate.entryFrom
)
if (changedEntry) this.refreshEntryDiagnostics(changedEntry)
}
const identityChanges = getEntryIdentityChanges(
update.changes,
previousIdentities,
this.entries
)
this.emitState(identityChanges.entryIdRemaps, identityChanges.removedEntryIds)
this.emitHistoryState()
if (!visualUpdate) {
if (historyEvent) {
this.emitHistoryApplied(update, historyEvent, previousIdentities, identityChanges)
}
this.scheduleFullValidation()
}
}
destroy() {
this.destroyed = true
if (this.validationTimer !== null) clearTimeout(this.validationTimer)
this.validationTimer = null
if (this.dispatchHandler) {
document.removeEventListener(BIB_WRITE_EVENT, this.dispatchHandler)
document.removeEventListener(BIB_DELETE_EVENT, this.dispatchHandler)
document.removeEventListener(BIB_IMPORT_EVENT, this.dispatchHandler)
this.dispatchHandler = null
}
if (this.requestStateHandler) {
document.removeEventListener(BIB_REQUEST_STATE_EVENT, this.requestStateHandler)
this.requestStateHandler = null
}
if (this.scrollHandler) {
document.removeEventListener(BIB_SCROLL_TO_EVENT, this.scrollHandler)
this.scrollHandler = null
}
this.cancelScrollAlignment?.()
this.cancelScrollAlignment = null
}
private emitHistoryState() {
document.dispatchEvent(
new CustomEvent(BIB_HISTORY_STATE_EVENT, {
detail: {
canUndo: undoDepth(this.view.state) > 0,
canRedo: redoDepth(this.view.state) > 0,
view: this.view,
},
})
)
}
/** Emit the current parsed projection. Entry ids derive from citation keys and
* their document-order occurrence indexes, so React receives explicit remaps
* whenever an edit changes those ids.
*/
private emitState(
entryIdRemaps?: BibEntryIdRemap[],
removedEntryIds?: string[]
) {
const diagnosticByOccurrence = new Map<string, BibSourceDiagnostic[]>()
const entryByStart = new Map(this.entries.map(entry => [entry.from, entry] as const))
// Keep all diagnostics for each entry, including independent syntax errors.
for (const diagnostic of this.diagnostics) {
const exact = diagnostic.entryFrom === undefined
? undefined
: entryByStart.get(diagnostic.entryFrom)
const target = exact ?? nearestEntryAt(this.entries, diagnostic.from)
if (!target) continue
const messages = diagnosticByOccurrence.get(target.entry.id) ?? []
messages.push(diagnostic)
diagnosticByOccurrence.set(target.entry.id, messages)
}
// Preserve unchanged positioned-entry objects. This still refreshes the
// exported array, but avoids allocating a new class instance for every entry
// when a local field edit changes diagnostics on only one occurrence.
this.entries = this.entries.map(entry => {
const nextDiagnostics = diagnosticByOccurrence.get(entry.entry.id) ?? []
const oldDiagnostics = getBibEntryDiagnostics(entry)
if (
oldDiagnostics.length === nextDiagnostics.length &&
oldDiagnostics.every((old, index) => {
const next = nextDiagnostics[index]
return old.from === next.from && old.line === next.line &&
old.code === next.code && old.field === next.field &&
old.relatedFrom === next.relatedFrom
})
) {
return entry
}
const projected = this.makePositioned(
entry.entry,
entry.from,
entry.to,
nextDiagnostics,
(entry as BibProjectedEntry).sourceFieldRanges
) as BibProjectedEntry
return projected
})
document.dispatchEvent(
new CustomEvent(BIB_ENTRIES_EVENT, {
detail: {
isBibFile: true,
documentId: this.documentId,
entries: this.entries,
revision: this.revision,
...(entryIdRemaps !== undefined
? { entryIdRemaps, removedEntryIds: removedEntryIds ?? [] }
: {}),
view: this.view,
},
})
)
}
/**
* Preserve diagnostics for entries untouched by a transaction and map their
* absolute coordinates through the same CodeMirror changes as entry ranges.
* Diagnostics belonging to edited entries are recalculated separately or by
* the debounced full-document validation.
*/
private remapDiagnostics(update: ViewUpdate, previousEntries: PositionedBibtexEntry[]) {
const changed: Array<{ from: number; to: number }> = []
update.changes.iterChangedRanges((fromA, toA) => changed.push({ from: fromA, to: toA }))
const previousEntryByStart = new Map(previousEntries.map(entry => [entry.from, entry] as const))
this.diagnostics = this.diagnostics
.filter(diagnostic => {
const owner = diagnostic.entryFrom === undefined
? undefined
: previousEntryByStart.get(diagnostic.entryFrom)
if (owner) {
return !changed.some(range =>
range.from < owner.to && range.to > owner.from ||
range.from === range.to && range.from > owner.from && range.from < owner.to
)
}
return !changed.some(range => diagnostic.from >= range.from && diagnostic.from <= range.to)
})
.map(diagnostic => {
const from = update.changes.mapPos(diagnostic.from, 1)
const entryFrom = diagnostic.entryFrom === undefined
? undefined
: update.changes.mapPos(diagnostic.entryFrom, 1)
const relatedFrom = diagnostic.relatedFrom === undefined
? undefined
: update.changes.mapPos(diagnostic.relatedFrom, 1)
return this.withCurrentLine({ ...diagnostic, from, entryFrom, relatedFrom })
})
.sort((a, b) => a.from - b.from)
}
private withCurrentLine(diagnostic: BibSourceDiagnostic): BibSourceDiagnostic {
const document = this.view.state.doc
const lineAt = (from: number) =>
document.lineAt(Math.max(0, Math.min(from, document.length))).number
return {
...diagnostic,
line: lineAt(diagnostic.from),
relatedLine: diagnostic.relatedFrom === undefined
? undefined
: lineAt(diagnostic.relatedFrom),
}
}
/** Revalidate the changed entry only; ordinary visual keystrokes avoid a full-file scan. */
private refreshEntryDiagnostics(entry: PositionedBibtexEntry) {
const raw = this.view.state.sliceDoc(entry.from, entry.to)
const diagnostics = validateBibSource(raw).map(diagnostic => {
const from = entry.from + diagnostic.from
return this.withCurrentLine({
...diagnostic,
from,
entryFrom: entry.from,
relatedFrom: diagnostic.relatedFrom === undefined
? undefined
: entry.from + diagnostic.relatedFrom,
})
})
this.diagnostics.push(...diagnostics)
this.diagnostics.sort((a, b) => a.from - b.from)
}
/** Code-mode edits can change structure anywhere; coalesce a full validation. */
private scheduleFullValidation() {
if (this.validationTimer !== null) clearTimeout(this.validationTimer)
this.validationTimer = setTimeout(() => {
this.validationTimer = null
this.diagnostics = validateBibSource(this.view.state.doc.toString())
this.emitState()
}, 200)
}
private parseEntries(
source: string,
range?: { from: number; to: number },
trustedWindow?: { from: number; to: number }
): PositionedBibtexEntry[] {
try {
const parsed = parseBibFile(source, range, trustedWindow)
const doc = this.view.state.doc
return parsed.map(entry =>
Object.assign(entry, {
line: doc.lineAt(entry.from).number,
toLine: doc.lineAt(entry.to).number,
})
)
} catch {
// Malformed source may temporarily have no safe visual projection. The
// original text remains untouched in CodeMirror and can still be edited
// from the source editor.
return []
}
}
private makePositioned(
entry: BibtexEntry,
from: number,
to: number,
diagnostics: BibSourceDiagnostic[] = [],
sourceFieldRanges?: BibProjectedEntry['sourceFieldRanges']
): PositionedBibtexEntry {
const doc = this.view.state.doc
const positioned = Object.assign(new PositionedBibtexEntry(), {
entry,
from,
to,
line: doc.lineAt(from).number,
toLine: doc.lineAt(to).number,
})
if (sourceFieldRanges) {
(positioned as BibProjectedEntry).sourceFieldRanges = sourceFieldRanges
}
setBibEntryDiagnostics(positioned, diagnostics)
return positioned
}
/** Remap an unchanged entry through a CodeMirror document change. */
private mapEntry(update: ViewUpdate, entry: PositionedBibtexEntry) {
return this.makePositioned(
entry.entry,
update.changes.mapPos(entry.from, 1),
update.changes.mapPos(entry.to, -1),
getBibEntryDiagnostics(entry),
(entry as BibProjectedEntry).sourceFieldRanges
)
}
/** Reparse the visual edit's entry; fall back to a full parse if it is lost. */
private applyVisualChange(
update: ViewUpdate,
oldEntries: PositionedBibtexEntry[],
target: BibVisualTarget
): { entries: PositionedBibtexEntry[]; entryFrom: number } | null {
const source = this.view.state.doc.toString()
const previous = target.mode === 'existing'
? oldEntries.find(entry => entry.entry.id === target.entryId)
: undefined
if (target.mode === 'existing' && !previous) return null
const sourceStart = previous
? update.changes.mapPos(previous.from, 1)
: target.sourceStart
if (sourceStart === undefined) return null
let parsed: BibProjectedEntry | null = null
if (previous && target.local) {
// The field-write planner has already checked a single source entry.
// Map its old end through the same transaction instead of reparsing the
// entire bibliography to rediscover a boundary we already own.
const end = update.changes.mapPos(previous.to, -1)
if (end > sourceStart) {
parsed = parseBibEntryInRange(source, sourceStart, end)
}
}
// Whole-entry replacements and edits that change an entry's structure
// must still rediscover boundaries from the full document.
if (!parsed) parsed = parseBibEntryAt(source, sourceStart)
if (!parsed) return null
const entries = oldEntries.map(entry =>
entry === previous
? this.makePositioned(parsed.entry, parsed.from, parsed.to, [], parsed.sourceFieldRanges)
: this.mapEntry(update, entry)
)
if (target.mode === 'new') {
entries.push(this.makePositioned(parsed.entry, parsed.from, parsed.to, [], parsed.sourceFieldRanges))
}
entries.sort((a, b) => a.from - b.from)
return { entries, entryFrom: parsed.from }
}
/**
* Update only entries touched by an undo/redo transaction. If a transaction
* changes something that cannot be projected safely from the previous entry
* ranges, return null and let the normal full parse handle it.
*/
private applyIncrementalChange(
update: ViewUpdate,
oldEntries: PositionedBibtexEntry[]
): PositionedBibtexEntry[] | null {
const changes: Array<{
fromA: number
toA: number
fromB: number
toB: number
}> = []
update.changes.iterChanges((fromA, toA, fromB, toB) => {
changes.push({ fromA, toA, fromB, toB })
})
if (changes.length !== 1) return null
const change = changes[0]
const source = this.view.state.doc.toString()
const next: PositionedBibtexEntry[] = []
for (const entry of oldEntries) {
const mappedFrom = update.changes.mapPos(entry.from, 1)
const mappedTo = update.changes.mapPos(entry.to, -1)
const affected = change.fromA <= entry.to && change.toA >= entry.from
if (!affected) {
// Entries before the edit retain both offsets and content. Reuse their
// positioned model instead of allocating a wrapper on every undo/redo.
next.push(
mappedFrom === entry.from && mappedTo === entry.to
? entry
: this.mapEntry(update, entry)
)
continue
}
if (mappedTo <= mappedFrom) continue
const parsed = parseBibEntryAt(source, mappedFrom, mappedTo)
if (!parsed) return null
next.push(this.makePositioned(parsed.entry, parsed.from, parsed.to, [], parsed.sourceFieldRanges))
}
if (change.toB > change.fromB) {
// Surviving, trusted entries delimit a self-contained local parse.
// If a neighbor is malformed, use the full Lezer projection instead.
const before = [...next].reverse().find(entry => entry.to <= change.fromB)
const after = next.find(entry => entry.from >= change.toB)
const trusted = (!before || (before as BibProjectedEntry).sourceFieldRanges) &&
(!after || (after as BibProjectedEntry).sourceFieldRanges)
const window = trusted ? {
from: before?.to ?? 0,
to: after?.from ?? source.length,
} : undefined
const inserted = this.parseEntries(source, {
from: change.fromB,
to: change.toB,
}, window)
const existingStarts = new Set(next.map(entry => entry.from))
for (const entry of inserted) {
if (
existingStarts.has(entry.from) ||
next.some(current => entry.from > current.from && entry.from < current.to)
) {
continue
}
next.push(entry)
existingStarts.add(entry.from)
}
}
next.sort((a, b) => a.from - b.from)
const doc = this.view.state.doc
return next.map(entry => {
const line = doc.lineAt(entry.from).number
const toLine = doc.lineAt(entry.to).number
if (entry.line === line && entry.toLine === toLine) return entry
return this.makePositioned(
entry.entry,
entry.from,
entry.to,
getBibEntryDiagnostics(entry),
(entry as BibProjectedEntry).sourceFieldRanges
)
})
}
private emitHistoryApplied(
update: ViewUpdate,
historyEvent: Transaction,
oldEntries: PreviousBibEntryIdentity[],
identityChanges: BibEntryIdentityChanges
) {
const changedRanges: Array<{ from: number; to: number }> = []
update.changes.iterChangedRanges((from, to) => {
changedRanges.push({ from, to })
})
// Emit one reset for every existing entry touched by this history step.
// A single undo transaction can change more than one range or entry.
const kind = historyEvent.isUserEvent('undo') ? 'undo' : 'redo'
const emitted = new Set<string>()
for (const previous of oldEntries) {
const wasChanged = changedRanges.some(({ from, to }) =>
from === to
? from >= previous.from && from < previous.to
: from < previous.to && to > previous.from
)
if (!wasChanged || emitted.has(previous.entryId)) continue
const current = identityChanges.currentByPreviousId.get(previous.entryId)
if (!current) continue
emitted.add(previous.entryId)
document.dispatchEvent(
new CustomEvent(BIB_HISTORY_APPLIED_EVENT, {
detail: {
kind,
// Key changes can remount the form before React sees this event.
// Include both identities so listeners can invalidate the old draft.
previousEntryId: previous.entryId,
entryId: current.entry.id,
entry: current.entry,
view: this.view,
},
})
)
}
}
/**
* Receive write/import/delete requests from React. Live field edits deliberately
* do not carry an old document snapshot: rapid typing can produce another
* transaction before React renders the previous one. Other writes and imports
* honor the supplied snapshot guards so they cannot apply stale data.
*/
private setupDispatchListener() {
this.dispatchHandler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as
| { view: EditorView; accepted?: boolean }
| undefined
if (!detail || detail.view !== this.view || this.view.state.readOnly) return
const {
entry,
entries,
mode,
entryId,
expectedEntry,
expectedRevision,
expectedDocument,
historyUserEvent,
fieldTarget,
} = detail as {
entry?: BibtexEntry
entries?: BibtexEntry[]
mode: 'existing' | 'new'
entryId?: string
expectedEntry?: BibtexEntry
expectedRevision?: number
expectedDocument?: unknown
historyUserEvent?: BibHistoryUserEvent
fieldTarget?: BibFieldWriteTarget
}
// Existing field writes target an entry id resolved against the current
// CodeMirror projection. They intentionally tolerate a React
// render lag while the user types. Revision/document guards are reserved
// for structural operations that must not apply to a stale snapshot.
const isLiveFieldWrite = ev.type === BIB_WRITE_EVENT &&
mode === 'existing' && fieldTarget !== undefined &&
entryId !== undefined
if (
!isLiveFieldWrite &&
ev.type !== BIB_DELETE_EVENT &&
((typeof expectedRevision === 'number' &&
expectedRevision !== this.revision) ||
(expectedDocument !== undefined &&
expectedDocument !== this.view.state.doc))
) {
this.emitWriteFailed('doc-changed')
ev.preventDefault()
return
}
if (ev.type === BIB_WRITE_EVENT) {
if (!entry) return
const source = this.view.state.doc.toString()
const current = entryId === undefined
? undefined
: this.entries.find(candidate => candidate.entry.id === entryId)
const isFieldWrite = mode === 'existing' && fieldTarget !== undefined &&
entryId !== undefined
if (isFieldWrite && (!current || (expectedEntry && current.entry !== expectedEntry))) {
this.emitWriteFailed('entry-gone')
ev.preventDefault()
return
}
// Clearing a field that exists only in the form is a source no-op.
if (
isFieldWrite && current && fieldTarget.kind === 'field' &&
!entry.hasField(fieldTarget.name) &&
!current.entry.hasField(fieldTarget.name)
) {
detail.accepted = true
return
}
const offset = isFieldWrite && current ? current.from : 0
const guard = isFieldWrite && current
? planBibFieldWrite(
source.slice(current.from, current.to),
entry,
fieldTarget,
fieldTarget.kind === 'field' && current.entry.hasField(fieldTarget.name)
)
: planBibWrite(source, entry, mode, current?.from)
if (!guard.ok) {
this.emitWriteFailed(guard.reason)
ev.preventDefault()
return
}
this.view.dispatch({
changes: {
from: offset + guard.plan.from,
to: offset + guard.plan.to,
insert: guard.plan.insert,
},
annotations: [
Transaction.userEvent.of(
historyUserEvent ?? (mode === 'new' ? 'input.complete' : 'input.type')
),
BIB_VISUAL_TARGET.of(mode === 'existing'
? { mode, entryId, local: isFieldWrite }
: { mode, sourceStart: guard.plan.sourceStart ?? guard.plan.from }),
],
})
detail.accepted = true
} else if (ev.type === BIB_IMPORT_EVENT) {
const entriesArg = entries ?? []
if (entriesArg.length === 0) return
const source = this.view.state.doc.toString()
const guard = planBibImport(source, entriesArg)
if (guard.ok === false) {
this.emitWriteFailed(guard.reason)
ev.preventDefault()
return
}
this.view.dispatch({
changes: guard.changes,
annotations: Transaction.userEvent.of('input.complete'),
})
detail.accepted = true
} else if (ev.type === BIB_DELETE_EVENT) {
const { entryIds } = detail as { entryIds?: string[] }
const ids = entryIds ?? (entryId ? [entryId] : [])
if (!entryIds && ids.length === 0) {
this.emitWriteFailed('entry-gone')
ev.preventDefault()
return
}
const byId = new Map(this.entries.map(entry => [entry.entry.id, entry] as const))
const starts = ids
.map(id => byId.get(id)?.from)
.filter((start): start is number => start !== undefined)
if (starts.length !== ids.length) {
this.emitWriteFailed('entry-gone')
ev.preventDefault()
return
}
const guard = planBibBulkDelete(
this.view.state.doc.toString(),
starts
)
if (!guard.ok) {
this.emitWriteFailed(guard.reason)
return
}
if (guard.changes.length === 0) return
this.view.dispatch({
changes: guard.changes,
annotations: Transaction.userEvent.of('delete'),
})
}
}
document.addEventListener(BIB_WRITE_EVENT, this.dispatchHandler)
document.addEventListener(BIB_DELETE_EVENT, this.dispatchHandler)
document.addEventListener(BIB_IMPORT_EVENT, this.dispatchHandler)
this.requestStateHandler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as
| { view?: EditorView }
| undefined
if (detail?.view !== this.view) return
// Re-run structural diagnostics whenever the visual panel requests a
// fresh projection. This covers returning from source mode even if its
// debounced validation was cancelled by an editor/view lifecycle change.
if (this.validationTimer !== null) clearTimeout(this.validationTimer)
this.validationTimer = null
this.diagnostics = validateBibSource(this.view.state.doc.toString())
this.emitState()
this.emitHistoryState()
}
document.addEventListener(BIB_REQUEST_STATE_EVENT, this.requestStateHandler)
}
private emitWriteFailed(reason: string) {
document.dispatchEvent(
new CustomEvent(BIB_WRITE_FAILED_EVENT, {
detail: { reason, view: this.view },
})
)
}
private setupScrollListener() {
this.scrollHandler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as
| { view?: EditorView; position?: number; entryId?: string }
| undefined
if (!detail || detail.view !== this.view) return
// A new request supersedes any pending alignment for this view.
this.cancelScrollAlignment?.()
this.cancelScrollAlignment = null
const resolvePosition = () => {
if (detail.entryId) {
return this.entries.find(
entry => entry.entry.id === detail.entryId
)?.from
}
return typeof detail.position === 'number'
? detail.position
: undefined
}
if (resolvePosition() === undefined) return
// Do not issue the scroll effect until CodeMirror has a real layout. After
// alignment, ResizeObserver keeps the target at the top while the revealed
// editor is resizing, then releases the request once layout settles.
let cancelled = false
let frame = 0
let scrollIssued = false
let settledTimer: number | null = null
const startedAt = performance.now()
let observer: ResizeObserver | null = null
const cleanup = () => {
if (cancelled) return
cancelled = true
if (frame) cancelAnimationFrame(frame)
if (settledTimer !== null) window.clearTimeout(settledTimer)
observer?.disconnect()
if (this.cancelScrollAlignment === cleanup) {
this.cancelScrollAlignment = null
}
}
this.cancelScrollAlignment = cleanup
const releaseAfterLayoutSettles = () => {
if (settledTimer !== null) window.clearTimeout(settledTimer)
// A resize during this grace period cancels the timer and realigns again.
settledTimer = window.setTimeout(cleanup, 250)
}
const scheduleAlignment = () => {
if (cancelled || frame) return
frame = requestAnimationFrame(() => {
frame = 0
if (cancelled) return
if (resolvePosition() === undefined) {
cleanup()
return
}
if (!this.view.dom.isConnected) {
// The source editor may be detached briefly while React changes modes.
if (performance.now() - startedAt >= 2000) cleanup()
else scheduleAlignment()
return
}
const viewport = this.view.scrollDOM
const editorRect = this.view.dom.getBoundingClientRect()
if (viewport.clientHeight === 0 || editorRect.height === 0) {
// Stay pending while the editor is hidden; ResizeObserver will also
// wake this request as soon as the source editor becomes measurable.
if (performance.now() - startedAt >= 2000) cleanup()
else scheduleAlignment()
return
}
const livePosition = resolvePosition()
if (livePosition === undefined) {
cleanup()
return
}
const pos = Math.min(
Math.max(0, livePosition),
this.view.state.doc.length
)
if (!scrollIssued) {
this.view.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, {
y: 'start',
yMargin: 0,
}),
})
this.view.focus()
scrollIssued = true
}
// CodeMirror batches this DOM read and the exact scroll correction in
// its own measure cycle, avoiding a race with its built-in scroll effect.
this.view.requestMeasure({
read: () => {
const currentPosition = resolvePosition()
if (currentPosition === undefined) return null
const currentPos = Math.min(
Math.max(0, currentPosition),
this.view.state.doc.length
)
const coords = this.view.coordsAtPos(currentPos)
const scrollRect = viewport.getBoundingClientRect()
const currentEditorRect = this.view.dom.getBoundingClientRect()
return {
delta: coords ? coords.top - scrollRect.top : null,
usable:
viewport.clientHeight > 0 && currentEditorRect.height > 0,
}
},
write: measurement => {
if (cancelled) return
if (!measurement || !measurement.usable || measurement.delta === null) {
scrollIssued = false
if (performance.now() - startedAt >= 2000) cleanup()
else scheduleAlignment()
return
}
if (Math.abs(measurement.delta) > 1) {
const before = viewport.scrollTop
viewport.scrollTop = before + measurement.delta
const after = viewport.scrollTop
const maxScrollTop = viewport.scrollHeight - viewport.clientHeight
const cannotScrollFurther =
after === before &&
((measurement.delta < 0 && before <= 0) ||
(measurement.delta > 0 && before >= maxScrollTop - 1))
// At the end of a document there may not be enough content below
// the target to put it at the viewport top. Do not retry forever.
if (cannotScrollFurther) {
releaseAfterLayoutSettles()
return
}
}
releaseAfterLayoutSettles()
},
})
})
}
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
if (settledTimer !== null) {
window.clearTimeout(settledTimer)
settledTimer = null
}
scheduleAlignment()
})
observer.observe(this.view.dom)
observer.observe(this.view.scrollDOM)
}
scheduleAlignment()
}
document.addEventListener(BIB_SCROLL_TO_EVENT, this.scrollHandler)
}
}
const bibEditorPlugin = ViewPlugin.fromClass(BibEditorPlugin)
export function commitBibKeyOnExit(view: EditorView, entryId: string, draft?: BibtexEntry): void {
view.plugin(bibEditorPlugin)?.commitKeyOnExit(entryId, draft)
}
export const extension = (options?: {
currentDoc?: { currentDoc?: { doc_id: string } }
}): Extension => [
bibEditorPlugin,
bibDocumentId.of(options?.currentDoc?.currentDoc?.doc_id ?? null),
]
@@ -0,0 +1,64 @@
/** Bind generic ORCID results to the guarded atomic BibTeX editor import. */
import { useCallback, useEffect, useRef } from 'react'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useBibEditorContext } from '../../context/bib-editor-context'
import type { OrcidImportItem, ImportResult } from '../orcid-api'
import { inspectOrcidImport, prepareOrcidImport } from '../../utils/bib-orcid-import'
type ImportGuard = {
documentId: string
revision: number
doc: unknown
keys: string[]
}
export function useBibOrcidImport(show: boolean) {
const { entries, revision, importMany } = useBibEditorContext()
const { currentDocumentId } = useEditorOpenDocContext()
const view = useCodeMirrorViewContext()
const guardRef = useRef<ImportGuard | null>(null)
const entriesRef = useRef(entries)
entriesRef.current = entries
// Capture the document at opening, not at import time. An unrelated edit or
// document switch must not redirect an in-flight ORCID import to another file.
useEffect(() => {
if (!show) {
guardRef.current = null
} else if (!guardRef.current && currentDocumentId) {
guardRef.current = {
documentId: currentDocumentId,
revision,
doc: view.state.doc,
keys: entriesRef.current.map(item => item.entry.key),
}
}
}, [show, currentDocumentId, revision, view])
const handleInspect = useCallback((item: OrcidImportItem) => inspectOrcidImport(item), [])
const handleInsert = useCallback(
(items: OrcidImportItem[]): ImportResult | false => {
const guard = guardRef.current
if (!guard || guard.documentId !== currentDocumentId ||
guard.doc !== view.state.doc) return false
const prepared = prepareOrcidImport(items, guard.keys)
if (!prepared) return false
const committed = importMany({
entries: prepared.entries,
expectedRevision: guard.revision,
expectedDocument: guard.doc,
})
return committed ? {
imported: prepared.entries.length,
enriched: prepared.enriched,
unusableIndices: prepared.unusableIndices,
skippedIndices: prepared.skippedIndices,
} : false
},
[importMany, currentDocumentId, view]
)
return { onInspect: handleInspect, onInsert: handleInsert }
}
@@ -0,0 +1,488 @@
/** ORCID search, selection and enrichment state for the BibTeX import modal. */
import { useState, useCallback, useEffect, useRef } from 'react'
import { withWorkSummary, workMetadataScore, hasMissingWorkFields } from '../work-metadata'
import { searchOrcidAuthors, fetchOrcidWorks, fetchOrcidWorkDetails, fetchDoiMetadataBatch, isValidOrcid, parseImportItem, workDoi, normalizedDoi, isRateLimit, errorMessage } from '../orcid-api'
import type { AuthorResult, Work, WorkVariant, OrcidImportItem, ImportResult } from '../orcid-api'
type OrcidInspection = { needsMetadata: boolean }
type OrcidPickerOptions = {
show: boolean
onHide: () => void
onInspect: (item: OrcidImportItem) => OrcidInspection | null
onInsert: (items: OrcidImportItem[]) => ImportResult | false | Promise<ImportResult | false>
t: (key: string, options?: Record<string, unknown>) => string
}
type Step = 'search' | 'works'
const ORCID_BATCH_SIZE = 100
type CrossrefFailure = 'rate-limit' | 'unavailable'
type FailedEnrichment = { title: string; reason: CrossrefFailure }
type ImportSummary = { imported: number; enriched: number }
export function useOrcidPicker({ show, onHide, onInsert, onInspect, t }: OrcidPickerOptions) {
// -- step state --
const [step, setStep] = useState<Step>('search')
// -- search step --
const [searchMode, setSearchMode] = useState<'name' | 'orcid'>('name')
const [nameQuery, setNameQuery] = useState('')
const [orcidInput, setOrcidInput] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const [authors, setAuthors] = useState<AuthorResult[]>([])
// -- works step --
const [selectedOrcid, setSelectedOrcid] = useState<string | null>(null)
const [selectedAuthorName, setSelectedAuthorName] = useState('')
const [works, setWorks] = useState<Work[]>([])
const [worksLoading, setWorksLoading] = useState(false)
const [worksError, setWorksError] = useState<string | null>(null)
// Keep successful details across failed imports, avoiding repeat ORCID calls.
const workDetails = useRef(new Map<number, OrcidImportItem>())
// Retry large failed ORCID batches in smaller groups on the next user click.
// No automatic retry (especially after HTTP 429).
const retryBatchSizes = useRef(new Map<number, number>())
const [selectedPutCodes, setSelectedPutCodes] = useState<Set<number>>(
new Set()
)
// -- import state --
const [importing, setImporting] = useState(false)
const [importError, setImportError] = useState<string | null>(null)
const [importSummary, setImportSummary] = useState<ImportSummary | null>(null)
const [crossrefFailures, setCrossrefFailures] = useState<FailedEnrichment[]>([])
const [unimportedTitles, setUnimportedTitles] = useState<string[]>([])
const requests = useRef<Partial<Record<'search' | 'works' | 'import', AbortController>>>({})
const cancelRequest = (kind: 'search' | 'works' | 'import') => {
requests.current[kind]?.abort()
delete requests.current[kind]
}
const cancelAll = useCallback(() => {
for (const controller of Object.values(requests.current)) controller?.abort()
requests.current = {}
}, [])
const resetPicker = useCallback(() => {
cancelAll()
setStep('search')
setSearchMode('name')
setNameQuery('')
setOrcidInput('')
setSearchLoading(false)
setSearchError(null)
setAuthors([])
setSelectedOrcid(null)
setSelectedAuthorName('')
setWorks([])
setWorksLoading(false)
setWorksError(null)
workDetails.current.clear()
retryBatchSizes.current.clear()
setSelectedPutCodes(new Set())
setImporting(false)
setImportError(null)
setImportSummary(null)
setCrossrefFailures([])
setUnimportedTitles([])
}, [cancelAll])
useEffect(() => {
if (!show) resetPicker()
return cancelAll
}, [show, resetPicker, cancelAll])
const handleClose = () => {
resetPicker()
onHide()
}
const handleSearchModeChange = (mode: 'name' | 'orcid') => {
cancelRequest('search')
setSearchLoading(false)
setSearchMode(mode)
setSearchError(null)
setAuthors([])
}
// Search by name
const handleSearchByName = async () => {
if (!nameQuery.trim() || searchLoading) return
cancelRequest('search')
const controller = new AbortController()
requests.current.search = controller
setSearchError(null)
setAuthors([])
setSearchLoading(true)
try {
const data = await searchOrcidAuthors(nameQuery.trim(), controller.signal)
if (requests.current.search !== controller) return
setAuthors(data.results)
if (data.results.length === 0) {
setSearchError(t('no_authors_found'))
}
} catch (err) {
if (requests.current.search === controller) {
setSearchError(errorMessage(err, t('search_failed'), t('invalid_response_from_server'), t('provider_rate_limited')))
}
} finally {
if (requests.current.search === controller) {
delete requests.current.search
setSearchLoading(false)
}
}
}
// Only the one ORCID works-summary request runs when opening the list.
// Full details and DOI resolution are deferred to Import.
const handlePickAuthor = async (orcid: string, displayName: string) => {
cancelRequest('works')
const controller = new AbortController()
requests.current.works = controller
setSelectedOrcid(orcid)
setSelectedAuthorName(displayName)
setStep('works')
setWorksError(null)
setImportError(null)
setImportSummary(null)
setCrossrefFailures([])
setUnimportedTitles([])
setWorks([])
setSelectedPutCodes(new Set())
workDetails.current.clear()
retryBatchSizes.current.clear()
setWorksLoading(true)
try {
const data = await fetchOrcidWorks(orcid, controller.signal)
if (requests.current.works !== controller) return
const returnedWorks = data.works
setWorks(returnedWorks)
if (!returnedWorks.length) setWorksError(t('no_works_found_for_this_author'))
} catch (err) {
if (requests.current.works === controller) {
setWorksError(errorMessage(err, t('failed_to_load_works'), t('invalid_response_from_server'), t('provider_rate_limited')))
}
} finally {
if (requests.current.works === controller) {
delete requests.current.works
setWorksLoading(false)
}
}
}
// Direct ORCID input
const handleGoToWorksByOrcid = async () => {
const orcid = orcidInput.trim()
if (!isValidOrcid(orcid)) return
setSearchError(null)
await handlePickAuthor(orcid, orcid)
}
// Toggle a work selection (by put-code)
const toggleWork = (putCode: number) => {
if (importing) return
setSelectedPutCodes(prev => {
const next = new Set(prev)
if (next.has(putCode)) {
next.delete(putCode)
} else {
next.add(putCode)
}
return next
})
}
// Select / deselect all works; summaries need no detailed metadata.
const allSelected = works.length > 0 && selectedPutCodes.size === works.length
const toggleAll = () => {
if (worksLoading || importing) return
setSelectedPutCodes(allSelected ? new Set() : new Set(works.map(work => work.putCode)))
}
// Import one selection; a successful write displays a final report.
const handleImport = async () => {
if (importSummary || !selectedOrcid || selectedPutCodes.size === 0 || requests.current.import) return
const controller = new AbortController()
requests.current.import = controller
const active = () => requests.current.import === controller && !controller.signal.aborted
const selected = works.filter(work => selectedPutCodes.has(work.putCode))
setImporting(true)
setImportError(null)
// The host decides whether the item is usable or needs more metadata.
// Cache that decision until the item changes through enrichment.
const inspected = new WeakMap<OrcidImportItem, OrcidInspection | null>()
const inspect = (item: OrcidImportItem): OrcidInspection | null => {
if (inspected.has(item)) return inspected.get(item) ?? null
let result: OrcidInspection | null = null
try {
result = onInspect(item)
} catch {
// Invalid optional source; the host may accept an alternate source.
}
inspected.set(item, result)
return result
}
// Parsing and validation are owned by the BibTeX editor, never the server.
const validCandidate = (item: OrcidImportItem): OrcidImportItem => {
if (!('bibtex' in item) || inspect(item)) return item
return item.work ? { work: item.work } : item
}
const needsMetadata = (item: OrcidImportItem) => {
const result = inspect(item)
return !result || result.needsMetadata
}
let rateLimited = false
const failedEnrichment = new Map<number, CrossrefFailure>()
try {
// Bulk ORCID detail retrieval is sequential, and limited to selection.
// No requests at all for already cached details.
const missing = selected.filter(work => !workDetails.current.has(work.putCode))
for (let start = 0; start < missing.length;) {
const size = retryBatchSizes.current.get(missing[start].putCode) ?? ORCID_BATCH_SIZE
const batch: Work[] = []
while (start < missing.length && batch.length < size &&
(retryBatchSizes.current.get(missing[start].putCode) ?? ORCID_BATCH_SIZE) === size) {
batch.push(missing[start++])
}
const smallerOnRetry = () => {
const nextSize = Math.max(1, Math.ceil(batch.length / 4))
for (const work of batch) retryBatchSizes.current.set(work.putCode, nextSize)
}
let result: unknown
try {
result = await fetchOrcidWorkDetails(selectedOrcid, batch.map(work => work.putCode), controller.signal)
} catch (err) {
if (!active()) return
if (isRateLimit(err)) {
rateLimited = true
break // No further provider requests after a 429.
}
smallerOnRetry()
continue
}
if (!active()) return
if (!result || typeof result !== 'object' || !Array.isArray((result as { results?: unknown }).results)) {
smallerOnRetry()
continue
}
const returned = new Map<number, OrcidImportItem>()
const allowed = new Set(batch.map(work => work.putCode))
for (const row of (result as { results: unknown[] }).results) {
if (!row || typeof row !== 'object') continue
const { putCode, item, failure } = row as { putCode?: number; item?: unknown; failure?: string }
if (typeof putCode !== 'number' || !allowed.has(putCode)) continue
const parsed = parseImportItem(item)
if (parsed) returned.set(putCode, parsed)
if (failure === 'rate-limit') rateLimited = true
}
for (const work of batch) {
const item = returned.get(work.putCode)
if (item) {
workDetails.current.set(work.putCode, withWorkSummary(item, work))
retryBatchSizes.current.delete(work.putCode)
} else {
// A missing work is retried independently, not with an entire
// previously successful batch.
retryBatchSizes.current.set(work.putCode, 1)
}
}
if (rateLimited) break
}
const prepared = new Map<number, OrcidImportItem>()
for (const work of selected) {
const detail = workDetails.current.get(work.putCode)
if (!detail) continue
const item = validCandidate(withWorkSummary(detail, work))
prepared.set(work.putCode, item)
workDetails.current.set(work.putCode, item)
}
// Inspect grouped alternatives only when a selected source lacks authors,
// title or year. Preserve the original source when it is equally complete.
const selectedByCode = new Map<number, WorkVariant>(selected.map(work => [work.putCode, work]))
const needsAlternate = (work: Work) => {
const item = prepared.get(work.putCode)
const summary = selectedByCode.get(work.putCode)!
return !!item && (needsMetadata(item) || hasMissingWorkFields(summary, item))
}
const alternatives = selected.filter(work => work.variants?.length && needsAlternate(work))
const maxAlternatives = Math.max(0, ...alternatives.map(work => work.variants!.length))
for (let index = 0; index < maxAlternatives && !rateLimited; index++) {
const candidates = alternatives.filter(work => work.variants![index] && needsAlternate(work))
const variants = new Map(candidates.map(work => [work.variants![index].putCode, work.variants![index]]))
const codes = [...variants.keys()]
for (let start = 0; start < codes.length && !rateLimited; start += ORCID_BATCH_SIZE) {
let result: Awaited<ReturnType<typeof fetchOrcidWorkDetails>>
try {
result = await fetchOrcidWorkDetails(selectedOrcid, codes.slice(start, start + ORCID_BATCH_SIZE), controller.signal)
} catch (err) {
if (!active()) return
if (isRateLimit(err)) rateLimited = true
continue
}
if (!active()) return
const found = new Map(result.results.flatMap(row => {
const item = parseImportItem(row.item)
return item ? [[row.putCode, item] as const] : []
}))
for (const work of candidates) {
const variant = work.variants![index]
if (!found.has(variant.putCode)) continue
const current = prepared.get(work.putCode)!
const source = selectedByCode.get(work.putCode)!
let alternate = validCandidate(withWorkSummary(found.get(variant.putCode)!, variant))
// Keep the original valid citation (and its supplied key), while
// adding structured fields from a better alternate ORCID source.
if ('bibtex' in current && inspect(current)) {
alternate = { ...alternate, bibtex: current.bibtex }
}
alternate = withWorkSummary(alternate, source)
const prior = inspect(current)
const next = inspect(alternate)
if (!next) continue
const priorScore = workMetadataScore(source, current) + Number(!!prior && !prior.needsMetadata) * 16
const nextScore = workMetadataScore(variant, alternate) + Number(!next.needsMetadata) * 16
if (nextScore <= priorScore) continue
prepared.set(work.putCode, alternate)
workDetails.current.set(work.putCode, alternate)
selectedByCode.set(work.putCode, variant)
}
}
}
if (!active()) return
// Only incomplete references are sent to Crossref. Failures use ORCID data.
// Only exact DOI lookup through Crossref remains. Missing metadata is
// imported from ORCID as-is; no DataCite or arXiv requests are made.
const doiByCode = new Map<number, string>()
for (const work of rateLimited ? [] : selected.filter(work =>
prepared.has(work.putCode) && needsMetadata(prepared.get(work.putCode)!)
)) {
const item = prepared.get(work.putCode)!
const doi = workDoi(item, selectedByCode.get(work.putCode)!)
if (!doi) continue
const key = normalizedDoi(doi)
if (!key) continue
// Keep an unambiguous grouped DOI in the structured work for import.
if (item.work && !item.work['external-ids']?.['external-id']?.some(
id => id['external-id-type']?.toLowerCase() === 'doi'
)) {
const supplemented = { ...item, work: { ...item.work, 'external-ids': { 'external-id': [
...(item.work['external-ids']?.['external-id'] ?? []),
{ 'external-id-type': 'doi', 'external-id-value': doi },
] } } }
prepared.set(work.putCode, supplemented)
workDetails.current.set(work.putCode, supplemented)
}
// Known arXiv DataCite DOIs are not registered at Crossref.
if (!item.doi && !key.startsWith('10.48550/arxiv.')) doiByCode.set(work.putCode, key)
}
const lookupDois = [...new Set(doiByCode.values())]
for (let start = 0; start < lookupDois.length && !rateLimited; start += 150) {
const batch = lookupDois.slice(start, start + 150)
let result: Awaited<ReturnType<typeof fetchDoiMetadataBatch>>
try {
result = await fetchDoiMetadataBatch(batch, controller.signal)
} catch (error) {
if (!active()) return
// Even an immediately rejected request was attempted. Do not mark
// later, untouched batches as Crossref failures.
result = { entries: new Map(), attempted: new Set(batch),
rateLimited: new Set(isRateLimit(error) ? batch : []), error }
}
if (!active()) return
for (const [code, doi] of doiByCode) {
if (!batch.includes(doi)) continue
const metadata = result.entries.get(doi)
if (metadata && needsMetadata(prepared.get(code)!)) {
const enriched = { ...prepared.get(code)!, doi: metadata }
prepared.set(code, enriched)
workDetails.current.set(code, enriched)
failedEnrichment.delete(code)
} else if (result.attempted.has(doi)) {
failedEnrichment.set(code, result.rateLimited.has(doi) ? 'rate-limit' : 'unavailable')
}
}
if (result.error && (isRateLimit(result.error) ||
(result.error instanceof Error && result.error.name === 'AbortError'))) {
rateLimited = isRateLimit(result.error)
break
}
}
if (!active()) return
// No DOI or no Crossref attempt is not a failure. Import ORCID-only
// entries for failed Crossref lookups in the same guarded transaction.
const ready = selected.filter(work => {
const item = prepared.get(work.putCode)
return item && inspect(item)
})
let skippedCodes = new Set<number>()
if (ready.length) {
const committed = await onInsert(ready.map(work => prepared.get(work.putCode)!))
if (!committed) {
if (active()) setImportError(t('import_failed'))
return // Do not report an import that was rejected by the editor.
}
if (!active()) return
setImportSummary({ imported: committed.imported, enriched: committed.enriched })
skippedCodes = new Set(committed.skippedIndices.flatMap(index =>
ready[index] ? [ready[index].putCode] : []))
const unusable = new Set(committed.unusableIndices)
setCrossrefFailures(ready.flatMap((work, index) => {
if (skippedCodes.has(work.putCode)) return []
const reason = failedEnrichment.get(work.putCode) ||
(unusable.has(index) ? 'unavailable' : null)
return reason ? [{ title: work.title || t('untitled'), reason }] : []
}))
}
const unimported = selected.filter(work => !ready.includes(work) || skippedCodes.has(work.putCode))
if (unimported.length) {
setUnimportedTitles(unimported.map(work => work.title || t('untitled')))
const message = t(skippedCodes.size
? 'some_works_could_not_be_imported' : 'some_works_could_not_be_fetched')
setImportError(rateLimited
? `${message}. ${t('provider_rate_limited')}` : message)
}
// The report replaces the works list after any successful import.
} catch (err) {
if (active()) {
setImportError(errorMessage(err, t('import_failed'), t('invalid_response_from_server'), t('provider_rate_limited')))
}
} finally {
if (active()) {
delete requests.current.import
setImporting(false)
}
}
}
const goBack = () => {
cancelRequest('works')
setWorksLoading(false)
setStep('search')
setWorksError(null)
setImportError(null)
}
const changeNameQuery = (value: string) => {
cancelRequest('search')
setAuthors([])
setSearchError(null)
setSearchLoading(false)
setNameQuery(value)
}
return {
step, searchMode, nameQuery, orcidInput, searchLoading, searchError, authors,
selectedAuthorName, works, worksLoading, worksError, selectedPutCodes,
importing, importError, importSummary, crossrefFailures, unimportedTitles, allSelected,
setOrcidInput, setSearchError, setWorksError, setImportError,
handleClose, goBack, handleSearchModeChange, changeNameQuery,
handleSearchByName, handleGoToWorksByOrcid, handlePickAuthor,
toggleWork, toggleAll, handleImport,
}
}
@@ -0,0 +1,421 @@
/** ORCID and Crossref data types, parsing and HTTP access for the BibTeX editor. */
import { fetchJSON, ProviderHttpError, ProviderResponseError } from './provider-fetch'
import { encodeDoiPath } from '../utils/bib-doi'
export type AuthorResult = {
orcid: string
givenNames: string
familyNames: string
institutionNames: string[]
}
export type WorkVariant = {
title: string
year: string
type: string
putCode: number
externalIds?: NonNullable<OrcidWork['external-ids']>['external-id']
preferredExternalIds?: NonNullable<OrcidWork['external-ids']>['external-id']
sourceName?: string
journalTitle?: string
}
export type Work = WorkVariant & { variants?: WorkVariant[] }
export type OrcidWork = {
url?: { value?: string }
type?: string
source?: { 'source-name'?: { value?: string } }
title?: { title?: { value?: string } }
'publication-date'?: { year?: { value?: string } }
'journal-title'?: { value?: string }
contributors?: {
contributor?: Array<{
'credit-name'?: { value?: string }
'contributor-attributes'?: { 'contributor-role'?: string }
}>
}
'external-ids'?: {
'external-id'?: Array<{
'external-id-type'?: string
'external-id-value'?: string
'external-id-url'?: { value?: string }
}>
}
}
// Structural Crossref response used by the host editor's existing converter.
export type CrossRefMessage = {
type?: string
title?: string[]
author?: Array<{ family?: string; given?: string; name?: string }>
issued?: { 'date-parts'?: number[][] }
'container-title'?: string[]
volume?: string
issue?: string
page?: string
DOI?: string
URL?: string
publisher?: string
}
export type DoiMetadata = { crossref: CrossRefMessage }
export type OrcidImportItem = (
| { bibtex: string; work?: OrcidWork }
| { work: OrcidWork }
) & { doi?: DoiMetadata }
// Reject malformed nested ORCID data at the provider boundary. A broken work is
// skipped independently; a valid embedded BibTeX citation can still be used.
function isUsableWork(value: unknown): value is OrcidWork {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const work = value as OrcidWork
const text = (value: unknown) => value == null || typeof value === 'string'
const named = (value: unknown): value is { value?: string } =>
value == null || (!!value && typeof value === 'object' && !Array.isArray(value) &&
text((value as { value?: unknown }).value))
const ids = work['external-ids']?.['external-id']
const people = work.contributors?.contributor
return named(work.title?.title) && named(work['publication-date']?.year) &&
named(work['journal-title']) &&
(ids == null || (Array.isArray(ids) && ids.every(id => id && typeof id === 'object' &&
text(id['external-id-type']) && text(id['external-id-value'])))) &&
(people == null || (Array.isArray(people) && people.every(person => person && typeof person === 'object' &&
named(person['credit-name']) &&
(person['contributor-attributes'] == null ||
text(person['contributor-attributes']['contributor-role'])))))
}
export function parseImportItem(data: unknown): OrcidImportItem | null {
if (!data || typeof data !== 'object') return null
const result = data as { bibtex?: unknown; work?: unknown }
const work = isUsableWork(result.work) ? result.work : null
if (typeof result.bibtex === 'string' && result.bibtex.trim()) {
return {
bibtex: result.bibtex,
...(work ? { work } : {}),
}
}
return work ? { work } : null
}
export type ImportResult = {
imported: number
enriched: number
unusableIndices: number[]
skippedIndices: number[]
}
const ORCID_FORMAT = /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/
export function isValidOrcid(orcid: string): boolean {
if (!ORCID_FORMAT.test(orcid)) return false
const digits = orcid.replaceAll('-', '')
let total = 0
for (const digit of digits.slice(0, 15)) total = (total + Number(digit)) * 2
const check = (12 - (total % 11)) % 11
return digits[15] === (check === 10 ? 'X' : String(check))
}
const ORCID_PUBLIC_API = 'https://pub.orcid.org/v3.0'
const CROSSREF_API = 'https://api.crossref.org/works'
const ORCID_MAX_BATCH = 100
const DOI_PATTERN = /^10\.\d{4,9}\/\S+$/
const SOLR_SPECIAL = /([+\-&|!(){}\[\]^"~*?:\\/])/g
function publicOrcid(orcid: string): string {
if (!isValidOrcid(orcid)) throw new Error('Invalid ORCID identifier')
return `${ORCID_PUBLIC_API}/${orcid}`
}
function validCodes(codes: number[]) {
if (!codes.length || codes.length > ORCID_MAX_BATCH ||
codes.some(code => !Number.isSafeInteger(code) || code < 1) ||
new Set(codes).size !== codes.length) throw new Error('Invalid ORCID put-codes')
}
function formatWork(work: OrcidWork & { citation?: { 'citation-type'?: string; 'citation-value'?: string } }): OrcidImportItem {
const citation = work.citation
if (citation?.['citation-type'] === 'bibtex' &&
typeof citation['citation-value'] === 'string' &&
citation['citation-value'].trim().startsWith('@')) {
return { bibtex: citation['citation-value'].trim(), work }
}
return { work }
}
/** ORCID is an external API: skip malformed summaries without discarding other works. */
export function normalizeWorks(response: unknown): Work[] {
const groups = (response as { group?: unknown } | null)?.group
if (!Array.isArray(groups)) throw new ProviderResponseError('Invalid ORCID works response')
const works: Work[] = []
const string = (value: unknown) => typeof value === 'string' ? value : ''
const read = (value: unknown, ...keys: string[]): string => {
let current = value
for (const key of keys) {
if (!current || typeof current !== 'object' || Array.isArray(current)) return ''
current = (current as Record<string, unknown>)[key]
}
return string(current)
}
type ExternalId = NonNullable<NonNullable<OrcidWork['external-ids']>['external-id']>[number]
const ids = (value: unknown): ExternalId[] => {
const raw = (value as { 'external-ids'?: { 'external-id'?: unknown } } | null)?.['external-ids']?.['external-id']
return Array.isArray(raw) ? raw.filter((id): id is ExternalId =>
!!id && typeof id === 'object' && !Array.isArray(id) &&
typeof id['external-id-type'] === 'string' && typeof id['external-id-value'] === 'string'
) : []
}
for (const group of groups) {
if (!group || typeof group !== 'object' || Array.isArray(group)) continue
const raw = (group as { 'work-summary'?: unknown })['work-summary']
if (!Array.isArray(raw)) continue
const summaries = raw.filter((item): item is Record<string, unknown> =>
!!item && typeof item === 'object' && !Array.isArray(item) &&
Number.isSafeInteger(item['put-code']) && Number(item['put-code']) > 0
)
if (!summaries.length) continue
const displayIndex = (item: Record<string, unknown>) => {
const value = item['display-index']
return value == null || value === '' || !Number.isFinite(Number(value))
? -Infinity : Number(value)
}
const ranked = summaries.sort((a, b) => displayIndex(b) - displayIndex(a))
const externalIds = [...ids(group), ...ranked.flatMap(ids)]
const source = (item: Record<string, unknown>): WorkVariant => ({
putCode: item['put-code'] as number,
title: read(item, 'title', 'title', 'value'),
year: read(item, 'publication-date', 'year', 'value'),
type: read(item, 'type'),
sourceName: read(item, 'source', 'source-name', 'value'),
journalTitle: read(item, 'journal-title', 'value'),
externalIds,
preferredExternalIds: ids(item),
})
const candidates = ranked.map(source)
const score = (item: WorkVariant) => Number(!!item.title.trim()) * 2 +
Number(/^\d{4}$/.test(item.year)) * 2 +
Number(!!item.journalTitle?.trim()) +
Number(item.preferredExternalIds?.some(id => id['external-id-type']?.toLowerCase() === 'doi')) * 2
// Respect the highest display-index when it has usable summary data.
// Otherwise use the richest summary; full contributors are checked on import.
let selected = candidates[0]
if (score(selected) < 6) {
for (const candidate of candidates.slice(1)) {
if (score(candidate) > score(selected)) selected = candidate
}
}
works.push({ ...selected, variants: candidates.filter(item => item.putCode !== selected.putCode) })
}
return works.sort((a, b) => (Number.parseInt(b.year, 10) || 0) - (Number.parseInt(a.year, 10) || 0))
}
/** Consider only structurally usable Crossref fields. */
function usefulCrossref(message: CrossRefMessage) {
return (Array.isArray(message.author) && message.author.some(person =>
!!person && typeof person === 'object' &&
((typeof person.family === 'string' && !!person.family.trim()) ||
(typeof person.name === 'string' && !!person.name.trim())))) ||
(Array.isArray(message.issued?.['date-parts']) &&
Number.isInteger(message.issued['date-parts']?.[0]?.[0])) ||
(Array.isArray(message.title) && message.title.some(title => typeof title === 'string' && !!title.trim())) ||
(Array.isArray(message['container-title']) && message['container-title'].some(title => typeof title === 'string' && !!title.trim())) ||
(typeof message.publisher === 'string' && !!message.publisher.trim()) ||
(typeof message.volume === 'string' && !!message.volume.trim()) ||
(typeof message.page === 'string' && !!message.page.trim())
}
/** Normalize before deduplicating and matching exact Crossref DOI identities. */
function doiValue(doi: string): string | null {
const trimmed = doi.trim()
const urlPrefix = /^https?:\/\/(?:dx\.)?doi\.org\//i
let value = trimmed.replace(urlPrefix, '').replace(/^doi:/i, '')
if (urlPrefix.test(trimmed)) {
try {
// DOI URLs escape path punctuation; a bare DOI may contain literal percent signs.
value = decodeURIComponent(value)
} catch {
return null
}
}
return DOI_PATTERN.test(value) && value.length <= 300 ? value : null
}
export function normalizedDoi(doi: string): string | null {
return doiValue(doi)?.toLowerCase() ?? null
}
/** Shared identity and usability check for both Crossref endpoints. */
function crossrefMetadata(value: unknown, requestedDoi: string): DoiMetadata | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const message = value as CrossRefMessage
return typeof message.DOI === 'string' && normalizedDoi(message.DOI) === requestedDoi &&
usefulCrossref(message) ? { crossref: message } : null
}
/** Batch ordinary DOIs via Crossref filters. DOIs containing commas need an
* individually encoded URL because the filter syntax uses commas as separators.
* Retain completed chunks and continue past non-rate-limit provider failures.
*/
export async function fetchDoiMetadataBatch(
dois: string[], signal: AbortSignal
): Promise<{ entries: Map<string, DoiMetadata>; attempted: Set<string>; rateLimited: Set<string>; error?: unknown }> {
if (!dois.length || dois.length > 150) throw new Error('Invalid DOI batch')
const unique = [...new Set(dois.map(normalizedDoi).filter((doi): doi is string =>
!!doi && !doi.startsWith('10.48550/arxiv.')
))]
const entries = new Map<string, DoiMetadata>()
const attempted = new Set<string>()
const rateLimited = new Set<string>()
const chunks: string[][] = []
let current: string[] = []
for (const doi of unique) {
if (doi.includes(',')) {
if (current.length) chunks.push(current)
chunks.push([doi])
current = []
continue
}
if (current.length && (current.length === 10 || [...current, doi].join(',').length > 1400)) {
chunks.push(current)
current = []
}
current.push(doi)
}
if (current.length) chunks.push(current)
let error: unknown
for (const batch of chunks) {
if (signal.aborted) break
batch.forEach(doi => attempted.add(doi))
const commaDoi = batch.length === 1 && batch[0].includes(',')
try {
if (commaDoi) {
const doiPath = encodeDoiPath(batch[0])
const result = await fetchJSON(`${CROSSREF_API}/${doiPath}`, signal) as {
message?: unknown
}
const metadata = crossrefMetadata(result?.message, batch[0])
if (metadata) entries.set(batch[0], metadata)
} else {
const params = new URLSearchParams({
filter: batch.map(doi => `doi:${doi}`).join(','),
rows: String(batch.length),
select: 'DOI,type,title,author,issued,container-title,volume,issue,page,URL,publisher',
})
const result = await fetchJSON(`${CROSSREF_API}?${params}`, signal, 4 * 1024 * 1024) as {
message?: { items?: unknown[] }
}
if (!Array.isArray(result?.message?.items)) throw new ProviderResponseError('Invalid Crossref batch')
for (const value of result.message.items) {
const doi = (value && typeof value === 'object' && 'DOI' in value &&
typeof value.DOI === 'string') ? normalizedDoi(value.DOI) : null
if (!doi || !batch.includes(doi)) continue
const metadata = crossrefMetadata(value, doi)
if (metadata) entries.set(doi, metadata)
}
}
} catch (failure) {
// Missing or temporarily unavailable metadata must not block other chunks.
if (commaDoi && failure instanceof ProviderHttpError && failure.status === 404) continue
if (isRateLimit(failure)) {
error = failure
batch.forEach(doi => rateLimited.add(doi))
break
}
// Cancellation or forbidden access is not a transient DOI-specific failure.
if (signal.aborted || (failure instanceof Error && failure.name === 'AbortError') ||
(failure instanceof ProviderHttpError && (failure.status === 401 || failure.status === 403))) {
error = failure
break
}
error ??= failure
}
}
return { entries, attempted, rateLimited, ...(error ? { error } : {}) }
}
/** Respect the selected ORCID source before considering grouped versions.
* A group-level DOI is usable only when every listed DOI is the same.
*/
export function workDoi(item: OrcidImportItem, summary: Work): string | null {
type ExternalId = NonNullable<Work['externalIds']>[number]
const candidates = (ids?: ExternalId[]) => [...new Set((ids ?? [])
.filter(id => id['external-id-type']?.toLowerCase() === 'doi')
.map(id => normalizedDoi(id['external-id-value'] ?? ''))
.filter((doi): doi is string => doi !== null))]
const detailed = candidates(item.work?.['external-ids']?.['external-id'])
if (detailed.length) return detailed.length === 1 ? detailed[0] : null
const preferred = candidates(summary.preferredExternalIds)
if (preferred.length) return preferred.length === 1 ? preferred[0] : null
const grouped = candidates(summary.externalIds)
return grouped.length === 1 ? grouped[0] : null
}
export function isRateLimit(err: unknown) {
return err instanceof ProviderHttpError &&
(err.status === 429 || (err.provider === 'orcid' && err.status === 503))
}
export function errorMessage(
err: unknown,
fallback: string,
invalidResponse: string,
tooMany: string
) {
if (isRateLimit(err)) {
return tooMany
}
if (err instanceof ProviderResponseError) return invalidResponse
return fallback
}
/** Search public ORCID author records. */
export async function searchOrcidAuthors(name: string, signal: AbortSignal) {
const query = name.trim()
if (!query || query.length > 200) throw new Error('Invalid author search')
const parts = query.split(/\s+/)
const escape = (value: string) => value.replace(SOLR_SPECIAL, '\\$1')
const q = parts.length > 1
? `given-names:"${escape(parts.slice(0, -1).join(' '))}" AND family-name:"${escape(parts.at(-1)!)}"`
: escape(query)
const result = await fetchJSON(`${ORCID_PUBLIC_API}/expanded-search/?q=${encodeURIComponent(q)}&start=0&rows=20`, signal) as {
'expanded-result'?: Array<Record<string, unknown>>
}
if (!Array.isArray(result?.['expanded-result'])) throw new ProviderResponseError('Invalid ORCID author response')
return { results: result['expanded-result'].flatMap(author => {
if (!author || typeof author !== 'object' ||
typeof author['orcid-id'] !== 'string' || !isValidOrcid(author['orcid-id'])) return []
return [{
orcid: author['orcid-id'] as string,
givenNames: typeof author['given-names'] === 'string' ? author['given-names'] : '',
familyNames: typeof author['family-names'] === 'string' ? author['family-names'] : '',
institutionNames: Array.isArray(author['institution-name'])
? author['institution-name'].filter((name): name is string => typeof name === 'string') : [],
}]
}) }
}
/** Fetch lightweight publication summaries. */
export async function fetchOrcidWorks(orcid: string, signal: AbortSignal) {
const result = await fetchJSON(`${publicOrcid(orcid)}/works`, signal, 8 * 1024 * 1024)
return { works: normalizeWorks(result) }
}
/** Fetch only the selected ORCID work details. */
export async function fetchOrcidWorkDetails(orcid: string, codes: number[], signal: AbortSignal) {
validCodes(codes)
const result = await fetchJSON(`${publicOrcid(orcid)}/works/${codes.join(',')}`, signal, 8 * 1024 * 1024) as {
bulk?: Array<{ work?: OrcidWork & { citation?: { 'citation-type'?: string; 'citation-value'?: string } } }>
}
if (!Array.isArray(result?.bulk)) throw new ProviderResponseError('Invalid ORCID detail response')
const returned = new Map<number, OrcidImportItem>()
for (const row of result.bulk) {
const code = (row?.work as OrcidWork & { 'put-code'?: number } | undefined)?.['put-code']
if (Number.isSafeInteger(code) && code && codes.includes(code)) {
const item = parseImportItem(formatWork(row.work!))
if (item) returned.set(code, item)
}
}
return { results: codes.map(putCode => ({ putCode, item: returned.get(putCode) ?? null })) }
}
@@ -0,0 +1,83 @@
/** Fetch only fixed public metadata providers in the browser, without credentials. */
export type MetadataProvider = 'orcid' | 'crossref'
export class ProviderHttpError extends Error {
constructor(readonly provider: MetadataProvider, readonly status: number) {
super(`${provider} HTTP ${status}`)
}
}
export class ProviderResponseError extends Error {}
function providerFor(url: string): MetadataProvider {
const parsed = new URL(url)
if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password) {
throw new Error('Unexpected metadata provider URL')
}
const host = parsed.hostname
if (host === 'pub.orcid.org') return 'orcid'
if (host === 'api.crossref.org') return 'crossref'
throw new Error('Unexpected metadata provider')
}
export async function fetchJSON(url: string, signal: AbortSignal, maxBytes = 2 * 1024 * 1024): Promise<unknown> {
const provider = providerFor(url)
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
const controller = new AbortController()
const deadline = setTimeout(() => controller.abort(), 15_000)
const onAbort = () => controller.abort()
signal.addEventListener('abort', onAbort, { once: true })
try {
if (signal.aborted) controller.abort()
const response = await fetch(url, {
headers: { Accept: 'application/json' },
credentials: 'omit',
redirect: 'error',
signal: controller.signal,
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new ProviderHttpError(provider, response.status)
}
const length = Number(response.headers.get('content-length'))
if (length > maxBytes) {
await response.body?.cancel().catch(() => undefined)
throw new Error('Metadata response too large')
}
// A missing stream cannot be consumed with a bounded allocation.
const reader = response.body?.getReader()
if (!reader) throw new Error('Metadata response has no readable body')
const chunks: Uint8Array[] = []
let size = 0
let finished = false
try {
for (;;) {
const { done, value } = await reader.read()
if (done) {
finished = true
break
}
size += value.byteLength
if (size > maxBytes) throw new Error('Metadata response too large')
chunks.push(value)
}
} finally {
if (!finished) await reader.cancel().catch(() => undefined)
reader.releaseLock()
}
const bytes = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
try {
return JSON.parse(new TextDecoder().decode(bytes))
} catch {
throw new ProviderResponseError('Invalid metadata JSON response')
}
} finally {
clearTimeout(deadline)
signal.removeEventListener('abort', onAbort)
}
}
@@ -0,0 +1,38 @@
/** ORCID work-summary fallback and provider selection, independent of UI. */
import type { OrcidImportItem, Work, WorkVariant } from './orcid-api'
/** Preserve title/year from the selected summary when absent in full details. */
export function withWorkSummary(item: OrcidImportItem, summary: Work): OrcidImportItem {
if (!item.work) return item
const work = item.work
const year = summary.year.trim()
const title = summary.title.trim()
const missingYear = !!year && !work['publication-date']?.year?.value?.trim()
const missingTitle = !!title && !work.title?.title?.value?.trim()
if (!missingYear && !missingTitle) return item
return { ...item, work: {
...work,
...(missingYear ? { 'publication-date': {
...work['publication-date'], year: { value: year },
} } : {}),
...(missingTitle ? { title: { title: { value: title } } } : {}),
} }
}
/** A conservative structured-metadata score; citation parsing stays in the host. */
export function workMetadataScore(summary: WorkVariant, item: OrcidImportItem): number {
const work = item.work
const ids = work?.['external-ids']?.['external-id'] ?? summary.preferredExternalIds ?? []
return Number(!!(work?.title?.title?.value || summary.title).trim()) * 2 +
Number(/^\d{4}$/.test(work?.['publication-date']?.year?.value || summary.year)) * 2 +
Number(!!(work?.['journal-title']?.value || summary.journalTitle)?.trim()) +
Number(ids.some(id => id['external-id-type']?.toLowerCase() === 'doi')) * 2 +
Number(!!work?.contributors?.contributor?.some(person => person['credit-name']?.value?.trim())) * 8
}
/** Do not fetch alternate details for otherwise usable records. */
export function hasMissingWorkFields(summary: WorkVariant, item: OrcidImportItem): boolean {
if (!item.work) return false
return !(item.work.title?.title?.value || summary.title).trim() ||
!/^\d{4}$/.test(item.work['publication-date']?.year?.value || summary.year)
}
@@ -0,0 +1,63 @@
import { hasBalancedCurlyBraces } from './bib-validate'
import { isBracedAuthor, splitBibtexAnd } from './bib-author-names'
export type BibAuthorRow = {
value: string
saved: string
}
/** A single editor state for both author input modes. */
export type BibAuthorDraft =
| { mode: 'rows'; rows: BibAuthorRow[] }
| { mode: 'text'; value: string; saved: string; invalidRow?: string }
export type BibNameDrafts = Partial<Record<string, BibAuthorDraft>>
export const MAX_AUTHOR_ROWS = 10
const joinAuthors = (values: string[]) =>
values.filter(value => value.trim()).join(' and ')
const authorInputValue = (value: string) =>
isBracedAuthor(value) ? value.slice(1, -1) : value
/** Preserve untouched names, but brace edited names only when needed. */
export function authorSourceValue(value: string, previous = ''): string {
if (!value.trim()) return ''
if (previous && value === authorInputValue(previous)) return previous
return /\band(?:\s|$)/i.test(value) && !isBracedAuthor(value)
? `{${value}}`
: value
}
export function createAuthorDraft(value: string): BibAuthorDraft {
const authors = splitBibtexAnd(value).filter(author => author.trim())
if (authors.length > MAX_AUTHOR_ROWS) {
return { mode: 'text', value, saved: value }
}
return {
mode: 'rows',
rows: (authors.length ? authors : ['']).map(saved => ({
value: authorInputValue(saved),
saved,
})),
}
}
export function authorDraftValue(draft: BibAuthorDraft): string {
return draft.mode === 'text'
? draft.value
: joinAuthors(draft.rows.map(row => authorSourceValue(row.value, row.saved)))
}
export function authorDraftSavedValue(draft: BibAuthorDraft): string {
return draft.mode === 'text'
? draft.saved
: joinAuthors(draft.rows.map(row => row.saved))
}
export function isAuthorDraftValid(draft: BibAuthorDraft): boolean {
return draft.mode === 'text'
? draft.invalidRow == null && hasBalancedCurlyBraces(draft.value)
: draft.rows.every(row => hasBalancedCurlyBraces(row.value))
}
@@ -0,0 +1,80 @@
/** Whether braces enclose the complete author rather than only part of it. */
export function isBracedAuthor(value: string): boolean {
if (!value.startsWith('{') || !value.endsWith('}')) return false
let depth = 0
for (let i = 0; i < value.length; i++) {
if (value[i] === '{') depth++
if (value[i] === '}' && --depth === 0 && i !== value.length - 1) return false
}
return depth === 0
}
/**
* Split authors conservatively on top-level `and`. This function is suitable for
* valid source text; malformed per-author brace balancing is handled by the visual
* author controls rather than repaired here. Braces preceded by backslashes
* still affect nesting, matching the BibTeX source parser.
*/
export function splitBibtexAnd(value: string): string[] {
const parts: string[] = []
let start = 0
let braceDepth = 0
let hasAuthorText = false
const contentEnd = value.trimEnd().length
const pushPart = (end: number) => {
const part = value.slice(start, end).trim()
if (part) parts.push(part)
start = end
}
for (let i = 0; i < value.length; i++) {
const char = value[i]
// The field's outer quotes were already parsed by BibtexFieldValue.
// Any quotes remaining here are literal author text, not delimiters.
if (char === '{') {
braceDepth++
hasAuthorText = true
continue
}
if (char === '}') {
if (braceDepth > 0) braceDepth--
hasAuthorText = true
continue
}
if (
char.toLowerCase() !== 'a' ||
(i > 0 && !/\s/.test(value[i - 1] || ''))
) {
if (!/\s/.test(char)) hasAuthorText = true
continue
}
// Check the separator in place. Matching against `value.slice(i)` at every
// word boundary repeatedly copied the remaining author string and could make
// long names/author lists quadratic.
const isAnd =
value[i + 1]?.toLowerCase() === 'n' &&
value[i + 2]?.toLowerCase() === 'd' &&
(i + 3 === value.length || /\s/.test(value[i + 3]))
// Only split when there is an author on both sides. A leading or
// trailing 'and' is literal text, not a separator.
if (!isAnd || braceDepth !== 0 || !hasAuthorText || i + 3 >= contentEnd) {
hasAuthorText = true
continue
}
pushPart(i)
hasAuthorText = false
i += 2
start = i + 1
while (start < value.length && /\s/.test(value[start])) start++
i = start - 1
}
const last = value.slice(start).trim()
if (last) parts.push(last)
return parts.length > 0 ? parts : ['']
}
@@ -0,0 +1,7 @@
/** BibTeX directives that do not represent bibliography entries. */
export const NON_BIBLIOGRAPHY_ENTRY_TYPES: ReadonlySet<string> = new Set([
'comment',
'preamble',
'string',
])
@@ -0,0 +1,24 @@
/** Shared DOI input conventions for paste detection and metadata fetching. */
const DOI_PREFIX = /^(?:(?:https?:\/\/)?(?:dx\.)?doi\.org\/|doi:\s*)/i
const DOI = /^10\.\d{4,9}\/\S+$/
/** Accept a bare DOI, doi: prefix, or doi.org URL with or without a scheme. */
export function normaliseDoi(raw: string): string {
return raw.trim().replace(DOI_PREFIX, '')
}
/** Classify pasted DOI input, including malformed prefixed DOIs for diagnostics. */
export function isDoiLine(raw: string): boolean {
const text = raw.trim()
const normalized = normaliseDoi(text)
return DOI.test(normalized) || (normalized !== text && normalized.length > 0)
}
export function isValidDoi(doi: string): boolean {
return DOI.test(doi)
}
/** Preserve DOI slash separators while escaping reserved characters in URL paths. */
export function encodeDoiPath(doi: string): string {
return doi.split('/').map(encodeURIComponent).join('/')
}
@@ -0,0 +1,93 @@
/** DOM event names and payload contracts shared by React and CodeMirror. */
import type { EditorView } from '@codemirror/view'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import type { PositionedBibtexEntry } from './bib-types'
import type { BibFieldWriteTarget } from './bib-write'
export const BIB_ENTRIES_EVENT = 'bib-editor:entries-updated'
export const BIB_WRITE_EVENT = 'bib-editor:write'
export const BIB_DELETE_EVENT = 'bib-editor:delete'
export const BIB_IMPORT_EVENT = 'bib-editor:import'
export const BIB_SCROLL_TO_EVENT = 'bib-editor:scroll-to'
export const BIB_WRITE_FAILED_EVENT = 'bib-editor:write-failed'
export const BIB_HISTORY_STATE_EVENT = 'bib-editor:history-state'
export const BIB_HISTORY_APPLIED_EVENT = 'bib-editor:history-applied'
export const BIB_REQUEST_STATE_EVENT = 'bib-editor:request-state'
export type BibEntryIdRemap = {
previousEntryId: string
entryId: string
from: number
}
export type BibHistoryUserEvent = 'input.type' | 'input.complete' | 'delete'
export type BibWriteRequest = {
entry: BibtexEntry
mode: 'existing' | 'new'
expectedRevision?: number
expectedDocument?: unknown
/** Current key-plus-occurrence-index id of the existing entry. */
entryId?: string
/** Reject a write if the source entry has changed since the Visual action. */
expectedEntry?: BibtexEntry
historyUserEvent?: BibHistoryUserEvent
/** Exact target of a visual field/header edit. */
fieldTarget?: BibFieldWriteTarget
}
export type BibDeleteRequest = {
entryId?: string
entryIds?: string[]
}
export type BibImportRequest = {
entries: BibtexEntry[]
expectedRevision: number
expectedDocument: unknown
}
/** Events are scoped to the CodeMirror view that owns the document. */
export type BibEntriesDetail = {
documentId: string | null
isBibFile: boolean
entries: PositionedBibtexEntry[]
revision: number
entryIdRemaps?: BibEntryIdRemap[]
removedEntryIds?: string[]
view: EditorView
}
export type BibHistoryStateDetail = {
canUndo: boolean
canRedo: boolean
view: EditorView
}
export type BibWriteFailureDetail = {
reason: string
view: EditorView
}
export type BibHistoryAppliedDetail = {
kind: 'undo' | 'redo'
entryId?: string
previousEntryId?: string
entry?: BibtexEntry
view: EditorView
}
/**
* A cancelable DOM event alone cannot confirm a write: dispatchEvent() returns
* true even with no listener. Only the owning CodeMirror plugin may acknowledge
* a request, after applying its transaction (or accepting a legitimate no-op).
*/
export function dispatchBibMutation(
eventName: typeof BIB_WRITE_EVENT | typeof BIB_IMPORT_EVENT,
request: BibWriteRequest | BibImportRequest,
view: EditorView
): boolean {
const detail = { ...request, view, accepted: false }
const event = new CustomEvent(eventName, { detail, cancelable: true })
return document.dispatchEvent(event) && detail.accepted
}
@@ -0,0 +1,167 @@
/** Pure identity reconciliation for entry occurrences across CodeMirror edits. */
import type { ChangeSet } from '@codemirror/state'
import type { PositionedBibtexEntry } from './bib-types'
import type { BibEntryIdRemap } from './bib-editor-events'
export type PreviousBibEntryIdentity = {
entryId: string
from: number
to: number
}
export type BibEntryIdentityChanges = {
entryIdRemaps: BibEntryIdRemap[]
removedEntryIds: string[]
currentByPreviousId: Map<string, PositionedBibtexEntry>
}
/** Update citation-key occurrence indexes in document order. */
export function updateOccurrenceIndexes(entries: PositionedBibtexEntry[]) {
const occurrenceIndexes = new Map<string, number>()
for (const positioned of entries) {
const key = positioned.entry.key
const occurrenceIndex = occurrenceIndexes.get(key) ?? 0
occurrenceIndexes.set(key, occurrenceIndex + 1)
positioned.entry.occurrenceIndex = occurrenceIndex
}
}
/** Nearest preceding entry, using sorted source offsets. */
export function nearestEntryAt(
entries: PositionedBibtexEntry[],
position: number
): PositionedBibtexEntry | undefined {
let low = 0
let high = entries.length
while (low < high) {
const middle = (low + high) >>> 1
if (entries[middle].from <= position) low = middle + 1
else high = middle
}
return low > 0 ? entries[low - 1] : undefined
}
/** Match old occurrences to new ones using the source transaction mapping. */
export function getEntryIdentityChanges(
changeset: ChangeSet,
previousEntries: PreviousBibEntryIdentity[],
nextEntries: PositionedBibtexEntry[]
): BibEntryIdentityChanges {
const changes: Array<{
fromA: number
toA: number
fromB: number
toB: number
}> = []
changeset.iterChanges((fromA, toA, fromB, toB) => {
changes.push({ fromA, toA, fromB, toB })
})
const currentByPreviousId = new Map<string, PositionedBibtexEntry>()
const nextByStart = new Map(nextEntries.map(entry => [entry.from, entry] as const))
const usedNext = new Set<PositionedBibtexEntry>()
const handledPrevious = new Set<PreviousBibEntryIdentity>()
const lowerBoundByStart = <T extends { from: number }>(items: T[], position: number) => {
let low = 0
let high = items.length
while (low < high) {
const middle = (low + high) >>> 1
if (items[middle].from < position) low = middle + 1
else high = middle
}
return low
}
const findCurrentAt = (position: number) => {
const candidate = nextByStart.get(position)
return candidate && !usedNext.has(candidate) ? candidate : undefined
}
const assign = (
previous: PreviousBibEntryIdentity,
current: PositionedBibtexEntry | undefined
) => {
handledPrevious.add(previous)
if (!current || usedNext.has(current)) return
currentByPreviousId.set(previous.entryId, current)
usedNext.add(current)
}
// If a transaction replaces a whole entry (or a larger source range), map
// occurrences inside that range by document order. This distinguishes a
// genuinely deleted entry from a following entry that happens to inherit
// the deleted entry's old key#occurrenceIndex ID.
for (const change of changes) {
if (change.fromA === change.toA) continue
const replaced: PreviousBibEntryIdentity[] = []
for (
let index = lowerBoundByStart(previousEntries, change.fromA);
index < previousEntries.length && previousEntries[index].from < change.toA;
index++
) {
const entry = previousEntries[index]
if (entry.to <= change.toA && !handledPrevious.has(entry)) {
replaced.push(entry)
}
}
if (replaced.length === 0) continue
const inserted: PositionedBibtexEntry[] = []
for (
let index = lowerBoundByStart(nextEntries, change.fromB);
index < nextEntries.length && nextEntries[index].from < change.toB;
index++
) {
const entry = nextEntries[index]
if (entry.to <= change.toB && !usedNext.has(entry)) {
inserted.push(entry)
}
}
replaced.forEach((previous, index) => assign(previous, inserted[index]))
}
for (const previous of previousEntries) {
if (handledPrevious.has(previous)) continue
// Inserting directly at an entry's start moves the existing occurrence
// after the inserted text. Replacing its start delimiter/key should map
// to the replacement's start instead.
const insertionAtStart = changes.some(change =>
change.fromA === previous.from && change.toA === previous.from
)
const startReplaced = changes.some(change =>
change.fromA < change.toA &&
change.fromA <= previous.from &&
previous.from < change.toA
)
const mappedFrom = changeset.mapPos(
previous.from,
insertionAtStart || !startReplaced ? 1 : -1
)
let current = findCurrentAt(mappedFrom)
if (!current && startReplaced) {
const mappedAfter = changeset.mapPos(previous.from, 1)
current = findCurrentAt(mappedAfter)
}
assign(previous, current)
}
const entryIdRemaps: BibEntryIdRemap[] = []
const removedEntryIds: string[] = []
for (const previous of previousEntries) {
const current = currentByPreviousId.get(previous.entryId)
if (!current) {
removedEntryIds.push(previous.entryId)
} else if (current.entry.id !== previous.entryId) {
entryIdRemaps.push({
previousEntryId: previous.entryId,
entryId: current.entry.id,
from: current.from,
})
}
}
return { entryIdRemaps, removedEntryIds, currentByPreviousId }
}
@@ -0,0 +1,38 @@
import type { BibSourceDiagnostic } from './bib-source-diagnostics'
import type { TFunction } from 'i18next'
import type { PositionedBibtexEntry } from '@/features/bibtex/positioned-bibtex-entry'
// Keep diagnostics alongside the positioned projection without extending the
// upstream entry type. WeakMap entries follow the lifetime of their projection.
const diagnosticsByEntry = new WeakMap<PositionedBibtexEntry, BibSourceDiagnostic[]>()
const EMPTY_DIAGNOSTICS: BibSourceDiagnostic[] = []
export function getBibEntryDiagnostics(
entry: PositionedBibtexEntry
): BibSourceDiagnostic[] {
return diagnosticsByEntry.get(entry) ?? EMPTY_DIAGNOSTICS
}
export function setBibEntryDiagnostics(
entry: PositionedBibtexEntry,
diagnostics: BibSourceDiagnostic[]
): void {
if (diagnostics.length) {
diagnosticsByEntry.set(entry, diagnostics)
} else {
diagnosticsByEntry.delete(entry)
}
}
export function translateBibSourceDiagnostic(
diagnostic: BibSourceDiagnostic,
t: TFunction
): string {
return t('bib_syntax_line', {
line: diagnostic.line,
problem: t(diagnostic.code, {
field: diagnostic.field ?? '',
line: diagnostic.relatedLine ?? diagnostic.line,
}),
})
}
@@ -0,0 +1,180 @@
/**
* Convert upstream BibTeX parsing into positioned visual-editor entries.
*
* Lezer determines entry boundaries. Incomplete entries with a recognizable
* header can be recovered; directives without an opener are not visual entries.
*/
import { NON_BIBLIOGRAPHY_ENTRY_TYPES } from './bib-constants'
import { parseBibtex } from '@/features/bibtex/bibtex-parser'
import { PositionedBibtexEntry } from '@/features/bibtex/positioned-bibtex-entry'
import { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import {
bibEntryHeaderAt,
skipBibtexTrivia,
bibCitationKeyRange,
} from './bib-source-scanner'
import {
findSyntaxEntryProjection,
projectKeylessEntry,
type BibFieldSourceRange,
type BibSyntaxEntryProjection,
} from './bib-syntax-ranges'
/** Source-relative Lezer field ranges are present only for trusted complete entries. */
export type BibProjectedEntry = PositionedBibtexEntry & {
sourceFieldRanges?: BibFieldSourceRange[]
}
type SourceRange = { from: number; to: number }
function positioned(entry: BibtexEntry, from: number, to: number): BibProjectedEntry {
return Object.assign(new PositionedBibtexEntry(), { entry, from, to })
}
/** A trusted node has real delimiters, no Lezer errors, and a matching key. */
function trustedEntry(source: string, node: BibSyntaxEntryProjection): BibtexEntry | null {
if (!node.clean || !node.entry || !node.citationKey) return null
const { from, to } = node.citationKey
return node.entry.key === source.slice(from, to) ? node.entry : null
}
/**
* Isolated incremental/undo parsing. Even an incomplete entry can be shown
* if upstream's parser recovered it; its syntax diagnostics disable editing.
*/
export function parseBibEntryAt(
source: string,
from: number,
knownTo?: number
): BibProjectedEntry | null {
if (from < 0 || from >= source.length || source[from] !== '@') return null
const header = bibEntryHeaderAt(source, from)
if (!header || NON_BIBLIOGRAPHY_ENTRY_TYPES.has(header.type)) return null
const to = knownTo ?? (() => {
const syntax = findSyntaxEntryProjection(source, false)
const entry = syntax.entries.find(item => item.from === from)
if (!entry) return from
return entry.closed ? entry.to :
(syntax.blockStarts.find(start => start > from) ?? source.length)
})()
if (to <= from) return null
const raw = source.slice(from, to)
const keyStart = skipBibtexTrivia(source, header.open + 1)
const keyless = projectKeylessEntry(raw)
if (keyless) {
const result = positioned(keyless.entry, from, from + keyless.close + 1)
result.sourceFieldRanges = keyless.fields
return result
}
const parsed = parseBibtex(raw).entries[0]
// The upstream parser may recover a nested entry instead of the entry at
// this offset. Never assign that nested entry to the outer source range.
if (parsed?.key && source.startsWith(parsed.key, keyStart)) {
return positioned(parsed, from, to)
}
// Lezer recognized this malformed entry, but upstream could not construct
// its content. Display a minimal entry; the source diagnostics mark it bad.
const keyRange = bibCitationKeyRange(source, header)
const key = keyRange && keyRange.to <= to
? source.slice(keyRange.from, keyRange.to)
: ''
return positioned(new BibtexEntry({ type: header.type, key }), from, to)
}
/**
* Parse the complete document once with Lezer. A syntax error may require
* boundary discovery, but field values always come from upstream parsers.
*/
export function parseBibFile(
source: string,
range?: SourceRange,
trustedWindow?: SourceRange
): BibProjectedEntry[] {
const from = Math.max(0, range?.from ?? 0)
const to = Math.min(source.length, range?.to ?? source.length)
if (to <= from) return []
// On undo/redo, the extension knows the surviving entry boundaries. Parse
// only the gap between those entries when every entry in it is complete.
// Ambiguous/incomplete regions fall back to the original full projection.
if (range && trustedWindow &&
0 <= trustedWindow.from && trustedWindow.from <= from &&
to <= trustedWindow.to && trustedWindow.to <= source.length) {
const local = parseBibFile(source.slice(trustedWindow.from, trustedWindow.to))
if (local.length && local.every(entry =>
entry.sourceFieldRanges !== undefined &&
entry.to <= trustedWindow.to - trustedWindow.from &&
(entry.from < from - trustedWindow.from ||
!source.slice(to, trustedWindow.from + entry.to).trim())
)) {
return local.filter(entry =>
entry.from >= from - trustedWindow.from && entry.from < to - trustedWindow.from
).map(entry => {
entry.from += trustedWindow.from
entry.to = Math.min(to, entry.to + trustedWindow.from)
return entry
})
}
}
const { entries, blockStarts } = findSyntaxEntryProjection(source)
let nextBlock = 0
return entries.flatMap(node => {
while (nextBlock < blockStarts.length && blockStarts[nextBlock] <= node.from) nextBlock++
// Incomplete entries leave their content in Junk. Stop at the next Lezer
// block, including @comment, @string, @preamble and % comments.
const end = node.closed ? node.to : (blockStarts[nextBlock] ?? source.length)
const entryTo = Math.min(end, to)
if (node.from < from || node.from >= to || entryTo <= node.from) return []
// An empty citation key is valid source for the visual editor. Upstream
// cannot parse its fields directly, so use an isolated in-memory projection.
const keyless = projectKeylessEntry(source.slice(node.from, entryTo))
if (keyless) {
const result = positioned(keyless.entry, node.from, node.from + keyless.close + 1)
result.sourceFieldRanges = keyless.fields
return [result]
}
// Use the upstream accumulator's partial entry when possible. Unlike an
// isolated reparse, it cannot accidentally select a nested sibling.
if (!node.entry) {
const partial = parseBibEntryAt(source, node.from, entryTo)
return partial ? [partial] : []
}
const result = positioned(node.entry, node.from, entryTo)
if (trustedEntry(source, node)) {
result.sourceFieldRanges = node.fields.map(field => ({
...field,
fieldFrom: field.fieldFrom - node.from,
valueFrom: field.valueFrom - node.from,
valueTo: field.valueTo - node.from,
}))
}
return [result]
})
}
/**
* Reproject a guarded field/header edit within its already-known entry range.
* The range comes from CodeMirror's current occurrence and transaction mapping,
* not from the citation key. Lezer sees only this entry, so ordinary field
* writes do not parse the full bibliography again. If the range contains more
* than one entry or is not entirely owned by one entry, let the caller fall
* back to full-document discovery.
*/
export function parseBibEntryInRange(
source: string,
from: number,
to: number
): BibProjectedEntry | null {
if (from < 0 || to > source.length || from >= to || source[from] !== '@') return null
const entries = parseBibFile(source.slice(from, to))
if (entries.length !== 1 || entries[0].from !== 0 || entries[0].to !== to - from) {
return null
}
const entry = entries[0]
entry.from = from
entry.to = to
return entry
}
@@ -0,0 +1,107 @@
import { BibtexFieldValue } from '@shared/bibtex/bibtex-field-value.mts'
const STANDARD_MONTHS = /^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)$/i
/** Follow upstream editable syntax: ## is a literal # and #name# a string reference. */
export function toBibFormString(
value: BibtexFieldValue,
fieldName?: string
): string {
const editable = value.toEditableString()
const month = editable.match(/^#([^#]+)#$/)
if (fieldName === 'month' && month && STANDARD_MONTHS.test(month[1])) {
return month[1]
}
return editable
}
export function fromBibFormString(
value: string,
fieldName?: string,
previous?: BibtexFieldValue
): BibtexFieldValue {
// An untouched field must not be reconstructed: the editable string does not
// distinguish an upstream NumberLiteral from a braced numeric StringLiteral.
if (previous && value === toBibFormString(previous, fieldName)) return previous
// Preserve the source's numeric atom when a numeric atom is edited.
if (previous && /^\d+$/.test(previous.toString()) && /^\d+$/.test(value)) {
return new BibtexFieldValue().addNumber(value)
}
if (fieldName === 'month' && STANDARD_MONTHS.test(value.trim())) {
return new BibtexFieldValue().addNamedString(value.trim())
}
return BibtexFieldValue.fromEditableString(value)
}
/** Upstream collapses line breaks inside literal atoms when parsing source. */
export function sameBibFormValue(editable: string, parsed: string): boolean {
return editable.replaceAll(/[\r\n]\s*/g, ' ') ===
parsed.replaceAll(/[\r\n]\s*/g, ' ')
}
/**
* Preserve whitespace in the editable value of a complete BibTeX expression.
* Upstream replaces newlines in literal atoms with spaces. Read those atoms
* from the original source, but accept the result only when the normalized
* editable spelling matches the upstream value. Unexpected syntax falls back
* to upstream instead of guessing at source ranges.
*/
export function textareaValueFromSource(
sourceValue: string,
canonical: string
): string | null {
let index = 0
const exact: string[] = []
const skipTrivia = () => {
while (index < sourceValue.length) {
if (/\s/.test(sourceValue[index])) {
index++
} else if (sourceValue[index] === '%') {
while (index < sourceValue.length && !/[\r\n]/.test(sourceValue[index])) index++
} else {
break
}
}
}
while (true) {
skipTrivia()
if (index >= sourceValue.length) return null
const opener = sourceValue[index]
let part: string
if (opener === '{' || opener === '"') {
const start = ++index
let depth = opener === '{' ? 1 : 0
while (index < sourceValue.length) {
const ch = sourceValue[index]
if (ch === '{') depth++
else if (ch === '}') {
if (opener === '{' && --depth === 0) break
if (opener === '"' && depth > 0) depth--
} else if (ch === '"' && opener === '"' && depth === 0) break
index++
}
if (index === sourceValue.length) return null
const literal = sourceValue.slice(start, index++)
part = literal.replaceAll('#', '##')
} else {
const start = index
while (index < sourceValue.length && !/[\s#%,{}()"\r\n]/.test(sourceValue[index])) index++
if (index === start) return null
const token = sourceValue.slice(start, index)
part = /^\d+$/.test(token) ? token : `#${token}#`
}
exact.push(part)
skipTrivia()
if (index >= sourceValue.length) break
if (sourceValue[index++] !== '#') return null
}
const value = exact.join('').replaceAll(/\r\n|\r/g, '\n')
return sameBibFormValue(value, canonical) ? value : null
}
@@ -0,0 +1,109 @@
/** English labels for BibTeX/BibLaTeX field names. These identifiers stay in English. */
const FIELD_LABELS: Record<string, string> = {
abstract: 'Abstract',
address: 'Address',
annote: 'Annotation',
archiveprefix: 'Archive prefix',
affiliation: 'Affiliation',
addendum: 'Addendum',
afterword: 'Afterword',
annotator: 'Annotator',
author: 'Author',
bookauthor: 'Book author',
booksubtitle: 'Book subtitle',
booktitle: 'Book title',
booktitleaddon: 'Book title addon',
chapter: 'Chapter',
commentator: 'Commentator',
crossref: 'Cross-reference',
date: 'Date',
doi: 'Digital object identifier (DOI)',
edition: 'Edition',
editor: 'Editor',
editora: 'Editor A',
editorb: 'Editor B',
editorc: 'Editor C',
eid: 'EID',
eprint: 'Eprint',
eprintclass: 'Eprint class',
eprinttype: 'Eprint type',
eventdate: 'Event date',
eventtitle: 'Event title',
eventtitleaddon: 'Event title addon',
foreword: 'Foreword',
holder: 'Holder',
howpublished: 'How published',
institution: 'Institution',
introduction: 'Introduction',
isbn: 'ISBN',
isrn: 'ISRN',
issn: 'ISSN',
issue: 'Issue',
issuesubtitle: 'Issue subtitle',
issuetitle: 'Issue title',
issuetitleaddon: 'Issue title addon',
journal: 'Journal',
journalsubtitle: 'Journal subtitle',
journaltitle: 'Journal title',
journaltitleaddon: 'Journal title addon',
language: 'Language',
location: 'Location',
mainsubtitle: 'Main subtitle',
maintitle: 'Main title',
maintitleaddon: 'Main title addon',
month: 'Month',
note: 'Note',
number: 'Number',
organization: 'Organization',
origlanguage: 'Original language',
pages: 'Pages',
pagetotal: 'Page total',
part: 'Part',
primaryclass: 'Primary class',
publisher: 'Publisher',
pubstate: 'Publication state',
school: 'School',
series: 'Series',
subtitle: 'Subtitle',
title: 'Title',
titleaddon: 'Title addon',
translator: 'Translator',
type: 'Type',
url: 'URL',
urldate: 'URL date',
venue: 'Venue',
version: 'Version',
volume: 'Volume',
volumes: 'Volumes',
year: 'Year',
}
const FIELD_GROUP_KEYS: Record<string, string> = {
Common: 'common',
Contributors: 'bib_field_group_contributors',
'Books and volumes': 'bib_field_group_books_and_volumes',
'Periodicals and journals': 'bib_field_group_periodicals_and_journals',
'Events and conferences': 'bib_field_group_events_and_conferences',
'Publication details': 'bib_field_group_publication_details',
'Digital and online': 'bib_field_group_digital_and_online',
'Language and origin': 'bib_field_group_language_and_origin',
}
type Translate = (key: string) => string
export function fieldLabel(name: string): string {
const known = FIELD_LABELS[name.toLowerCase()]
if (known) return known
return name
.split('_')
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function fieldGroupLabel(label: string, translate: Translate): string {
const key = FIELD_GROUP_KEYS[label]
return key ? translate(key) : label
}
@@ -0,0 +1,7 @@
/** Return the translation key for a field's helper line, if it has one. */
export function helperKeyForField(fieldName: string): string | null {
if (fieldName === 'pages') return 'bib_field_helper_page_range'
if (fieldName === 'doi') return 'bib_field_helper_doi_identifier'
if (fieldName === 'eprint') return 'bib_field_helper_preprint_archive_identifier'
return null
}
@@ -0,0 +1,321 @@
/**
* Helpers for turning pasted bibliography/DOI input into BibtexEntry values. This
* module normalizes imported citation keys before they reach the write path.
*/
import { NON_BIBLIOGRAPHY_ENTRY_TYPES } from './bib-constants'
import { BIBTEX_CITATION_KEY_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
import { parseBibFile } from './bib-entry-position'
import {
bibEntryHeaderAt,
physicalLineEnd,
consumeOneNewline,
skipBibtexTrivia,
} from './bib-source-scanner'
import { validateBibSource } from './bib-source-diagnostics'
import type { BibSourceDiagnostic } from './bib-source-diagnostics'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { generateCitationKey } from './bib-key-generation'
import { findSyntaxEntryProjection, findSyntaxSourceBlocks, analyzeBibSyntax } from './bib-syntax-ranges'
import type { BibSyntaxProjection, BibSyntaxEntryProjection } from './bib-syntax-ranges'
import { isDoiLine } from './bib-doi'
import { humanizeCitationHeading } from './overleaf-type-map.ts'
export type BibImportItem =
| { kind: 'bibtex'; entry: BibtexEntry; raw: string }
| { kind: 'doi'; raw: string }
| { kind: 'error'; raw: string; error?: string; errors?: BibSourceDiagnostic[]; syntaxError?: boolean }
export function splitImportText(text: string): BibImportItem[] {
const items: BibImportItem[] = []
let position = 0
// The full-text Lezer tree already contains trusted entries and their
// boundaries. Reuse it instead of reparsing every complete import candidate.
const syntax = analyzeBibSyntax(text)
let blocks = syntax.blocks
const entries = new Map(syntax.entries.map(entry => [entry.from, entry]))
const invalid = new Set(validateBibSource(text, syntax).map(item => item.entryFrom))
let canReuse = true
let blockIndex = 0
while (position < text.length) {
const end = physicalLineEnd(text, position)
const line = text.slice(position, end)
const trimmed = line.trim()
if (!trimmed) {
position = consumeOneNewline(text, end)
continue
}
if (trimmed.startsWith('%')) {
// Upstream ignores free-standing BibTeX line comments during parsing.
position = consumeOneNewline(text, end)
continue
}
if (isDoiLine(trimmed)) {
items.push({ kind: 'doi', raw: trimmed })
} else {
// Lezer discovers BibTeX blocks; ordinary lines may be DOI references.
while (blocks[blockIndex] && blocks[blockIndex].from < position) blockIndex++
const candidate = blocks[blockIndex]
const block = candidate?.from < end ? candidate : null
if (block) {
if (block.type === 'comment') {
position = block.to
continue
}
const header = bibEntryHeaderAt(text, block.from)
// Keep incomplete @commands as error rows; never silently discard
// source text that Lezer recognized as an entry.
if (!header) {
items.push(...parseImportBlock(text.slice(block.from, block.to)))
position = block.to
continue
}
let entryEnd = block.to
const original = text.slice(block.from, entryEnd)
if (!block.closed) {
// A standalone DOI line is another import item, even when the
// preceding entry never closed. Do not split balanced entries here.
for (let cursor = 0; cursor < original.length;) {
const lineEnd = physicalLineEnd(original, cursor)
if (cursor > 0 && isDoiLine(original.slice(cursor, lineEnd))) {
entryEnd = block.from + cursor
break
}
cursor = consumeOneNewline(original, lineEnd)
}
}
const raw = text.slice(block.from, entryEnd)
const trusted = canReuse && !invalid.has(block.from)
? completeImportEntry(text, entries.get(block.from), block.from, entryEnd)
: null
if (trusted) items.push({ kind: 'bibtex', entry: trusted, raw })
else items.push(...parseImportBlock(raw))
if (entryEnd < block.to) {
// A DOI split an unclosed entry. Reparse the remainder because Lezer
// may have absorbed subsequent entries into that malformed value.
blocks = findSyntaxSourceBlocks(text.slice(entryEnd)).map(item => ({
...item, from: item.from + entryEnd, to: item.to + entryEnd,
}))
blockIndex = 0
canReuse = false
}
position = entryEnd
continue
}
items.push(trimmed.startsWith('@')
? { kind: 'error', raw: line, error: 'could_not_parse_this_reference', syntaxError: true }
: { kind: 'error', raw: line })
}
position = consumeOneNewline(text, end)
}
return items
}
function parseImportBlock(block: string): BibImportItem[] {
// Diagnose the original source before asking upstream to parse it.
// Invalid citation keys are normalized only for importable entries.
const syntax = analyzeBibSyntax(block)
const diagnostics = validateBibSource(block, syntax).filter(
item => item.code !== 'citation_key_contains_invalid_characters'
)
if (diagnostics.length > 0) {
return [{
kind: 'error',
raw: block,
errors: diagnostics,
syntaxError: true,
}]
}
// Diagnosed source is non-importable; strict Lezer completeness is checked
// below as well, since diagnostics alone cannot guarantee safe serialization.
const header = bibEntryHeaderAt(block, block.indexOf('@'))
if (header && NON_BIBLIOGRAPHY_ENTRY_TYPES.has(header.type)) return []
// A partial upstream accumulation is not safe to serialize. Require one
// complete, error-free entry occupying the whole block, not merely a
// diagnostic-free source or a non-null parser result.
const entry = parseCompleteImportEntry(block, syntax)
if (!entry) {
return [{
kind: 'error', raw: block,
error: 'could_not_parse_this_reference', syntaxError: true,
}]
}
return [{ kind: 'bibtex', entry, raw: block }]
}
/** An upstream entry must cover all non-trivia input and have trusted fields. */
function parseCompleteImportEntry(
source: string,
projection: BibSyntaxProjection = findSyntaxEntryProjection(source)
): BibtexEntry | null {
if (projection.needsRecovery || projection.entries.length !== 1) return null
return completeImportEntry(source, projection.entries[0], 0, source.length)
}
/** Validate one already-projected occurrence without another Lezer parse. */
function completeImportEntry(
source: string,
node: BibSyntaxEntryProjection | undefined,
from: number,
to: number
): BibtexEntry | null {
if (!node || node.from !== from || !node.clean || !node.closed || !node.entry ||
!node.citationKey || node.citationKey.from === node.citationKey.to ||
node.entry.key !== source.slice(node.citationKey.from, node.citationKey.to) ||
(node.to !== to && skipBibtexTrivia(source, node.to) !== to)) return null
return node.entry
}
export type BibImportRow = {
rowId: string
status: 'ok' | 'doi-ok' | 'error' | 'empty'
raw: string
entry: BibtexEntry | null
error?: string
errors?: BibSourceDiagnostic[]
heading: string
title: string
typeLabel: string
syntaxError?: boolean
}
/** Build one import row without rebuilding unrelated rows. */
export function buildImportRow(
item: BibImportItem,
rowIndex: number,
doiResult: string | BibtexEntry | undefined
): BibImportRow {
const base: BibImportRow = {
rowId: `import-${rowIndex}`,
status: 'ok',
raw: 'raw' in item ? item.raw : '',
entry: null,
heading: '',
title: '',
typeLabel: '',
}
switch (item.kind) {
case 'bibtex':
case 'doi': {
if (item.kind === 'doi') {
if (doiResult === undefined) {
return { ...base, status: 'empty', raw: item.raw }
}
if (typeof doiResult === 'string') {
return { ...base, status: 'error', raw: item.raw, error: doiResult }
}
}
const sourceEntry = item.kind === 'bibtex' ? item.entry : doiResult as BibtexEntry
const entry = prepareImportedEntry(sourceEntry)
const raw = item.kind === 'bibtex'
? `@${(entry ?? sourceEntry).type}{${(entry ?? sourceEntry).key}`
: item.raw
if (!entry) {
return { ...base, status: 'error', raw: item.kind === 'bibtex' ? item.raw : raw,
error: 'could_not_repair_this_reference' }
}
return {
...base,
status: item.kind === 'bibtex' ? 'ok' : 'doi-ok',
raw,
entry,
heading: humanizeCitationHeading(entry),
title: entry.getField('title').toDisplayString().trim(),
typeLabel: entry.type,
}
}
case 'error': {
// Keep syntax-invalid rows non-importable. Show fields that the upstream
// streaming parser can recover, without repairing the original source.
const entry = item.syntaxError ? recoverSyntaxErrorEntry(item.raw) : null
return {
...base,
status: 'error',
raw: item.raw,
entry,
heading: entry ? humanizeCitationHeading(entry) : '',
title: entry?.getField('title').toDisplayString().trim() ?? '',
typeLabel: entry?.type ?? '',
error: item.error ?? 'could_not_parse_this_reference',
errors: item.errors,
syntaxError: item.syntaxError,
}
}
}
}
function recoverSyntaxErrorEntry(raw: string): BibtexEntry | null {
try {
return parseBibFile(raw)[0]?.entry ?? null
} catch {
// Error rows must remain visible even when malformed syntax defeats recovery.
return null
}
}
export function buildImportRows(
items: BibImportItem[],
doiResults: (string | BibtexEntry | undefined)[]
): BibImportRow[] {
let doiIndex = 0
return items.map((item, rowIndex) => {
const result = item.kind === 'doi' ? doiResults[doiIndex++] : undefined
return buildImportRow(item, rowIndex, result)
})
}
/**
* Prepare one parsed entry for import. Upstream has already parsed valid BibTeX syntax. This final pass makes
* the entry serializable by ensuring a
* valid citation key. Required fields are deliberately not checked here: an
* incomplete but syntactically valid entry is still useful and can be fixed after
* import. Duplicate keys are equally valid in this visual editor.
*/
function prepareImportedEntry(entry: BibtexEntry): BibtexEntry | null {
let key = entry.key.trim()
if (!BIBTEX_CITATION_KEY_REGEX.test(key)) {
key = Array.from(key)
.filter(char => BIBTEX_CITATION_KEY_REGEX.test(char))
.join('')
}
if (!key) {
key = generatedKey(entry, 'imported')
}
if (key.length > MAX_KEY_LENGTH) {
key = key.slice(0, MAX_KEY_LENGTH)
}
const normalised = entry.setKey(key)
// This round trip is necessary even for a Lezer-clean source: upstream
// represents a quoted literal as a braced literal when serializing. For
// example, note="a}b" is valid but serializes as note={a}b}, which changes
// the structure. It also rejects invalid DOI-generated field names/types.
const reparsed = parseCompleteImportEntry(normalised.toString())
if (!reparsed || reparsed.type !== normalised.type ||
reparsed.key !== normalised.key ||
reparsed.fields.size !== normalised.fields.size) return null
for (const [name, value] of normalised.fields) {
if (!reparsed.hasField(name) || reparsed.getField(name).toString() !== value.toString()) {
return null
}
}
return reparsed
}
const MAX_KEY_LENGTH = 128
function isValidKey(key: string): boolean {
return key.length > 0 && key.length <= MAX_KEY_LENGTH && BIBTEX_CITATION_KEY_REGEX.test(key)
}
/** Generate a valid fallback key for pasted entries. */
function generatedKey(entry: BibtexEntry, fallback: string): string {
const key = generateCitationKey(entry).slice(0, MAX_KEY_LENGTH)
return isValidKey(key) ? key : fallback
}
@@ -0,0 +1,76 @@
import { BibtexNameList } from '@shared/bibtex/bibtex-name.mts'
import { generateBaseCitationKey, pickAvailableKey } from '@/features/bibtex/citation-key'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import type { BibProjectedEntry } from './bib-entry-position'
const VALID_YEAR = /^\d{4}$/
/**
* Generate a citation key even when the entry does not contain enough metadata
* for the upstream generator. The visual editor deliberately uses deterministic
* fallbacks instead of making the Generate button a no-op:
*
* - no usable author → `Unknown`
* - no plain four-digit year (including a non-year date such as 2.2.2222) → `0000`
*/
export function generateCitationKey(entry: BibtexEntry): string {
// Use upstream BibtexNameList interpretation and upstream key generation,
// including its behavior for brace-protected organization names.
const parsedAuthors = entry.getAuthors()
const firstAuthor = parsedAuthors.names[0]
const authors =
firstAuthor && firstAuthor.toLast().trim() !== ''
? parsedAuthors
: new BibtexNameList('Unknown')
const rawYear = entry.getYear().trim()
const year = VALID_YEAR.test(rawYear) ? rawYear : '0000'
return generateBaseCitationKey({ authors, year })
}
/** Exclude one source occurrence for existing entries; new entries exclude none.
* Duplicate source keys still participate in collision detection.
*/
export function pickAvailableKeyForEntry(
base: string,
existingKeys: readonly string[],
sourceKey?: string
): string {
const keys = [...existingKeys]
const ownIndex = sourceKey === undefined ? -1 : keys.indexOf(sourceKey)
if (ownIndex !== -1) keys.splice(ownIndex, 1)
return pickAvailableKey(base, keys)
}
/** Generate a unique key using the same policy for every editor write path. */
export function generateAvailableCitationKey(
entry: BibtexEntry,
existingKeys: readonly string[],
sourceKey?: string
): string {
return pickAvailableKeyForEntry(generateCitationKey(entry), existingKeys, sourceKey)
}
/** Display-only suggestion. The source key remains empty until explicitly edited. */
export function displayCitationKey(
entry: BibtexEntry,
existingKeys: readonly string[]
): string {
return entry.key || generateAvailableCitationKey(entry, existingKeys, '')
}
/** Commit a generated key only for a complete, keyless source entry.
* Existing keyless entries remain unchanged until their Visual preview exits.
*/
export function keyToCommitOnLeave(
sourceEntry: Pick<BibProjectedEntry, 'entry' | 'sourceFieldRanges'>,
existingKeys: readonly string[],
draft?: BibtexEntry
): string | null {
const fields = sourceEntry.sourceFieldRanges
if (!fields || new Set(fields.map(field => field.name)).size !== fields.length) {
return null
}
const entry = sourceEntry.entry
if (entry.key.trim() || draft?.key.trim()) return null
return generateAvailableCitationKey(draft ?? entry, existingKeys, entry.key)
}
@@ -0,0 +1,47 @@
/** The visible row and its distance above the list viewport's top edge. */
export type BibListPosition = {
entryId: string
offset: number
scrollTop: number
}
/** Capture the first visible bibliography row without depending on prior row heights. */
export function captureBibListPosition(list: HTMLElement): BibListPosition | null {
const rows = list.querySelector('.bibtex-entry-list-body')?.children
if (!rows?.length) return null
const viewportTop = list.getBoundingClientRect().top
let low = 0
let high = rows.length
while (low < high) {
const middle = (low + high) >>> 1
if (rows[middle].getBoundingClientRect().bottom <= viewportTop) low = middle + 1
else high = middle
}
const row = rows[Math.min(low, rows.length - 1)] as HTMLElement
const entryId = row.dataset.entryId
if (!entryId) return null
return {
entryId,
offset: viewportTop - row.getBoundingClientRect().top,
scrollTop: list.scrollTop,
}
}
/** Restore the same visible row and in-row offset, even if preceding rows changed height. */
export function restoreBibListPosition(list: HTMLElement, position: BibListPosition): boolean {
const rows = list.querySelector('.bibtex-entry-list-body')?.children
if (!rows?.length) return false
const row = Array.from(rows).find(item => (item as HTMLElement).dataset.entryId === position.entryId)
if (!row) {
// Final fallback policy for filtered lists: use the saved pixel offset,
// accept the restoration, and track subsequent scrolling in that filter.
// Clearing the filter does not retry the former (now absent) row anchor.
list.scrollTop = position.scrollTop
return true
}
const viewportTop = list.getBoundingClientRect().top
const rowTop = row.getBoundingClientRect().top
list.scrollTop += rowTop - viewportTop + position.offset
return true
}
@@ -0,0 +1,197 @@
import { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { BibtexFieldValue } from '@shared/bibtex/bibtex-field-value.mts'
import { BIBTEX_CITATION_KEY_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
import { generateAvailableCitationKey, pickAvailableKeyForEntry } from './bib-key-generation'
import { splitImportText } from './bib-import'
import { isValidDoi, normaliseDoi } from './bib-doi'
import { crossRefMessageToEntry } from './doi-fetcher'
import type { OrcidImportItem, OrcidWork } from '../orcid/orcid-api'
function fromOrcidWork(work: OrcidWork): BibtexEntry {
const fields = new Map<string, BibtexFieldValue>()
const add = (field: string, value?: string) => {
if (value?.trim()) fields.set(field, new BibtexFieldValue().addString(value.trim()))
}
const journal = work['journal-title']?.value
const type = work.type === 'journal-article' && journal ? 'article'
: work.type === 'conference-paper' && journal ? 'inproceedings'
: 'misc'
add('title', work.title?.title?.value)
add('year', work['publication-date']?.year?.value)
add(type === 'article' ? 'journal' : type === 'inproceedings' ? 'booktitle' : 'howpublished', journal)
const contributors = work.contributors?.contributor ?? []
for (const role of ['author', 'editor'] as const) {
const names = contributors
.filter(person => (person['contributor-attributes']?.['contributor-role'] || 'author') === role)
.map(person => person['credit-name']?.value?.trim())
.filter((name): name is string => Boolean(name))
// Protect corporate names containing the BibTeX name separator.
.map(name => /\s+and\s+/i.test(name) ? `{${name}}` : name)
add(role, names.join(' and '))
}
const doi = work['external-ids']?.['external-id']?.find(
id => id['external-id-type']?.toLowerCase() === 'doi'
)?.['external-id-value']
if (doi) {
const normalized = normaliseDoi(doi)
if (isValidDoi(normalized)) add('doi', normalized)
}
return new BibtexEntry({ type, key: '', fields })
}
/** Only the editor interprets BibTeX/DOI metadata and assigns citation keys. */
function parseCitation(text: string): BibtexEntry | null {
const parsed = splitImportText(text)
return parsed.length === 1 && parsed[0].kind === 'bibtex' ? parsed[0].entry : null
}
function isSafeEntry(entry: BibtexEntry): boolean {
return !!parseCitation(entry.setKey(entry.key || 'orcid-import').toString())
}
function mergeFields(entry: BibtexEntry, source: BibtexEntry, overwrite = false): BibtexEntry {
for (const field of source.getFields()) {
const value = source.getField(field)
if (!value.toDisplayString().trim()) continue
if (overwrite || !entry.getField(field).toDisplayString().trim()) {
entry = entry.setField(field, value)
}
}
// The provider's year is authoritative when enrichment was requested.
// Do not retain a contradictory date from the earlier ORCID citation.
if (overwrite) {
const year = source.getField('year').toDisplayString().trim()
const date = source.getField('date').toDisplayString().trim()
const oldDate = entry.getField('date').toDisplayString().trim()
if (year && !date && oldDate && !oldDate.startsWith(year)) {
entry = entry.deleteField('date')
} else if (date && !year) {
const oldYear = entry.getField('year').toDisplayString().trim()
const dateYear = date.match(/^\d{4}/)?.[0]
if (oldYear && dateYear && oldYear !== dateYear) entry = entry.deleteField('year')
}
}
return entry
}
/** Merge available sources without assigning a citation key, recording whether
* Crossref changed final metadata, and whether provider data was unusable. */
function mergedOrcidEntry(item: OrcidImportItem): {
entry: BibtexEntry; enriched: boolean; unusableCrossref: boolean
} | null {
const citation = 'bibtex' in item ? parseCitation(item.bibtex) : null
if ('bibtex' in item && !citation) return null
// Convert structured Crossref metadata using the existing editor converter.
// An invalid response must not make the original ORCID record unusable.
let doi: BibtexEntry | null = null
let unusableCrossref = false
if (item.doi?.crossref) {
// DOI metadata is optional. An invalid provider response must not make an
// otherwise valid ORCID citation unusable.
try {
const candidate = crossRefMessageToEntry(item.doi.crossref).entry
if (isSafeEntry(candidate)) doi = candidate
else unusableCrossref = true
} catch {
unusableCrossref = true
}
}
// Preserve the original citation key; Crossref fields take precedence
// when a DOI was explicitly retrieved. ORCID fills remaining gaps.
const workEntry = item.work ? fromOrcidWork(item.work) : null
let entry = citation || workEntry || doi
if (!entry) return null
if (citation && workEntry) {
const merged = mergeFields(entry, workEntry)
if (isSafeEntry(merged)) entry = merged
}
const beforeDoi = entry
if (doi && entry !== doi) {
try {
let merged = mergeFields(entry, doi, true)
// Preserve the original citation key if the provider generated another.
if (!citation && !merged.key && doi.key &&
BIBTEX_CITATION_KEY_REGEX.exec(doi.key)?.[0] === doi.key) {
merged = merged.setKey(doi.key)
}
// An explicit provider type can also supersede an ORCID fallback type.
if (item.doi?.crossref?.type) merged = merged.setType(doi.type)
if (isSafeEntry(merged)) entry = merged
else unusableCrossref = true
} catch {
// Unusable enrichment must not discard a valid ORCID record.
unusableCrossref = true
}
}
// Validate the fully merged entry once, without writing any temporary key.
const checked = parseCitation(entry.setKey(entry.key || 'orcid-import').toString())
if (!checked) return null
const finalEntry = checked.setKey(entry.key)
const enriched = !!doi && (entry === doi || finalEntry.type !== beforeDoi.type ||
[...new Set([...finalEntry.getFields(), ...beforeDoi.getFields()])].some(field =>
finalEntry.getField(field).toDisplayString() !== beforeDoi.getField(field).toDisplayString()
))
return { entry: finalEntry, enriched, unusableCrossref }
}
/** Validate and assess metadata without generating a citation key. */
export function inspectOrcidImport(item: OrcidImportItem): { needsMetadata: boolean } | null {
try {
const result = mergedOrcidEntry(item)
if (!result) return null
const entry = result.entry
return { needsMetadata: entry.getAuthors().names.length === 0 ||
!entry.getField('year').toDisplayString().trim() }
} catch {
return null
}
}
/** The editor, not the ORCID server, owns BibTeX parsing and citation keys. */
export function prepareOrcidImport(
items: OrcidImportItem[],
existingKeys: readonly string[]
): { entries: BibtexEntry[]; enriched: number; unusableIndices: number[]; skippedIndices: number[] } | null {
if (items.length === 0) return null
let enriched = 0
const unusableIndices: number[] = []
const skippedIndices: number[] = []
const keys = [...existingKeys]
const entries: BibtexEntry[] = []
for (const [index, item] of items.entries()) {
try {
const merged = mergedOrcidEntry(item)
if (!merged) {
skippedIndices.push(index)
continue
}
let entry = merged.entry
if (!entry.key.trim() || BIBTEX_CITATION_KEY_REGEX.exec(entry.key)?.[0] !== entry.key) {
// Assign keys only after successful preparation, preserving deterministic order.
entry = entry.setKey(generateAvailableCitationKey(entry, keys, ''))
} else if (keys.includes(entry.key)) {
entry = entry.setKey(pickAvailableKeyForEntry(entry.key, keys))
}
keys.push(entry.key)
entries.push(entry)
if (merged.enriched) enriched++
else if (merged.unusableCrossref) unusableIndices.push(index)
} catch {
// One malformed entry must not interrupt preparation of other references.
// The remaining valid entries are still committed atomically by the caller.
skippedIndices.push(index)
}
}
return entries.length ? { entries, enriched, unusableIndices, skippedIndices } : null
}
@@ -0,0 +1,87 @@
import type { BibListPosition } from './bib-list-position'
/** UI-only preview state, retained when reopening a file or project. */
export type BibPreviewSession = {
entryId: string | null
entryFrom: number | null
listShown: boolean
listPosition?: BibListPosition
}
export function bibPreviewSessionKey(projectId: string, documentId: string): string {
return `bib-editor:preview:v1:${JSON.stringify([projectId, documentId])}`
}
export function readBibPreviewSession(key: string): BibPreviewSession | null {
try {
const value = JSON.parse(localStorage.getItem(key) ?? 'null')
if (
!value ||
(value.entryId !== null && typeof value.entryId !== 'string') ||
(value.entryFrom !== null && (!Number.isSafeInteger(value.entryFrom) || value.entryFrom < 0)) ||
typeof value.listShown !== 'boolean' ||
(value.listPosition !== undefined && (
typeof value.listPosition?.entryId !== 'string' ||
!Number.isFinite(value.listPosition.offset) ||
!Number.isFinite(value.listPosition.scrollTop) ||
value.listPosition.scrollTop < 0
))
) return null
return value
} catch {
return null
}
}
export function writeBibPreviewSession(key: string, state: BibPreviewSession): void {
try {
localStorage.setItem(key, JSON.stringify(state))
} catch {
// Storage may be unavailable or disabled. The editor still works normally.
}
}
export function resolveBibPreviewSession<T extends { from: number; entry: { id: string } }>(
state: BibPreviewSession | null,
entries: T[]
): { entry: T | null; listShown: boolean } {
// A keyed ID must never resolve to a different entry at the same offset.
// Keyless occurrence IDs can change when another keyless entry gets a key;
// only those entries use source position as a fallback.
const byId = state?.entryId
? entries.find(item => item.entry.id === state.entryId)
: undefined
const entry = state?.entryId && /^#\d+$/.test(state.entryId)
? entries.find(item => item.from === state.entryFrom) ?? byId ?? null
: byId ?? null
return { entry, listShown: entry ? state?.listShown ?? true : true }
}
/** One document-scoped cache shared by selection and scroll updates. */
export function createBibPreviewSessionStore(key: string) {
let state: BibPreviewSession = readBibPreviewSession(key) ?? {
entryId: null, entryFrom: null, listShown: true,
}
let timer: ReturnType<typeof setTimeout> | undefined
let dirty = false
const flush = () => {
clearTimeout(timer)
timer = undefined
if (!dirty) return
writeBibPreviewSession(key, state)
dirty = false
}
return {
read: () => state,
update(patch: Partial<BibPreviewSession>, deferred = false) {
state = { ...state, ...patch }
dirty = true
if (!deferred) flush()
else {
clearTimeout(timer)
timer = setTimeout(flush, 150)
}
},
flush,
}
}
@@ -0,0 +1,90 @@
/**
* Source diagnostics based on upstream Lezer syntax and field ranges.
* Empty citation keys are supported in the visual editor. Only semantic checks
* that Lezer does not perform (invalid keys and duplicate fields) live here.
*/
import { NON_BIBLIOGRAPHY_ENTRY_TYPES } from './bib-constants'
import { bibEntryHeaderAt, bibCitationKeyRange, bibtexIdentifierEnd } from './bib-source-scanner'
import { findSyntaxSourceBlocksWithIssues } from './bib-syntax-ranges'
import type { BibSyntaxSourceBlock, BibSyntaxSourceIssue } from './bib-syntax-ranges'
import { BIBTEX_CITATION_KEY_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
export type BibSourceDiagnostic = {
from: number
entryFrom?: number
line: number
code: string
field?: string
relatedFrom?: number
relatedLine?: number
}
/**
* Report semantic errors and Lezer syntax issues in source-offset order.
* `syntax` may be reused from an existing projection of `source` to avoid
* parsing twice. Offsets use UTF-16 positions; line numbers are 1-based.
*/
export function validateBibSource(
source: string,
syntax: { blocks: BibSyntaxSourceBlock[]; issues: BibSyntaxSourceIssue[] } =
findSyntaxSourceBlocksWithIssues(source)
): BibSourceDiagnostic[] {
const starts = [0]
for (let i = 0; i < source.length; i++) {
if (source[i] === '\r') {
if (source[i + 1] === '\n') i++
starts.push(i + 1)
} else if (source[i] === '\n') {
starts.push(i + 1)
}
}
const lineAt = (offset: number): number => {
let low = 0
let high = starts.length
while (low < high) {
const middle = (low + high) >>> 1
if (starts[middle] <= offset) low = middle + 1
else high = middle
}
return low
}
const diagnostics: BibSourceDiagnostic[] = []
const add = (from: number, code: string, entryFrom: number,
field?: string, relatedFrom?: number) => {
diagnostics.push({
from, code, entryFrom, field, relatedFrom, line: lineAt(from),
relatedLine: relatedFrom === undefined ? undefined : lineAt(relatedFrom),
})
}
for (const block of syntax.blocks) {
if (NON_BIBLIOGRAPHY_ENTRY_TYPES.has(block.type)) continue
const header = bibEntryHeaderAt(source, block.from)
const keyRange = block.citationKey ?? (header && bibCitationKeyRange(source, header))
if (keyRange) {
const key = source.slice(keyRange.from, keyRange.to)
if (key && !BIBTEX_CITATION_KEY_REGEX.test(key)) {
add(keyRange.from, 'citation_key_contains_invalid_characters', block.from)
}
}
// Duplicate fields are syntactically legal to Lezer but invalid for the
// visual editor. The Lezer-provided ranges also cover valid keyless entries.
const seen = new Set<string>()
for (const field of block.fields ?? []) {
if (seen.has(field.name)) {
add(field.fieldFrom, 'bib_syntax_duplicate_field', block.from,
source.slice(field.fieldFrom,
bibtexIdentifierEnd(source, field.fieldFrom) ?? field.fieldFrom))
}
seen.add(field.name)
}
}
// A semantic error must not suppress an unrelated Lezer syntax error.
for (const issue of syntax.issues) {
add(issue.from, issue.code, issue.entryFrom, issue.field, issue.relatedFrom)
}
return diagnostics.sort((a, b) => a.from - b.from)
}
@@ -0,0 +1,182 @@
/**
* Small source-text helpers for key edits and conservative write guards.
* Entry boundaries, fields and syntax errors come from upstream Lezer.
*/
import {
isBibtexIdentifierContinuationChar,
isBibtexIdentifierStartChar,
} from '@shared/bibtex/bibtex-identifier.mts'
/** End of the physical line, before LF, CRLF or a lone CR. */
export function physicalLineEnd(source: string, index: number): number {
while (index < source.length && source[index] !== '\n' && source[index] !== '\r') {
index++
}
return index
}
/** Start of the physical line containing `index` (LF, CRLF and CR). */
export function physicalLineStart(source: string, index: number): number {
let i = Math.min(index, source.length) - 1
while (i >= 0 && source[i] !== '\n' && source[i] !== '\r') i--
return i + 1
}
/** Skip a line comment, including its physical newline when present. */
export function skipLineComment(source: string, index: number): number {
return consumeOneNewline(source, physicalLineEnd(source, index))
}
/** Skip whitespace and, at token boundaries, upstream-style `%` comments.
* Call only between BibTeX tokens, never inside a braced or quoted value.
*/
export function skipBibtexTrivia(
source: string,
index: number,
allowLineBreaks = true
): number {
let i = index
while (i < source.length) {
if (source[i] === ' ' || source[i] === '\t' ||
(allowLineBreaks && (source[i] === '\r' || source[i] === '\n'))) {
i++
} else if (allowLineBreaks && source[i] === '%') {
i = skipLineComment(source, i)
} else {
break
}
}
return i
}
/** Returns the end offset of an identifier in source or null. */
export function bibtexIdentifierEnd(source: string, start: number): number | null {
if (!isBibtexIdentifierStartChar(source[start] ?? '')) return null
let end = start + 1
while (isBibtexIdentifierContinuationChar(source[end] ?? '')) end++
return end
}
/** A parsed entry header, with offsets in the original source. */
export type BibEntryHeader = {
type: string
typeFrom: number
typeTo: number
opener: '{' | '('
open: number
}
/**
* Parse an entry header without copying the rest of the document. This helper is
* intentionally index-based because it is called once per entry while walking a
* complete bibliography; suffix slices there otherwise make many-entry files
* quadratic in total source length.
*/
export function bibEntryHeaderAt(source: string, index: number): BibEntryHeader | null {
if (source[index] !== '@') return null
let i = skipBibtexTrivia(source, index + 1)
const typeStart = i
const typeEnd = bibtexIdentifierEnd(source, i)
if (typeEnd === null) return null
i = typeEnd
const type = source.slice(typeStart, i).toLowerCase()
i = skipBibtexTrivia(source, i)
const opener = source[i]
if (opener !== '{' && opener !== '(') return null
return { type, typeFrom: typeStart, typeTo: typeEnd, opener, open: i }
}
/**
* Source offsets of a citation key in a normally separated entry header.
* Accepts a comma or the actual outer closing delimiter (key-only entries).
* Incomplete and malformed headers without either separator are left to the
* recovery paths; never search into a following entry for a comma.
*/
export function bibCitationKeyRange(
source: string,
header: Pick<BibEntryHeader, 'open' | 'opener'>
): { from: number; to: number } | null {
// The key may follow whitespace and complete line comments. These are
// header trivia, not part of the citation key or its editable source range.
const from = skipBibtexTrivia(source, header.open + 1)
const closer = header.opener === '{' ? '}' : ')'
let end = from
let keyEnd: number | null = null
while (end < source.length) {
const char = source[end]
if (char === ',' || char === closer) {
let to = keyEnd ?? end
// Upstream admits Unicode spaces inside key identifiers, but after an
// ASCII whitespace boundary, trailing Unicode whitespace is trivia.
// Preserve malformed keys containing interior ASCII whitespace here:
// they must remain editable so users can repair them in source mode.
while (to > from && /[ \t\r\n]/.test(source[to - 1])) to--
const candidate = source.slice(from, to)
const boundary = candidate.search(/[ \t\r\n]/)
if (boundary >= 0 && /^\s*$/.test(candidate.slice(boundary))) {
to = from + boundary
}
return { from, to }
}
if (char === '%') {
// An inline comment belongs to the header, but not to the key. Retain
// its original offsets when replacing just the key in the source.
keyEnd ??= end
end = skipLineComment(source, end)
continue
}
// After a comment, only whitespace and the separator may follow the key.
if (keyEnd !== null && !/\s/.test(char)) return null
// A nested value or another entry marker is not part of a normal key.
if (char === '{' || char === '(' || char === '}' || char === ')' ||
(char === '@' && isLikelyEntryStart(source, end))) {
return null
}
end++
}
return null
}
/** Key followed by a field but missing the comma separating them. */
export function bibMissingKeyCommaRange(
source: string,
header: Pick<BibEntryHeader, 'open'>
): { from: number; to: number; field: string } | null {
const from = skipBibtexTrivia(source, header.open + 1)
let to = from
while (to < source.length && !/[\s,{}()]/.test(source[to])) to++
if (to === from || !/\s/.test(source[to] ?? '')) return null
const next = skipBibtexTrivia(source, to)
const nameEnd = bibtexIdentifierEnd(source, next)
const field = nameEnd !== null &&
source[skipBibtexTrivia(source, nameEnd, true)] === '='
? source.slice(next, nameEnd) : null
return field ? { from, to, field } : null
}
/** Whether only horizontal whitespace precedes this position on its line. */
function isAtLineStart(source: string, index: number): boolean {
let i = index - 1
while (i >= 0 && (source[i] === ' ' || source[i] === '\t')) i--
return i < 0 || source[i] === '\n' || source[i] === '\r'
}
/** Recognize a possible entry marker at the beginning of a line. */
export function isLikelyEntryStart(source: string, index: number): boolean {
return source[index] === '@' && isAtLineStart(source, index) &&
bibEntryHeaderAt(source, index) !== null
}
/** Advance past one physical newline, preserving additional blank lines. */
export function consumeOneNewline(source: string, index: number): number {
if (source.startsWith('\r\n', index)) return index + 2
if (source[index] === '\n' || source[index] === '\r') return index + 1
return index
}
@@ -0,0 +1,543 @@
/**
* Source ranges obtained from Overleaf's own Lezer BibTeX grammar.
*
* Every top-level Lezer Entry supplies a boundary, even when incomplete.
* Only complete, error-free nodes provide trusted fields and entry content.
*/
import { parser as bibtexParser } from '@/features/source-editor/lezer-bibtex/bibtex.mjs'
import { Entry } from '@/features/source-editor/lezer-bibtex/bibtex.terms.mjs'
import type { SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
import { BibtexEntryAccumulator } from '@/features/bibtex/bibtex-parser'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { bibEntryHeaderAt, bibCitationKeyRange, bibMissingKeyCommaRange, bibtexIdentifierEnd, skipBibtexTrivia } from './bib-source-scanner'
import { BIBTEX_CITATION_KEY_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
type BibSyntaxRange = { from: number; to: number }
export type BibFieldSourceRange = {
name: string
fieldFrom: number
valueFrom: number
valueTo: number
}
type BibSyntaxEntryRange = BibSyntaxRange & {
/** Completed Lezer fields; trusted for writes only when the entry is clean. */
fields: BibFieldSourceRange[]
}
/** One upstream entry and its exact source fields, obtained from one Lezer tree. */
export type BibSyntaxEntryProjection = BibSyntaxEntryRange & {
type: string
entry: BibtexEntry | null
/** Exact upstream CitationKey token, when the grammar recognized one. */
citationKey: { from: number; to: number } | null
/** Real outer closing delimiter, even when the entry has syntax errors. */
closed: boolean
/** No syntax errors or incomplete field values: safe for source field edits. */
clean: boolean
}
export type BibSyntaxProjection = {
entries: BibSyntaxEntryProjection[]
/** True if any upstream Entry was malformed. */
needsRecovery: boolean
/** Start of every top-level BibTeX directive (excluding Junk and % comments). */
blockStarts: number[]
}
function walk(node: SyntaxNode, visit: (node: SyntaxNode) => void): void {
visit(node)
for (let child = node.firstChild; child; child = child.nextSibling) {
walk(child, visit)
}
}
function childNamed(node: SyntaxNode, name: string): SyntaxNode | null {
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.type.name === name) return child
}
return null
}
function hasRealEntryCloser(entry: SyntaxNode): boolean {
const body = childNamed(entry, 'EntryBody')
if (!body) return false
const closer = childNamed(body, 'BodyClose')
return !!closer && closer.from < closer.to && closer.to === entry.to
}
function hasRealValueClosers(value: SyntaxNode): boolean {
let complete = true
walk(value, node => {
if (node.type.name !== 'StringLiteral') return
const open = childNamed(node, 'StringOpen')
const close = childNamed(node, 'StringClose')
if (!open || !close || open.from >= open.to || close.from >= close.to) {
complete = false
}
})
return complete
}
function fieldsInEntry(source: string, entry: SyntaxNode): {
fields: BibFieldSourceRange[]
citationKey: { from: number; to: number } | null
clean: boolean
} | null {
const fields: BibFieldSourceRange[] = []
let complete = true
let clean = true
let citationKey: { from: number; to: number } | null = null
walk(entry, node => {
if (node.type.isError) clean = false
if (node.type.name === 'CitationKey') {
citationKey = { from: node.from, to: node.to }
}
if (node.type.name !== 'Field') return
const nameNode = childNamed(node, 'FieldName')
const valueNode = childNamed(node, 'Value')
if (!nameNode || !valueNode || !hasRealValueClosers(valueNode)) {
complete = false
return
}
fields.push({
name: source.slice(nameNode.from, nameNode.to).toLowerCase(),
fieldFrom: node.from,
valueFrom: valueNode.from,
valueTo: valueNode.to,
})
})
// Keep complete field nodes for diagnostics even if a later field is malformed.
return { fields, citationKey, clean: clean && complete }
}
/** Top-level BibTeX directives delimit incomplete entries. Junk remains part
* of its preceding malformed entry; standalone % comments are source trivia. */
function topLevelBoundary(node: SyntaxNode): boolean {
return node.type.name === 'Entry' ||
node.type.name === 'CommentEntry' || node.type.name === 'StringEntry' ||
node.type.name === 'PreambleEntry'
}
/** Build one occurrence directly from Lezer, preserving duplicate citation keys. */
function accumulateSyntaxEntry(source: string, entryNode: SyntaxNode): BibtexEntry | null {
const accumulator = new BibtexEntryAccumulator()
accumulator.reset()
const visit = (node: SyntaxNode): void => {
const ref = { type: node.type, from: node.from, to: node.to } as SyntaxNodeRef
const descend = accumulator.enter(ref, (from: number, to: number) => source.slice(from, to))
if (descend !== false) {
for (let child = node.firstChild; child; child = child.nextSibling) visit(child)
}
accumulator.leave(ref)
}
visit(entryNode)
return accumulator.build()
}
/**
* Parse the document once and retain each accepted Entry node together with its
* exact source and field ranges. Accumulate the entry once, so consumers do
* not need the Lezer node or an isolated reparsing step.
*/
function projectSyntaxEntries(
source: string,
includeEntries: boolean,
tree: Tree
): BibSyntaxProjection {
const entries: BibSyntaxEntryProjection[] = []
let incomplete = false
const blockStarts: number[] = []
// Lezer already identifies entry starts, including malformed entries that
// end at an error token and leave the rest of their text in a Junk node.
for (let node = tree.topNode.firstChild; node; node = node.nextSibling) {
if (topLevelBoundary(node)) blockStarts.push(node.from)
if (!node.type.is(Entry)) continue
const closed = hasRealEntryCloser(node)
const result = fieldsInEntry(source, node)
const clean = closed && !!result?.clean
if (!clean) incomplete = true
const typeNode = node.firstChild && childNamed(node.firstChild, 'EntryType')
entries.push({
type: typeNode ? source.slice(typeNode.from, typeNode.to).toLowerCase() : 'misc',
from: node.from,
to: node.to,
fields: result?.fields ?? [],
citationKey: result?.citationKey ?? null,
closed,
clean,
entry: includeEntries ? accumulateSyntaxEntry(source, node) : null,
})
}
return { entries, needsRecovery: incomplete, blockStarts }
}
/** Independently project entries from one upstream tree. */
export function findSyntaxEntryProjection(
source: string,
includeEntries = true
): BibSyntaxProjection {
return projectSyntaxEntries(source, includeEntries, bibtexParser.parse(source))
}
/** Top-level blocks and Lezer syntax errors, from the same syntax tree.
* These errors describe entries, not comments or arbitrary Junk between entries.
* A valid empty citation key is explicitly supported by the visual editor.
*/
export type BibSyntaxSourceBlock = {
from: number
to: number
type: string
/** True only when Lezer found the real outer closer. */
closed?: boolean
/** Completed Lezer Field nodes for diagnostics; not trusted write ranges. */
fields?: BibFieldSourceRange[]
citationKey?: { from: number; to: number } | null
}
export type BibSyntaxSourceIssue = {
from: number
entryFrom: number
code: 'bib_syntax_invalid_entry_header' | 'bib_syntax_invalid_field' |
'bib_syntax_missing_closing_quote' | 'bib_syntax_missing_closing_brace_opened_on_line' |
'bib_syntax_expected_equals_after_field_name' | 'bib_syntax_expected_comma_before_field' |
'bib_syntax_expected_comma_after_citation_key' | 'bib_syntax_unexpected_comma'
field?: string
relatedFrom?: number
}
function firstSyntaxError(node: SyntaxNode): number | null {
if (node.type.isError) return node.from
for (let child = node.firstChild; child; child = child.nextSibling) {
const error = firstSyntaxError(child)
if (error !== null) return error
}
return null
}
/** Classify simple separator errors using Lezer's error position.
* Do not reconstruct fields or parse Junk after a syntax error. */
function fieldSeparatorIssue(
entry: SyntaxNode, source: string, errorFrom: number
): Pick<BibSyntaxSourceIssue, 'code' | 'field'> | null {
const body = childNamed(entry, 'EntryBody')
if (!body) return null
const header = bibEntryHeaderAt(source, entry.from)
const missingKeyComma = header && bibMissingKeyCommaRange(source, header)
if (missingKeyComma && missingKeyComma.to <= errorFrom) {
return { code: 'bib_syntax_expected_comma_after_citation_key',
field: missingKeyComma.field }
}
let previous: SyntaxNode | null = null
for (let node = body.firstChild; node; node = node.nextSibling) {
if (node.type.isError && node.from === errorFrom && source[node.from] === ',' &&
source.slice(body.from, node.from).trimEnd().endsWith(',')) {
return { code: 'bib_syntax_unexpected_comma' }
}
if (node.type.name === 'Field' && firstSyntaxError(node) === errorFrom) {
const name = childNamed(node, 'FieldName')
if (name && !childNamed(node, '=')) {
return { code: 'bib_syntax_expected_equals_after_field_name',
field: source.slice(name.from, name.to) }
}
}
if (node.type.isError && node.from === errorFrom &&
previous?.type.name === 'Field' && childNamed(previous, 'Value')) {
const next = skipBibtexTrivia(source, errorFrom)
const end = bibtexIdentifierEnd(source, next)
if (end !== null && source[skipBibtexTrivia(source, end)] === '=') {
return { code: 'bib_syntax_expected_comma_before_field',
field: source.slice(next, end) }
}
}
previous = node
}
return null
}
/** Lezer cannot continue after a missing or invalid key. Supply a temporary
* valid key for diagnostic projection, without changing the source.
* All returned coordinates refer to the original source.
*/
function virtualKeylessEntry(source: string, recoverInvalidKey = false): {
source: string
shift: (offset: number) => number
keyRange: { from: number; to: number }
} | null {
const header = bibEntryHeaderAt(source, 0)
const range = header && bibCitationKeyRange(source, header)
if (!range || (range.from !== range.to &&
(!recoverInvalidKey || BIBTEX_CITATION_KEY_REGEX.test(source.slice(range.from, range.to))))) return null
const key = 'BibEditorTemporaryKey'
const delta = key.length - (range.to - range.from)
return {
source: source.slice(0, range.from) + key + source.slice(range.to),
keyRange: range,
shift: offset => offset >= range.from + key.length
? offset - delta : Math.min(offset, range.from),
}
}
/** The shared keyless projection for diagnostics and field edits; optionally
* recover invalid keys for diagnostics too. The temporary key stays in memory.
*/
function projectVirtualKeylessEntry(
source: string, includeEntry = false, recoverInvalidKey = false
):
| {
clean: true
fields: BibFieldSourceRange[]
keyRange: { from: number; to: number }
close: number
entry: BibtexEntry | null
}
| { clean: false; errorFrom: number; fields: BibFieldSourceRange[]
unclosed: ReturnType<typeof missingStringClose>
separator: ReturnType<typeof fieldSeparatorIssue> }
| null {
const virtual = virtualKeylessEntry(source, recoverInvalidKey)
if (!virtual) return null
const tree = bibtexParser.parse(virtual.source)
const node = tree.topNode.firstChild
const error = node?.type.is(Entry) ? firstSyntaxError(node) : null
const unclosed = node?.type.is(Entry) ? missingStringClose(node, virtual.source) : null
const invalid = (from: number) => ({
clean: false as const,
errorFrom: virtual.shift(from),
// Completed upstream fields before the error are useful for diagnostics,
// but must never be used as trusted editing ranges.
fields: node?.type.is(Entry) ? (fieldsInEntry(virtual.source, node)?.fields ?? []).map(field => ({
...field,
fieldFrom: virtual.shift(field.fieldFrom),
valueFrom: virtual.shift(field.valueFrom),
valueTo: virtual.shift(field.valueTo),
})) : [],
unclosed: unclosed && { ...unclosed, relatedFrom: virtual.shift(unclosed.relatedFrom) },
separator: node?.type.is(Entry) && error !== null
? fieldSeparatorIssue(node, virtual.source, error) : null,
})
// Standalone % comments after a complete entry are trivia, not part of it.
// They can follow the entry in Lezer's top-level tree without invalidating it.
let trailing = node?.nextSibling
while (trailing?.type.name === 'Comment') trailing = trailing.nextSibling
if (!node || !node.type.is(Entry) || trailing || node.from !== 0 ||
!hasRealEntryCloser(node) || error !== null) {
return invalid(error ?? (node?.to ?? virtual.source.length))
}
const fields = fieldsInEntry(virtual.source, node)
if (!fields?.clean) return invalid(node.to)
const entry = includeEntry ? accumulateSyntaxEntry(virtual.source, node) : null
if (includeEntry && !entry) return invalid(node.to)
return {
clean: true,
keyRange: virtual.keyRange,
close: virtual.shift(node.to - 1),
entry: entry?.setKey('') ?? null,
fields: fields.fields.map(field => ({
...field,
fieldFrom: virtual.shift(field.fieldFrom),
valueFrom: virtual.shift(field.valueFrom),
valueTo: virtual.shift(field.valueTo),
})),
}
}
/** Detect an unclosed string directly from Lezer's StringOpen/StringClose nodes.
* No independent quote or brace state machine is needed. */
function missingStringClose(entry: SyntaxNode, source: string): {
code: 'bib_syntax_missing_closing_quote' | 'bib_syntax_missing_closing_brace_opened_on_line'
relatedFrom: number
} | null {
let missing: ReturnType<typeof missingStringClose> = null
walk(entry, node => {
if (node.type.name !== 'StringLiteral' || childNamed(node, 'StringClose')) return
const open = childNamed(node, 'StringOpen')
if (open) {
missing = {
code: source[open.from] === '"'
? 'bib_syntax_missing_closing_quote'
: 'bib_syntax_missing_closing_brace_opened_on_line',
relatedFrom: open.from,
}
}
})
return missing
}
function collectSourceBlocks(
source: string,
includeIssues: boolean,
tree: Tree,
projectedEntries?: Map<number, BibSyntaxEntryProjection>
): {
blocks: BibSyntaxSourceBlock[]
issues: BibSyntaxSourceIssue[]
} {
const nodes: SyntaxNode[] = []
for (let node = tree.topNode.firstChild; node; node = node.nextSibling) {
if (topLevelBoundary(node)) {
nodes.push(node)
}
}
const blocks: BibSyntaxSourceBlock[] = []
const issues: BibSyntaxSourceIssue[] = []
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index]
const body = node.lastChild
const closer = body && childNamed(body, 'BodyClose')
const closed = !!closer && closer.from < closer.to && closer.to === node.to
const typeNode = node.firstChild && childNamed(node.firstChild, 'EntryType')
const type = node.type.name === 'Entry'
? (typeNode ? source.slice(typeNode.from, typeNode.to).toLowerCase() : '')
: node.type.name.replace('Entry', '').toLowerCase()
const to = closed ? node.to : (nodes[index + 1]?.from ?? source.length)
// Lezer stops at an empty key. Use the shared virtual-key projection to
// locate its real closer, including when a % comment follows the entry.
const keyless = node.type.is(Entry) && !closed
? projectVirtualKeylessEntry(source.slice(node.from, to)) : null
const block: BibSyntaxSourceBlock = {
from: node.from,
to: keyless?.clean ? node.from + keyless.close + 1 : to,
type, closed: keyless?.clean || closed,
}
blocks.push(block)
if (!includeIssues || !node.type.is(Entry)) continue
// Lezer's completed Field nodes can still diagnose duplicates when a
// later field is malformed. Do not use these ranges for source writes.
const cached = projectedEntries?.get(node.from)
// The combined projection has already collected complete field nodes,
// including those preceding a syntax error. Do not walk them twice.
const parsed = cached ?? fieldsInEntry(source, node)
if (parsed) {
block.fields = parsed.fields
if (parsed.clean && closed) block.citationKey = parsed.citationKey
}
const header = bibEntryHeaderAt(source, node.from)
if (!header) {
issues.push({ from: node.from,
entryFrom: node.from, code: 'bib_syntax_invalid_entry_header' })
continue
}
let error = firstSyntaxError(node)
if (error === null && closed) continue
// A missing or invalid key stops Lezer before the body. Reparse with a
// virtual key so the rest of the entry determines the syntax diagnosis.
const diagnosticKeyless = keyless ??
projectVirtualKeylessEntry(source.slice(node.from, to), false, true)
if (diagnosticKeyless) {
// Include fields Lezer recognized before the first malformed value.
// These ranges are diagnostic-only; write planning requires a clean entry.
block.fields = diagnosticKeyless.fields.map(field => ({
...field,
fieldFrom: node.from + field.fieldFrom,
valueFrom: node.from + field.valueFrom,
valueTo: node.from + field.valueTo,
}))
if (diagnosticKeyless.clean) {
block.to = node.from + diagnosticKeyless.close + 1
block.citationKey = {
from: node.from + diagnosticKeyless.keyRange.from,
to: node.from + diagnosticKeyless.keyRange.to,
}
continue
}
}
if (diagnosticKeyless && !diagnosticKeyless.clean) error = node.from + diagnosticKeyless.errorFrom
const unclosed = diagnosticKeyless && !diagnosticKeyless.clean && diagnosticKeyless.unclosed
? { ...diagnosticKeyless.unclosed, relatedFrom: node.from + diagnosticKeyless.unclosed.relatedFrom }
: missingStringClose(node, source)
const separator = diagnosticKeyless && !diagnosticKeyless.clean
? diagnosticKeyless.separator : error !== null ? fieldSeparatorIssue(node, source, error) : null
issues.push({ from: error ?? node.to, entryFrom: node.from,
code: unclosed?.code ?? separator?.code ?? 'bib_syntax_invalid_field',
field: separator?.field, relatedFrom: unclosed?.relatedFrom })
}
return { blocks, issues }
}
/** Include Lezer's syntax errors when validating input before an import.
* In particular, a block can have well-formed delimiters but an invalid value.
*/
export function findSyntaxSourceBlocksWithIssues(source: string): {
blocks: BibSyntaxSourceBlock[]
issues: BibSyntaxSourceIssue[]
} {
return collectSourceBlocks(source, true, bibtexParser.parse(source))
}
/** Top-level BibTeX blocks, including incomplete entries and @comment bodies.
* Lezer owns discovery; malformed blocks extend up to the next upstream block.
*/
export function findSyntaxSourceBlocks(source: string): BibSyntaxSourceBlock[] {
return collectSourceBlocks(source, false, bibtexParser.parse(source)).blocks
}
/** The complete upstream projection and syntax diagnostics from one grammar parse.
* Import can share this result with its diagnostics and strict completeness
* guard, instead of parsing the same candidate twice.
*/
export function analyzeBibSyntax(source: string): BibSyntaxProjection & {
blocks: BibSyntaxSourceBlock[]
issues: BibSyntaxSourceIssue[]
} {
const tree = bibtexParser.parse(source)
const projection = projectSyntaxEntries(source, true, tree)
const entriesByStart = new Map(projection.entries.map(entry => [entry.from, entry]))
return {
...projection,
...collectSourceBlocks(source, true, tree, entriesByStart),
}
}
/**
* Upstream Lezer cannot parse fields after an empty citation key. Project only
* that single source occurrence through a temporary, valid key, without ever
* writing the temporary text to CodeMirror. Every field offset is translated
* back to the original text; malformed entries still fail closed.
*/
export function projectKeylessEntry(source: string): {
entry: BibtexEntry
fields: BibFieldSourceRange[]
close: number
} | null {
const projection = projectVirtualKeylessEntry(source, true)
if (!projection?.clean || !projection.entry) return null
return {
entry: projection.entry,
fields: projection.fields,
close: projection.close,
}
}
/** Trusted field ranges and real outer closer from one isolated Lezer parse.
* Both editing positions come from the same syntax projection; the write planner
* need not scan delimiters again. Keyless entries use the same virtual-key
* projection as the Visual editor, with offsets mapped to the original text.
*/
export function findTrustedFieldSource(source: string): {
fields: BibFieldSourceRange[]
close: number
} | null {
// Keyless entries need only the virtual-key parse. Do not first parse an
// incomplete entry with the original grammar and then parse it again.
const keyless = projectVirtualKeylessEntry(source)
if (keyless) {
return keyless.clean ? { fields: keyless.fields, close: keyless.close } : null
}
const projection = findSyntaxEntryProjection(source, false)
if (projection.needsRecovery || projection.entries.length !== 1) return null
const entry = projection.entries[0]
return entry.from === 0 && entry.clean && entry.closed
? { fields: entry.fields, close: entry.to - 1 } : null
}
@@ -0,0 +1,75 @@
/**
* Visual-editor type model. This is the bridge between the raw BibTeX entry model
* and the per-type field order/required-field rules used by the React forms.
*/
import { getEntryType, getTypeFields } from '@shared/bibtex/entry-types.mts'
import type { BibEntryType } from '@shared/bibtex/bibtex-entry-schema.mts'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { OPTIONAL_FIELD_NAMES, requiredFieldGroups, requiredFieldNames } from './overleaf-type-map.ts'
export type { PositionedBibtexEntry } from '@/features/bibtex/positioned-bibtex-entry'
export type { BibEntryType }
/** Field presentation. Unlisted fields use an ordinary one-line input. */
const FIELD_TYPES = new Map<string, 'name' | 'large'>([
['author', 'name'],
['editor', 'name'],
['title', 'large'],
['note', 'large'],
['keywords', 'large'],
['abstract', 'large'],
])
export const getFieldType = (field: string): 'name' | 'large' | 'short' =>
FIELD_TYPES.get(field) ?? 'short'
export const NAME_FIELDS = [...FIELD_TYPES]
.filter(([, type]) => type === 'name')
.map(([field]) => field)
export const isNameField = (field: string): boolean =>
getFieldType(field) === 'name'
/** A required field is missing when its value is empty, even if it exists. */
export function missingRequiredFieldGroups(entry: BibtexEntry): string[][] {
return requiredFieldGroups(getEntryType(entry.type.toLowerCase()))
.filter(group =>
group.every(field => !entry.getField(field).toEditableString().trim())
)
}
/** Return the field order defined by the shared BibTeX entry type. */
export function formRowsFor(type: string): string[] {
return [...getTypeFields(type.toLowerCase())]
}
/**
* Optional rows visible in the form are the union of fields already present in
* the entry and fields explicitly added during this editing session. Keeping the
* union here is important for malformed entries: a hidden field can still need to
* be revealed when its value is invalid.
*/
export function optionalVisibleFor(
type: string,
entry: BibtexEntry,
addedFields: string[] = []
): string[] {
const typeDef = getEntryType(type.toLowerCase())
const required = new Set(requiredFieldNames(typeDef).map(field => field.toLowerCase()))
const formFields = new Set(typeDef.fields.map(field => field.toLowerCase()))
const added = new Set(addedFields)
const candidateFields = new Set([
...OPTIONAL_FIELD_NAMES,
...addedFields,
...entry.getFieldsArray().map(({ key }) => key.toLowerCase()),
])
return [...candidateFields].filter(field => {
if (formFields.has(field) || required.has(field)) return false
if (field === 'abstract' && !added.has(field)) return false
// A field containing only whitespace still exists in the BibTeX source.
// Do not hide its textarea after closing and reopening the preview.
return entry.hasField(field) || added.has(field)
})
}
@@ -0,0 +1,191 @@
/**
* Pure validation for the bib visual editor.
*
* The preview and manual-entry modal use these messages for required fields,
* invalid nonempty keys and non-blocking format warnings. Source syntax errors
* and entry boundaries are handled separately by the Lezer-backed diagnostics.
*
* Rules used by this module:
* - empty citation keys are valid source; parsing never writes a key.
* Explicit Visual editing may generate one using the field-write path.
* - required fields: standalone "X is required"; OR-group "Either A or B is
* required" shown next to EVERY empty member of the group.
* - format warnings for filled values: year, date/URL date, DOI, URL.
* Format warnings do not block otherwise structurally safe writes.
*
* validateEntry() is pure: it returns machine-readable messages
* (ValidationMessage), the view renders them via i18n.
*/
import { BIBTEX_CITATION_KEY_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
import { missingRequiredFieldGroups } from './bib-types'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { fieldLabel } from './bib-field-labels'
import { isValidDoi } from './bib-doi'
export type ValidationMessage =
| {
kind: 'id-invalid'
}
| {
/** One empty standalone required field, or a whole empty OR-group */
kind: 'required-missing'
/** Field member(s) this message applies to (standalone: 1) */
labelFields: string[]
group: boolean
}
| {
kind: 'year-format'
}
| {
kind: 'date-format'
}
| {
kind: 'doi-format'
}
| {
kind: 'url-invalid'
}
/**
* Match upstream BibTeX brace handling: every { and } is structural, even
* after a backslash. A trailing backslash is ordinary field content.
*/
export function hasBalancedCurlyBraces(value: string): boolean {
let depth = 0
for (const char of value) {
if (char === '{') depth++
else if (char === '}') {
if (depth === 0) return false
depth--
}
}
return depth === 0
}
/** BibLaTeX date/date-range syntax, including partial dates and date-times. */
const DATE_PART_PATTERN = String.raw`(?:[+-]?\d{4}(?:-(?:\d{2}|XX)(?:-(?:\d{2}|XX)(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)?)?|\d{3}X|\d{2}XX)[?~%]?`
const DATE_PATTERN = new RegExp(
String.raw`^(?:${DATE_PART_PATTERN}(?:\s*\/\s*(?:${DATE_PART_PATTERN}|\.\.))?|\.\.\s*\/\s*${DATE_PART_PATTERN})$`, 'i'
)
/** Validate the date syntax only; calendar and range ordering are not checked. */
function hasValidBiblatexDate(value: string): boolean {
const date = value.trim()
return !date || DATE_PATTERN.test(date)
}
const URL_PATTERN =
/^(?:https?:\/\/)?(?:\[[\da-f:.]+\]|[a-z\d](?:[a-z\d-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]*[a-z\d])?)*\.?)(?::\d+)?(?:[/?#]\S*)?$/i
/** Validate URL syntax; an http(s) scheme is optional. */
function hasValidUrl(value: string): boolean {
return URL_PATTERN.test(value.trim())
}
/** Empty keys are valid source and editable in the visual editor. */
export function isWritableCitationKey(key: string): boolean {
// $ can match before a final newline in JavaScript, so compare the entire
// match rather than treating RegExp.test() as a full-string assertion.
return key === '' || BIBTEX_CITATION_KEY_REGEX.exec(key)?.[0] === key
}
export function hasUnbalancedCurlyBraces(entry: BibtexEntry): boolean {
return entry
.getFieldsArray()
.some(({ value }) => !hasBalancedCurlyBraces(value.toEditableString()))
}
/** All format warnings are non-blocking; values are tested only if nonempty. */
const FORMAT_CHECKS: {
field: string
kind: 'year-format' | 'date-format' | 'doi-format' | 'url-invalid'
valid: (value: string) => boolean
}[] = [
{ field: 'year', kind: 'year-format', valid: value => /^\d{4}$/.test(value.trim()) },
{ field: 'date', kind: 'date-format', valid: hasValidBiblatexDate },
{ field: 'urldate', kind: 'date-format', valid: hasValidBiblatexDate },
{ field: 'doi', kind: 'doi-format', valid: value => isValidDoi(value.trim()) },
{ field: 'url', kind: 'url-invalid', valid: hasValidUrl },
]
export type ValidationResult = {
/** Validation messages keyed by field name (shown under each listed field) */
byField: Record<string, ValidationMessage>
/** True when there are no messages, including non-blocking warnings. */
valid: boolean
}
/**
* Validate an entry draft.
*
* An empty citation key is valid here. Generation/commit is managed by the
* Visual editor's key-on-leave lifecycle, never by this validator.
*/
export function validateEntry(
entry: BibtexEntry
): ValidationResult {
const field = (name: string) => entry.getField(name).toEditableString()
const key = entry.key
const byField: Record<string, ValidationMessage> = {}
if (!isWritableCitationKey(key)) {
byField.id = { kind: 'id-invalid' }
}
// Attach one group error to each empty alternative so every input is marked.
for (const members of missingRequiredFieldGroups(entry)) {
const group = members.length > 1
for (const f of members) {
if (f in byField) continue
byField[f] = {
kind: 'required-missing',
labelFields: members,
group,
}
}
}
// Required-field errors take precedence over format-only warnings.
for (const { field: name, kind, valid } of FORMAT_CHECKS) {
const value = field(name)
if (value.trim() && !byField[name] && !valid(value)) {
byField[name] = { kind }
}
}
return { byField, valid: Object.keys(byField).length === 0 }
}
const FORMAT_WARNING_KINDS = new Set<ValidationMessage['kind']>([
'year-format',
'date-format',
'doi-format',
'url-invalid',
])
export function isFormatWarning(
message: ValidationMessage | undefined
): boolean {
return message !== undefined && FORMAT_WARNING_KINDS.has(message.kind)
}
/** Return concise labels for non-blocking field-format warnings. */
export function formatWarningLabels(entry: BibtexEntry): string[] {
return Object.entries(validateEntry(entry).byField)
.filter(([, message]) => isFormatWarning(message))
.map(([field]) => {
if (field === 'doi') return 'DOI'
return fieldLabel(field)
})
}
/**
* Format warnings do not block submission or source edits; required fields
* and invalid nonempty citation keys are blocking for form submissions.
* Structural source writes have separate Lezer-based guards.
*/
export function hasBlockingValidationErrors(result: ValidationResult): boolean {
return Object.values(result.byField).some(
message => !isFormatWarning(message)
)
}
@@ -0,0 +1,13 @@
/** Only the attached, synchronized source document may accept an exit write. */
export function canCommitDepartingDocument(
document: {
joined: boolean
cm6?: { view: unknown }
getSnapshot(): string | undefined
},
view: { state: { doc: { toString(): string } } }
): boolean {
return document.joined &&
document.cm6?.view === view &&
document.getSnapshot() === view.state.doc.toString()
}
@@ -0,0 +1,485 @@
/**
* Pure planners for mutations of the BibTeX source. These functions do not
* modify CodeMirror; they only calculate and validate the text changes that
* the extension may apply to the current document.
*
* Whole-entry and structural planners take the current document source;
* planBibFieldWrite() instead receives one isolated source entry. Each returns
* a guarded change plan without touching the DOM, React or CodeMirror.
*
* Source ranges are always resolved against the current document immediately
* before the write. This is essential because source offsets become stale after
* any insertion or deletion elsewhere in the file.
*/
import { parseBibFile } from './bib-entry-position'
import {
hasBalancedCurlyBraces,
hasBlockingValidationErrors,
isWritableCitationKey,
validateEntry,
} from './bib-validate'
import {
bibCitationKeyRange,
bibMissingKeyCommaRange,
physicalLineStart,
consumeOneNewline,
physicalLineEnd,
skipBibtexTrivia,
isLikelyEntryStart,
bibEntryHeaderAt,
} from './bib-source-scanner'
import { findTrustedFieldSource } from './bib-syntax-ranges'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
export type BibWritePlan = {
kind: 'replace' | 'append'
/** inclusive start offset */
from: number
/** exclusive end offset (=== from for append) */
to: number
/** text to splice in between from and to */
insert: string
/** source offset where the entry itself begins after applying this plan */
sourceStart?: number
}
export type BibWriteGuard =
| { ok: true; plan: BibWritePlan }
| { ok: false; reason: string }
const ENTRY_GONE_REASON = 'entry-gone'
const FIELD_NOT_FOUND_REASON = 'field-gone'
const INVALID_FIELD_VALUE_REASON = 'invalid-field-value'
function replacePlan(from: number, to: number, insert: string): BibWriteGuard {
return { ok: true, plan: { kind: 'replace', from, to, insert, sourceStart: from } }
}
type ParsedSourceEntry = ReturnType<typeof parseBibFile>[number]
/** Find one exact source occurrence without allocating a filtered array. */
function findUniqueEntryByStart(
entries: ParsedSourceEntry[],
sourceStart: number
): ParsedSourceEntry | null {
let match: ParsedSourceEntry | null = null
for (const entry of entries) {
if (entry.from !== sourceStart) continue
if (match) return null
match = entry
}
return match
}
/** Build a source-offset index once for operations resolving several entries. */
function indexUniqueEntriesByStart(
entries: ParsedSourceEntry[]
): Map<number, ParsedSourceEntry | null> {
const byStart = new Map<number, ParsedSourceEntry | null>()
for (const entry of entries) {
if (byStart.has(entry.from)) byStart.set(entry.from, null)
else byStart.set(entry.from, entry)
}
return byStart
}
/**
* A malformed source range must not swallow a later entry-looking directive.
* Lezer is authoritative for trusted entries; these may legitimately contain
* an @-directive in a quoted or braced field. For *untrusted* ranges, fail
* closed if there is another line-start entry marker, regardless of whether a
* brace eventually closes: that brace could belong to the following entry.
*/
function containsPossibleFollowingEntry(
source: string,
from: number,
to: number
): boolean {
for (let i = from + 1; i < to; i++) {
if (source[i] === '@' && isLikelyEntryStart(source, i)) return true
}
return false
}
/** Only a complete, error-free Lezer entry has trustworthy source boundaries. */
function unsafeWholeEntryRange(
source: string,
entry: ParsedSourceEntry
): boolean {
if (entry.sourceFieldRanges !== undefined) return false
// A damaged @type without an opener has no replaceable outer entry.
if (!bibEntryHeaderAt(source, entry.from)) return true
return containsPossibleFollowingEntry(source, entry.from, entry.to)
}
/**
* Check both editable field text and the exact source representation emitted
* by BibtexFieldValue.toString(). This is deliberately repeated at the write
* boundary: a malformed raw value must not become a malformed BibTeX fragment
* if conversion/serialization normalizes it differently from the form text.
*/
function hasSafeSerializedFieldValues(entry: BibtexEntry): boolean {
return entry.getFieldsArray().every(({ value }) =>
hasBalancedCurlyBraces(value.toEditableString()) &&
hasBalancedCurlyBraces(value.toString())
)
}
function hasSafeSerializedEntry(entry: BibtexEntry): boolean {
return (
hasSafeSerializedFieldValues(entry) &&
hasBalancedCurlyBraces(entry.toString())
)
}
/**
* Locate the optional comma after a value, skipping interspersed whitespace
* and `%` comments. Keep the exact comma offset: the text before it may
* contain comments that an edit must preserve.
*/
function fieldSeparatorAfter(source: string, valueEnd: number): {
commaAt: number | null
after: number
} {
const at = skipBibtexTrivia(source, valueEnd)
const commaAt = source[at] === ',' ? at : null
return {
commaAt,
after: skipBibtexTrivia(source, commaAt === null ? at : at + 1),
}
}
/** Follow the first physical line ending already used by the document. */
function sourceNewline(source: string): string {
return source.match(/\r\n|\r|\n/)?.[0] ?? '\n'
}
/** Match generated BibTeX text to the document's newline convention. */
function serializeForSource(entry: BibtexEntry, newline: string): string {
return entry.toString().replace(/\r\n|\r|\n/g, newline)
}
/** Format an append without rewriting any existing source text. */
function appendBibEntries(
source: string,
entries: BibtexEntry[]
): { insert: string; sourceStart: number } {
const newline = sourceNewline(source)
const endsInNewline = source.endsWith('\n') || source.endsWith('\r')
const separator = source.length > 0 && !endsInNewline ? newline : ''
const trailingNewline = endsInNewline ? newline : ''
return {
insert: `${separator}${entries.map(entry => serializeForSource(entry, newline)).join(newline)}${trailingNewline}`,
sourceStart: source.length + separator.length,
}
}
export type BibFieldWriteTarget =
| { kind: 'entry-type' }
| { kind: 'citation-key' }
| { kind: 'field'; name: string }
/**
* Plan a small visual-editor mutation inside one already-resolved entry.
* `source` is only the entry text, never the complete bibliography.
*/
export function planBibFieldWrite(
source: string,
entry: BibtexEntry,
target: BibFieldWriteTarget,
existingField?: boolean
): BibWriteGuard {
if (target.kind === 'citation-key') {
if (!isWritableCitationKey(entry.key)) {
return { ok: false, reason: INVALID_FIELD_VALUE_REASON }
}
const entryHeader = bibEntryHeaderAt(source, 0)
const keyRange = entryHeader ? bibCitationKeyRange(source, entryHeader) : null
if (keyRange) {
return replacePlan(keyRange.from, keyRange.to, entry.key)
}
// Recover headers missing the comma before the first field. Repair the
// separator while changing the key so the entry becomes safely writable.
const malformed = entryHeader ? bibMissingKeyCommaRange(source, entryHeader) : null
if (malformed) {
return replacePlan(malformed.from, malformed.to, `${entry.key},`)
}
return { ok: false, reason: ENTRY_GONE_REASON }
}
if (target.kind === 'entry-type') {
const header = bibEntryHeaderAt(source, 0)
if (!header) return { ok: false, reason: ENTRY_GONE_REASON }
return replacePlan(header.typeFrom, header.typeTo, entry.type)
}
const normalized = target.name.toLowerCase()
const fieldExistsInSourceModel = existingField ?? entry.hasField(normalized)
const trusted = findTrustedFieldSource(source)
// The fields and closing delimiter must come from the same trusted Lezer
// entry; never infer either from malformed source.
if (!trusted) return { ok: false, reason: ENTRY_GONE_REASON }
const sourceFieldRanges = trusted.fields
const range = sourceFieldRanges.reduce<(typeof sourceFieldRanges)[number] | null>(
(found, candidate) => candidate.name === normalized ? candidate : found,
null
)
const value = entry.getField(normalized)
// Format warnings (including malformed URLs) are informational. The
// structural checks below still reject unbalanced braces
// before serializing the value into the bibliography source.
// Keep the structural guard at the serialization boundary as well as in the
// React form. This is field-local so another valid field can still be saved.
const serializedValue = value.toString()
if (
entry.hasField(normalized) &&
(!hasBalancedCurlyBraces(value.toEditableString()) ||
!hasBalancedCurlyBraces(serializedValue))
) {
return { ok: false, reason: INVALID_FIELD_VALUE_REASON }
}
const insertValue = serializedValue.replace(/\r\n|\r|\n/g, sourceNewline(source))
if (range) {
if (!entry.hasField(normalized)) {
let from = range.fieldFrom
let to = range.valueTo
const lineStart = physicalLineStart(source, from)
const before = source.slice(lineStart, from)
const separator = fieldSeparatorAfter(source, to)
// Preserve source comments between the removed value and its comma.
// A plain range deletion would erase those comments; leaving the comma
// behind instead can make upstream discard the following field.
let insert = ''
if (separator.commaAt !== null) {
insert = source.slice(to, separator.commaAt)
to = separator.commaAt + 1
}
const afterField = source.slice(to)
if (
before.trim() === '' &&
insert.trim() === '' &&
/^[ \t]*(?:\r\n|\r|\n|$)/.test(afterField)
) {
from = lineStart
to = consumeOneNewline(source, physicalLineEnd(source, to))
insert = ''
}
return replacePlan(from, to, insert)
}
return replacePlan(range.valueFrom, range.valueTo, insertValue)
}
// When the current source already contains the field according to the
// current parsed entry, failing to locate its source range is never a reason
// to guess where the field belongs. Falling through here would use the last
// `}` in the supplied entry text as an insertion point, which can turn a
// stale/truncated range into a field inserted inside another field value.
if (fieldExistsInSourceModel) {
return { ok: false, reason: FIELD_NOT_FOUND_REASON }
}
if (!entry.hasField(normalized)) {
return { ok: false, reason: FIELD_NOT_FOUND_REASON }
}
const close = trusted.close
const newline = sourceNewline(source)
const lastField = sourceFieldRanges[sourceFieldRanges.length - 1]
// Insert after the last value, or after the citation key if there are no
// fields. Both cases share the same trailing separator and comment rules.
let from = lastField?.valueTo
if (from === undefined) {
const header = bibEntryHeaderAt(source, 0)
const keyRange = header ? bibCitationKeyRange(source, header) : null
if (!keyRange) return { ok: false, reason: ENTRY_GONE_REASON }
from = keyRange.to
}
if (from > close) return { ok: false, reason: ENTRY_GONE_REASON }
const separator = fieldSeparatorAfter(source, from)
if (separator.after !== close) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
const tail = source.slice(from, close)
// Existing field values need a fresh comma if their tail contains only
// whitespace. Otherwise keep the original separator and comments verbatim.
const prefix = lastField && !tail.includes('%')
? ','
: `${separator.commaAt === null ? ',' : ''}${tail.replace(/[ \t\r\n]+$/, '')}`
const insert = `${prefix}${newline} ${normalized} = ${insertValue},${newline}`
return replacePlan(from, close, insert)
}
/**
* Plan a write of a single entry against the *current* source.
*
* mode 'existing': the extension supplies the current source start for the
* occurrence it resolved from its own CodeMirror state. Citation keys are not
* used for targeting because they are not unique.
*
* mode 'new': the entry is appended at the end of the source.
*
* Existing-entry ranges are clamped into [0, source.length].
*/
export function planBibWrite(
source: string,
entry: BibtexEntry,
mode: 'existing' | 'new',
entryStart?: number
): BibWriteGuard {
// This planner serializes an entire entry. Reject it while any field is
// structurally invalid; existing entries use planBibFieldWrite for field-local
// changes, allowing valid fields to save while another field remains invalid.
if (!hasSafeSerializedEntry(entry)) {
return { ok: false, reason: INVALID_FIELD_VALUE_REASON }
}
if (mode === 'existing') {
if (entryStart === undefined) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
const entries = parseBibFile(source)
// The source start is resolved by the CodeMirror extension from the current
// entry id, identifying the exact duplicate-key occurrence.
const own = findUniqueEntryByStart(entries, entryStart)
if (!own) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
const { from: entryFrom, to: entryTo } = own
const from = Math.max(0, Math.min(entryFrom, source.length))
const to = Math.max(0, Math.min(entryTo, source.length))
// Trust complete upstream entries; guard only ambiguous recovered ranges.
if (unsafeWholeEntryRange(source, own)) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
return replacePlan(from, to, serializeForSource(entry, sourceNewline(source)))
}
if (hasBlockingValidationErrors(validateEntry(entry))) {
return { ok: false, reason: INVALID_FIELD_VALUE_REASON }
}
// Append without rewriting existing entries.
const { insert, sourceStart } = appendBibEntries(source, [entry])
return {
ok: true,
plan: {
kind: 'append',
from: source.length,
to: source.length,
insert,
sourceStart,
},
}
}
export type BibBulkDeleteGuard =
| { ok: true; changes: { from: number; to: number; insert: string }[] }
| { ok: false; reason: string }
/**
* Plan a bulk delete against the current source. The operation is
* all-or-nothing: if any requested source occurrence no longer exists, none of
* the requested deletions is returned. Ranges are sorted so the caller can apply
* them as one CodeMirror change set.
*/
export function planBibBulkDelete(
source: string,
entryStarts: number[]
): BibBulkDeleteGuard {
const unique = [...new Set(entryStarts)]
if (unique.length === 0) {
// Nothing selected is a successful no-op; the caller can skip dispatching
// because there are no changes to apply.
return { ok: true, changes: [] }
}
const entries = parseBibFile(source)
const entryByStart = indexUniqueEntriesByStart(entries)
const ranges: { from: number; to: number }[] = []
for (const entryStart of unique) {
const match = entryByStart.get(entryStart)
if (!match) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
const { from: entryFrom, to: entryTo } = match
if (unsafeWholeEntryRange(source, match)) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
// Keep the bulk operation consistent with single-entry deletion: remove
// one separator line break, not an entire run of blank lines.
const end = consumeOneNewline(source, entryTo)
ranges.push({ from: entryFrom, to: end })
}
ranges.sort((a, b) => a.from - b.from)
// Parsed entries should never overlap. Treat overlap as a guard failure rather
// than risk producing an invalid CodeMirror change set.
for (let i = 1; i < ranges.length; i++) {
if (ranges[i].from < ranges[i - 1].to) {
return { ok: false, reason: ENTRY_GONE_REASON }
}
}
return {
ok: true,
changes: ranges.map(r => ({ from: r.from, to: r.to, insert: '' })),
}
}
export type BibImportInsert = {
from: number
to: number
insert: string
}
export type BibImportGuard =
| { ok: true; changes: BibImportInsert[] }
| { ok: false; reason: string }
const KEY_CONFLICT_REASON = 'key-conflict'
/**
* Plan a multi-entry import as one guarded append. Missing citation keys are
* rejected, as are keys with invalid characters. Duplicate keys are allowed
* both against the document and within the imported batch because entries are
* identified by source occurrence in this editor, not by key uniqueness.
*
* The resulting text uses the same newline rules as a normal new-entry append.
* A leading separator is added only when the current document does not already
* end in a newline.
*/
export function planBibImport(
source: string,
entries: BibtexEntry[]
): BibImportGuard {
// This planner is reached only from the .bib visual editor. An import is
// also valid when the bibliography is currently empty.
if (entries.length === 0) {
return { ok: true, changes: [] }
}
if (entries.some(entry => entry.key.trim() === '' || !isWritableCitationKey(entry.key))) {
return { ok: false, reason: KEY_CONFLICT_REASON }
}
if (entries.some(entry => !hasSafeSerializedEntry(entry))) {
return { ok: false, reason: INVALID_FIELD_VALUE_REASON }
}
const { insert } = appendBibEntries(source, entries)
return {
ok: true,
changes: [{ from: source.length, to: source.length, insert }],
}
}
@@ -0,0 +1,184 @@
/**
* Client-side DOI metadata fetcher.
*
* Strategy:
* 1. CrossRef API (api.crossref.org) — CORS-friendly, returns JSON we convert to BibTeX fields.
* 2. doi.org content-negotiation with Accept: application/x-bibtex — may fail on some publishers
* due to redirect CORS restrictions, but works for many.
*/
import { parseBibtex } from '@/features/bibtex/bibtex-parser'
import { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { BibtexFieldValue } from '@shared/bibtex/bibtex-field-value.mts'
import { normaliseDoi, isValidDoi, encodeDoiPath } from './bib-doi'
interface FetchedBibEntry {
entry: BibtexEntry
}
// CrossRef response conversion helpers.
interface CrossRefAuthor {
family?: string
given?: string
name?: string
}
export interface CrossRefMessage {
type?: string
title?: string[]
author?: CrossRefAuthor[]
issued?: { 'date-parts'?: number[][] }
'container-title'?: string[]
volume?: string
issue?: string
page?: string
DOI?: string
URL?: string
publisher?: string
}
function crossRefTypeToBibtex(crType?: string): string {
switch (crType) {
case 'book':
case 'monograph':
return 'book'
case 'book-chapter':
return 'inbook'
case 'proceedings-article':
return 'inproceedings'
case 'proceedings':
return 'proceedings'
case 'thesis':
return 'phdthesis'
case 'report':
return 'techreport'
case 'dataset':
return 'misc'
default:
return 'article'
}
}
export function crossRefMessageToEntry(msg: CrossRefMessage): FetchedBibEntry {
const type = crossRefTypeToBibtex(msg.type)
const fields = new Map<string, BibtexFieldValue>()
const setField = (name: string, value: string | undefined) => {
if (value) fields.set(name, BibtexFieldValue.fromEditableString(value))
}
setField('title', msg.title?.[0])
if (msg.author?.length) {
setField(
'author',
msg.author
.map(a => a.name
? `{${a.name}}`
: [a.family, a.given].filter(Boolean).join(', '))
.filter(Boolean)
.join(' and ')
)
}
const year = msg.issued?.['date-parts']?.[0]?.[0]
if (year) setField('year', String(year))
if (msg['container-title']?.[0]) {
if (type === 'inbook' || type === 'inproceedings' || type === 'proceedings') {
setField('booktitle', msg['container-title'][0])
} else {
setField('journal', msg['container-title'][0])
}
}
setField('volume', msg.volume)
if (msg.issue) setField('number', String(msg.issue))
setField('pages', msg.page)
setField('doi', msg.DOI)
setField('url', msg.URL)
setField('publisher', msg.publisher)
return { entry: new BibtexEntry({ type, key: '', fields }) }
}
// Public DOI fetch API.
const DOI_REQUEST_TIMEOUT_MS = 10_000
/** Bound each provider attempt, including response-body parsing. */
async function withDoiRequestTimeout<T>(
request: (signal: AbortSignal) => Promise<T>
): Promise<T> {
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
request(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort()
reject(new Error('doi_request_timeout'))
}, DOI_REQUEST_TIMEOUT_MS)
}),
])
} finally {
if (timeout !== undefined) clearTimeout(timeout)
}
}
/**
* Fetch bibliographic metadata for a DOI and return it as BibTeX fields.
* Throws an Error if the DOI is invalid or all sources fail.
*/
export async function fetchEntryFromDoi(raw: string): Promise<FetchedBibEntry> {
const doi = normaliseDoi(raw)
if (!isValidDoi(doi)) {
throw new Error('invalid_doi_format')
}
// First try CrossRef because its JSON response is straightforward to map
// into the BibTeX fields used by the form.
try {
const url = `https://api.crossref.org/works/${encodeDoiPath(doi)}`
const message = await withDoiRequestTimeout(async signal => {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
credentials: 'omit',
redirect: 'error',
signal,
})
if (!res.ok) return undefined
const json: { message?: CrossRefMessage } = await res.json()
return json?.message
})
if (message) return crossRefMessageToEntry(message)
} catch {
// Continue with the content-negotiation fallback below.
}
// Fall back to doi.org content negotiation when CrossRef cannot resolve the DOI.
try {
const url = `https://doi.org/${encodeDoiPath(doi)}`
const text = await withDoiRequestTimeout(async signal => {
const res = await fetch(url, {
headers: { Accept: 'application/x-bibtex, text/x-bibtex;q=0.9' },
credentials: 'omit',
// doi.org content negotiation legitimately redirects to publishers.
signal,
})
return res.ok ? res.text() : undefined
})
if (text?.trim().startsWith('@')) {
const parsed = parseBibtex(text).entries[0]
if (parsed) return { entry: parsed }
}
} catch {
// Both providers failed; report one useful error to the caller.
}
throw new Error(
'could_not_retrieve_doi_metadata'
)
}
@@ -0,0 +1,165 @@
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { BibtexFieldValue } from '@shared/bibtex/bibtex-field-value.mts'
import { BibtexName } from '@shared/bibtex/bibtex-name.mts'
import { isBracedAuthor, splitBibtexAnd } from './bib-author-names'
import type { BibEntryType } from '@shared/bibtex/bibtex-entry-schema.mts'
import { fieldLabel } from './bib-field-labels'
/** Labels and grouping for fields offered by the Add-field combobox. */
interface FieldTaxonomyGroup {
label: string
fields: { label: string; field: string }[]
}
/**
* Optional-field groups and their display order. Labels come from fieldLabel()
* so the form and the Add-field menu cannot disagree about a field's name.
*/
const OPTIONAL_FIELD_GROUPS: readonly { label: string; fields: readonly string[] }[] = [
{
label: 'Common',
fields: [
'abstract', 'subtitle', 'titleaddon', 'language', 'note', 'addendum',
'pubstate',
],
},
{
label: 'Contributors',
fields: [
'editor', 'editora', 'editorb', 'editorc', 'translator', 'annotator',
'commentator', 'introduction', 'foreword', 'afterword', 'bookauthor',
'holder',
],
},
{
label: 'Books and volumes',
fields: [
'maintitle', 'mainsubtitle', 'maintitleaddon', 'booktitle', 'booksubtitle',
'booktitleaddon', 'volume', 'volumes', 'part', 'edition', 'chapter',
'pages', 'pagetotal', 'eid',
],
},
{
label: 'Periodicals and journals',
fields: [
'journaltitle', 'journalsubtitle', 'journaltitleaddon', 'issuetitle',
'issuesubtitle', 'issuetitleaddon', 'issue',
],
},
{
label: 'Events and conferences',
fields: [
'eventtitle', 'eventtitleaddon', 'eventdate', 'venue',
],
},
{
label: 'Publication details',
fields: [
'publisher', 'location', 'organization', 'institution', 'series', 'number',
'type', 'version', 'month', 'isbn', 'issn', 'isrn', 'howpublished',
],
},
{
label: 'Digital and online',
fields: [
'doi', 'eprint', 'eprintclass', 'eprinttype', 'url', 'urldate',
],
},
{
label: 'Language and origin',
fields: [
'origlanguage',
],
},
]
export const OPTIONAL_FIELD_TAXONOMY: readonly FieldTaxonomyGroup[] =
OPTIONAL_FIELD_GROUPS.map(({ label, fields }) => ({
label,
fields: fields.map(field => ({ label: fieldLabel(field), field })),
}))
/** Field names represented by the shared optional-field taxonomy. */
export const OPTIONAL_FIELD_NAMES: ReadonlySet<string> = new Set(
OPTIONAL_FIELD_TAXONOMY.flatMap(group =>
group.fields.map(field => field.field)
)
)
/** Normalize required-field alternatives once for both labels and validation. */
export function requiredFieldGroups(
entryType: Pick<BibEntryType, 'requiredFields'>
): string[][] {
return entryType.requiredFields.map(field =>
Array.isArray(field) ? field : [field]
)
}
export function requiredFieldNames(
entryType: Pick<BibEntryType, 'requiredFields'>
): string[] {
return requiredFieldGroups(entryType).flat()
}
/**
* Prefer upstream name parsing for ordinary author/editor lists. Its display
* conversion removes braces before splitting on `and`, so a protected name
* needs the editor's brace-aware splitter instead.
*/
export function citationAuthorSurnames(entry: BibtexEntry): {
surnames: string[]
hasOthers: boolean
} {
const raw =
entry.getField('author').toEditableString().trim() ||
entry.getField('editor').toEditableString().trim()
if (!raw.includes('{') && !raw.includes('}')) {
const { names, hasOthers } = entry.getAuthors()
return { surnames: names.map(name => name.toLast()).filter(Boolean), hasOthers }
}
// Upstream getAuthors() cannot distinguish an internal protected `and`
// from an author separator after braces have been removed for display.
const authors = splitBibtexAnd(raw)
const hasOthers =
authors.length > 1 && authors[authors.length - 1].toLowerCase() === 'others'
const names = hasOthers ? authors.slice(0, -1) : authors
return {
surnames: names.map(name => {
const display = BibtexFieldValue.fromEditableString(name).toDisplayString()
return isBracedAuthor(name)
? display
: new BibtexName(display).toLast()
}).filter(Boolean),
hasOthers,
}
}
/** Build a compact citation heading from author/editor names and year. */
export function humanizeCitationHeading(entry: BibtexEntry): string {
const { surnames, hasOthers } = citationAuthorSurnames(entry)
const first = surnames[0] || ''
let who = entry.key.trim()
if (surnames.length === 1 && !hasOthers) {
who = first || who
} else if (surnames.length === 2 && !hasOthers) {
who = `${first} & ${surnames[1]}`
} else if (surnames.length > 0) {
who = `${first} et al.`
}
const year = entry.getYear().trim()
return year ? `${who} (${year})` : who
}
/** Offer all taxonomy fields except the entry type's regular and required fields. */
export function offeredOptionalFields(
entryType: Pick<BibEntryType, 'fields' | 'requiredFields'>
): string[] {
const excluded = new Set([
...entryType.fields.map(field => field.toLowerCase()),
...requiredFieldNames(entryType).map(field => field.toLowerCase()),
])
return [...OPTIONAL_FIELD_NAMES].filter(field => !excluded.has(field))
}
@@ -0,0 +1,20 @@
/**
* Manual pluralization helper.
*
* The CE core i18n runs i18next with `compatibilityJSON: 'v3'`
* (frontend/js/i18n.ts), which DISABLES the modern `_one`/`_other`
* suffix plural handling — a count-based lookup always returns
* base key. So we pick the singular/plural key explicitly and pass
* `count` for `__count__` interpolation (v3-style `__var__` works).
*/
type TFunc = (key: string, opts?: Record<string, unknown>) => string
export function plural(
t: TFunc,
count: number,
singularKey: string,
pluralKey: string,
options?: Record<string, unknown>
): string {
return t(count === 1 ? singularKey : pluralKey, { ...options, count })
}
@@ -0,0 +1,149 @@
/**
* Pure helpers for the entry-list/preview UI. Keeping these decisions outside
* the React components makes selection, navigation, search and filenames easy to test.
*
* The preview panel is a view over the current parsed `entries` list: no
* document state, just indices into it. These helpers keep the panel
* component thin and the edge cases (boundaries, empty list, bulk selection,
* required-missing labels, humanized summary, download filename) unit
* testable without a DOM.
*
* Field labels are shared with the form because the same names appear in both
* validation messages and the preview's missing-field summary.
*/
import { missingRequiredFieldGroups, type PositionedBibtexEntry } from './bib-types'
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
import { BibtexFieldValue } from '@shared/bibtex/bibtex-field-value.mts'
import { splitBibtexAnd } from './bib-author-names'
import { fieldLabel } from './bib-field-labels'
/**
* Produce a label for each missing required-field group. Members of an
* alternative group are joined with "or" (for example, "Year or Date"), while
* separate required groups remain comma-separated by the caller.
*/
export function missingRequiredLabels(
entry: BibtexEntry,
or: string
): string[] {
const groups = missingRequiredFieldGroups(entry)
const labels = groups.map(group =>
[...new Set(group)].map(field => fieldLabel(field)).join(` ${or} `)
)
return labels
}
/** Adjacent entry, or null at either end of the list. */
function adjacentEntry<T>(entries: readonly T[], index: number, direction: -1 | 1): T | null {
return entries[index + direction] ?? null
}
export function prevEntry<T>(entries: readonly T[], index: number): T | null {
return adjacentEntry(entries, index, -1)
}
export function nextEntry<T>(entries: readonly T[], index: number): T | null {
return adjacentEntry(entries, index, 1)
}
/** Keep only selected occurrence ids that are still present in the current projection. */
export function bulkDeleteIds(
entries: PositionedBibtexEntry[],
selectedIds: string[]
): string[] {
const present = new Set(entries.map(e => e.entry.id))
return selectedIds.filter(id => present.has(id))
}
/** Download filename for a single previewed entry. */
export function downloadBibEntryFilename(citationKey: string): string {
const name = citationKey.trim()
return `${name || 'entry'}.bib`
}
/** Download filename for a bulk selection. */
export function downloadBibBulkFilename(openDocName: string): string {
const name = (openDocName || '').trim()
return name.endsWith('.bib') ? 'selected_' + name : `selected_${name || 'bibliography'}.bib`
}
/**
* Build the text searched by the entry list. The search intentionally covers only
* the fields that are useful when scanning a bibliography row: citation key,
* title, author, editor, year, date, and venue.
* (journal/journaltitle/booktitle). Empty query matches everything.
*/
export function matchesSearch(positioned: PositionedBibtexEntry, query: string): boolean {
const q = query.trim().toLowerCase()
if (q === '') return true
const entry = positioned.entry
const field = (name: string) => entry.getField(name).toDisplayString()
const matchesNames = (name: 'author' | 'editor') =>
splitBibtexAnd(entry.getField(name).toEditableString()).some(author =>
BibtexFieldValue.fromEditableString(author)
.toDisplayString()
.toLowerCase()
.includes(q)
)
return [
entry.key,
field('title'),
field('year'),
field('date'),
field('journal'),
field('journaltitle'),
field('booktitle'),
].some(value => value.toLowerCase().includes(q)) ||
matchesNames('author') || matchesNames('editor')
}
/**
* Split text into alternating plain/match segments so the card can render
* matching pieces with <mark>. Matching is case-insensitive and literal.
* Non-query input → a single plain segment; empty text → no segments.
*/
export function splitHighlighted(
text: string,
query: string
): { text: string; match: boolean }[] {
const needle = query.trim().toLowerCase()
if (text === '') return []
if (needle === '') return [{ text, match: false }]
const lower = text.toLowerCase()
if (!lower.includes(needle)) return [{ text, match: false }]
// Map each lowercased UTF-16 offset back to its original character.
// Use whole-string lowercasing for matches: it is context-sensitive
// (e.g. Greek final sigma), unlike lowercasing characters separately.
const starts: number[] = []
const ends: number[] = []
let offset = 0
for (const character of text) {
const end = offset + character.length
for (let i = 0; i < character.toLowerCase().length; i++) {
starts.push(offset)
ends.push(end)
}
offset = end
}
const segments: { text: string; match: boolean }[] = []
let sourceEnd = 0
let searchFrom = 0
while (searchFrom < lower.length) {
const idx = lower.indexOf(needle, searchFrom)
if (idx === -1) break
const from = starts[idx]
const to = ends[idx + needle.length - 1]
searchFrom = idx + needle.length
// Two folded substrings can overlap the same original character.
if (from < sourceEnd) continue
if (from > sourceEnd) segments.push({ text: text.slice(sourceEnd, from), match: false })
segments.push({ text: text.slice(from, to), match: true })
sourceEnd = to
}
if (sourceEnd < text.length) {
segments.push({ text: text.slice(sourceEnd), match: false })
}
return segments
}
@@ -0,0 +1,837 @@
:root {
--author-btn-hover-bg: var(--neutral-70);
--bibtex-preview-input-border: var(--neutral-70);
--bibtex-card-hover-bg: var(--neutral-85);
--bibtex-card-selected-bg:var(--green-70);
--bibtex-card-selected-border:var(--green-60);
--bibtex-card-selected-hover-bg:var(--green-65);
--bibtex-card-secondary-text:var(--neutral-40);
--bibtex-card-previewing-bg:var(--green-70);
--bibtex-card-previewing-border:var(--green-60);
--bibtex-card-previewing-accent:var(--green-30);
--wide-target-checkbox-ring: 255 255 255;
}
[data-theme='light'] {
--author-btn-hover-bg: var(--green-10);
--bibtex-preview-input-border: var(--neutral-30);
--bibtex-card-hover-bg: var(--neutral-05);
--bibtex-card-selected-bg:var(--bg-accent-03);
--bibtex-card-selected-border:var(--green-20);
--bibtex-card-selected-hover-bg:var(--green-15);
--bibtex-card-secondary-text:var(--content-secondary);
--bibtex-card-previewing-bg:var(--bg-accent-03);
--bibtex-card-previewing-border:var(--green-20);
--bibtex-card-previewing-accent:var(--green-50);
--wide-target-checkbox-ring: 27 34 44;
}
.bibtex-entry-list-body {
position:relative;
}
.bibtex-entry-card-row:has(.bibtex-entry-card-previewing),
.bibtex-entry-card-row:has(.bibtex-entry-card-selected),
.bibtex-entry-card-row:hover {
z-index: 1;
}
.bibtex-entry-card-row:has(.dropdown-menu.show) {
z-index: 2;
}
.bibtex-entry-card {
background-color: var(--bg-primary-themed);
border: 1px solid var(--border-divider-themed);
border-radius: var(--border-radius-medium);
color: var(--content-secondary-themed);
display: flex;
gap: var(--spacing-05);
padding: var(--spacing-05);
position: relative;
}
.bibtex-entry-card:focus-visible {
box-shadow: 0 0 0 2px var(--border-active-dark);
outline: none;
}
.bibtex-entry-card:hover {
background-color: var(--bibtex-card-hover-bg);
}
.bibtex-entry-card.bibtex-entry-card-selected {
background-color: var(--bibtex-card-selected-bg);
border-color: var(--bibtex-card-selected-border);
}
.bibtex-entry-card.bibtex-entry-card-selected:hover {
background-color: var(--bibtex-card-selected-hover-bg);
}
.bibtex-entry-card.bibtex-entry-card-previewing {
background-color: var(--bibtex-card-previewing-bg);
border-color: var(--bibtex-card-previewing-border);
}
.bibtex-entry-card.bibtex-entry-card-previewing:before {
background-color: var(--bibtex-card-previewing-accent);
border-radius: var(--border-radius-medium) 0 0 var(--border-radius-medium);
bottom: 0;
content: "";
left: 0;
position: absolute;
top: 0;
width: 6px;
}
.bibtex-entry-card.bibtex-entry-card-clickable {
cursor: pointer;
}
.bibtex-entry-card-compact {
border: 0;
border-bottom: 1px solid var(--border-divider-themed);
border-radius: 0;
gap: var(--spacing-04);
padding: var(--spacing-04);
}
.bibtex-entry-card-compact.bibtex-entry-card-previewing:before {
border-radius: 0;
}
.bibtex-entry-card-compact .bibtex-entry-card-content {
gap: var(--spacing-01);
justify-content: center;
}
.bibtex-entry-card-compact .bibtex-entry-card-checkbox {
padding-left: var(--spacing-03);
padding-right: var(--spacing-03);
}
.bibtex-entry-card-compact .bibtex-entry-card-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.bibtex-entry-card-checkbox {
padding: var(--spacing-02);
}
.bibtex-entry-card-content {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-02);
min-width: 0;
}
.bibtex-entry-card-header {
align-items: center;
display: flex;
gap: var(--spacing-04);
padding-bottom: var(--spacing-02);
}
.bibtex-entry-card-key {
background-color: var(--bg-tertiary-themed);
border-radius: var(--border-radius-base);
color: var(--content-primary-themed);
font-family: DM Mono,monospace;
font-feature-settings: "ss05";
font-size: var(--font-size-02);
line-height: var(--line-height-02);
min-width: 0;
overflow-wrap: break-word;
padding: var(--spacing-01) var(--spacing-02);
}
.bibtex-entry-card-key mark {
padding: 0;
}
.bibtex-entry-card-details {
display: flex;
flex-direction: column;
font-size: var(--font-size-02);
gap: var(--spacing-02);
line-height: var(--line-height-02);
min-width: 0;
overflow-wrap: break-word;
}
.bibtex-entry-card-details mark {
padding: 0;
}
.bibtex-entry-card-meta {
display: flex;
gap: var(--spacing-02) var(--spacing-06);
min-width: 0;
}
.bibtex-entry-card-title {
color: var(--content-primary-themed);
font-weight: 600;
}
.bibtex-entry-card-author,
.bibtex-entry-card-year {
color: var(--bibtex-card-secondary-text);
}
.bibtex-entry-card-author {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bibtex-entry-card-year {
flex-shrink: 0;
}
.bibtex-entry-error-icon {
align-items: center;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: transparent;
border: 0;
color: var(--red-50);
cursor: pointer;
display: inline-flex;
height: var(--line-height-03);
padding: 0;
}
.bibtex-entry-error-icon-warning {
color: var(--content-warning-themed);
}
.bibtex-tooltip-errors {
display: flex;
flex-direction: column;
gap: var(--spacing-04);
text-align: left;
}
.bibtex-tooltip-errors ul {
margin-bottom: 0;
}
.bibtex-tooltip-errors blockquote {
margin-left: var(--spacing-06);
white-space: pre-wrap;
}
.wide-target-checkbox {
align-items: flex-start;
display: flex;
flex-shrink: 0;
}
.wide-target-checkbox-box {
display: flex;
position: relative;
}
.wide-target-checkbox-box .form-checkbox {
display: flex;
margin: 0;
min-height: auto;
padding-left: 0;
}
.wide-target-checkbox-box .form-check-input {
cursor: pointer;
float: none;
margin: 0;
position: relative;
z-index: 1;
}
.wide-target-checkbox-box:hover .wide-target-checkbox-target {
background-color: rgb(var(--wide-target-checkbox-ring)/8%);
}
.wide-target-checkbox-box:has(.form-check-input:disabled) .wide-target-checkbox-target {
cursor: default;
}
.wide-target-checkbox-box:has(.form-check-input:disabled):hover .wide-target-checkbox-target {
background-color: transparent;
}
.wide-target-checkbox-target {
border-radius: 50%;
cursor: pointer;
height: 40px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
width: 40px;
}
.button-autocomplete-container {
background-color: var(--bg-primary-themed);
border: 1px solid var(--border-divider-themed);
left: 0;
margin-top: var(--spacing-02);
top: 100%;
z-index: 1000;
}
.button-autocomplete-container:not(.expand-up) .form-control {
margin-bottom: var(--spacing-03);
}
.button-autocomplete-container.expand-up {
bottom: 100%;
margin-bottom: var(--spacing-02);
margin-top: 0;
top: auto;
}
.button-autocomplete-container .dropdown-item {
border-radius: var(--border-radius-base);
padding: var(--spacing-02) var(--spacing-04);
}
.button-autocomplete-container .dropdown-item:focus,
.button-autocomplete-container .dropdown-item:hover {
background-color: var(--bg-secondary-themed);
}
.button-autocomplete-container .dropdown-menu {
border: none;
box-shadow: none;
margin: 0;
max-height: 290px;
overflow-y: auto;
padding: 0;
}
.bibtex-collapsible-heading {
align-items: center;
background: transparent;
border: none;
cursor: pointer;
display: flex;
gap: var(--spacing-02);
margin-bottom: var(--spacing-04);
padding: var(--spacing-02) 0;
text-decoration: none;
width: auto;
}
.bibtex-collapsible-heading:hover {
opacity: 0.8;
}
.bibtex-collapsible-heading:focus {
outline: 2px solid var(--border-active-themed);
outline-offset: 2px;
}
.bibtex-collapsible-heading-icon {
align-items: center;
color: var(--content-secondary-themed);
display: inline-flex;
flex-shrink: 0;
margin-left: var(--spacing-01);
}
.bibtex-entry-form .form-label,
.bibtex-entry-form .form-text {
color: var(--content-secondary-themed);
}
.bibtex-entry-form .form-control[readonly],
.bibtex-entry-form .form-control[readonly]:focus,
.bibtex-entry-form .form-control[readonly]:focus-visible {
background-color: var(--bg-disabled-themed);
border-color: var(--border-disabled-themed);
}
.bibtex-entry-form .form-control,
.bibtex-entry-form textarea.form-control,
.bibtex-entry-form input.form-control {
border-color: var(--bibtex-preview-input-border);
background-color: var(--bg-primary-themed);
color: var(--color-primary-themed);
&.is-invalid:focus-visible {
background: var(--bg-primary-themed);
color: var(--color-primary-themed);
}
}
.bibtex-form-required {
color: var(--content-danger);
}
.bibtex-entry-preview-panel {
background-color: var(--bg-primary-themed);
color: var(--content-primary-themed);
display: flex;
flex-direction: column;
max-width: 100%;
transform: translateX(100%);
transition: transform .3s ease;
z-index: 20;
min-height: 0;
}
.bibtex-entry-preview-panel.bibtex-entry-preview-panel-open {
transform: none;
}
.bibtex-entry-preview-header {
display: flex;
justify-content: space-between;
padding: var(--spacing-05);
}
.bibtex-entry-preview-header-nav {
display: flex;
}
.bibtex-entry-preview-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.bibtex-entry-preview-summary {
align-items: flex-start;
display: flex;
gap: var(--spacing-05);
padding: 0 var(--spacing-08) var(--spacing-04);
}
:root .bibtex-entry-preview-header .btn.btn-ghost,
:root .bibtex-entry-preview-summary .btn.btn-ghost {
--btn-ghost-background: var(--bg-primary-themed);
--btn-ghost-hover-background: var(--bg-secondary-themed);
--bs-btn-disabled-color: var(--content-disabled-themed);
--bs-btn-disabled-bg: var(--bg-disabled-themed);
}
.bibtex-entry-preview-summary-actions {
margin-right: calc(var(--spacing-05) - var(--spacing-08));
}
.bibtex-entry-preview-summary-actions .dropdown-toggle:after {
content: none !important;
}
.bibtex-entry-preview-summary-content {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-02);
min-width: 0;
}
.bibtex-entry-preview-summary-key-row {
align-items: center;
display: flex;
gap: var(--spacing-04);
justify-content: space-between;
padding-right: var(--spacing-04);
}
.bibtex-entry-preview-summary-key {
background-color: var(--bg-tertiary-themed);
border-radius: var(--border-radius-base);
color: var(--content-primary-themed);
font-family: DM Mono,monospace;
font-feature-settings: "ss05";
font-size: var(--font-size-02);
line-height: var(--line-height-02);
padding: var(--spacing-01) var(--spacing-02);
}
.bibtex-entry-preview-summary-title {
color: var(--content-primary-themed);
font-weight: 600;
}
.bibtex-entry-preview-summary-meta {
color: var(--content-secondary-themed);
display: flex;
font-size: var(--font-size-02);
gap: var(--spacing-06);
line-height: var(--line-height-02);
}
.bibtex-entry-preview-tabs {
background-color: var(--bg-primary-themed);
border-bottom: 1px solid var(--border-divider-themed);
display: flex;
gap: var(--spacing-04);
margin-bottom: var(--spacing-06);
position: sticky;
top: 0;
z-index: 1;
}
.bibtex-entry-preview-tab {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: transparent;
border: 0;
border-bottom: 3px solid transparent;
color: var(--content-secondary-themed);
cursor: pointer;
font-size: var(--font-size-02);
line-height: var(--line-height-02);
padding: var(--spacing-04);
}
.bibtex-entry-preview-tab.bibtex-entry-preview-tab-active {
border-bottom-color: var(--green-50);
color: var(--content-primary-themed);
}
.bibtex-entry-preview-tab:focus-visible {
box-shadow: 0 0 0 2px var(--border-active-dark);
outline: none;
}
.bibtex-entry-preview-tab:disabled {
cursor: default;
opacity: 0.5;
}
.bibtex-entry-preview-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 var(--spacing-08) var(--spacing-08);
}
.bibtex-entry-preview-body .form-group {
scroll-margin-top: 4rem;
}
.bibtex-entry-preview-body .form-label,
.bibtex-entry-preview-body .form-text {
color: var(--content-secondary-themed);
}
.bibtex-entry-preview-body-abstract {
display: flex;
flex-direction: column;
}
.bibtex-abstract-form-group,
.bibtex-entry-preview-body-abstract #bibtex-entry-form,
.bibtex-entry-preview-body-abstract .bibtex-entry-preview-fields,
.bibtex-entry-preview-body-abstract #bibtex-entry-preview-panel-abstract {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.bibtex-abstract-textarea {
flex: 1;
min-height: 0;
resize: none;
}
.bibtex-import-textarea {
font-family: DM Mono,monospace;
font-feature-settings: "ss05";
}
.bibtex-import-form .form-label,
.bibtex-import-form .form-text {
color: var(--content-secondary-themed);
}
.bibtex-import-preview-card-check {
padding-top: var(--spacing-05);
}
.bibtex-import-preview-card {
border: 1px solid var(--border-divider-themed);
border-radius: var(--border-radius-large);
display: flex;
gap: var(--spacing-06);
padding: var(--spacing-04) var(--spacing-06);
}
.bibtex-import-preview-card:hover {
background-color: var(--bg-secondary-themed);
}
.bibtex-import-preview-card-content {
display: flex;
flex: 1;
flex-direction: column;
}
.bibtex-import-preview-card-details {
display: flex;
flex-direction: column;
gap: var(--spacing-03);
padding-top: var(--spacing-04);
}
.bibtex-import-preview-card-heading {
font-weight: 600;
}
.bibtex-import-preview-card-field {
color: var(--bibtex-card-secondary-text);
}
.bibtex-import-preview-card-error {
font-size: var(--font-size-01);
color: var(--content-danger-themed);
font-style: italic;
}
.bibtex-import-preview-card-key {
background-color: var(--bg-tertiary-themed);
border-radius: var(--border-radius-base);
color: var(--content-primary-themed);
font-family: DM Mono,monospace;
font-feature-settings: "ss05";
font-size: var(--spacing-05);
font-weight: 500;
margin-top: var(--spacing-04);
padding: var(--spacing-01) var(--spacing-02);
width: -moz-fit-content;
width: fit-content;
}
.bibtex-import-preview-count {
font-weight: 600;
margin-bottom: var(--spacing-04);
padding: var(--spacing-04) 0;
}
.bibtex-import-preview-list {
display: flex;
flex-direction: column;
gap: var(--spacing-04);
}
.bibtex-import-preview-check-all {
padding: var(--spacing-05) var(--spacing-06) var(--spacing-04);
}
.bibtex-import-preview-footer {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
}
.bibtex-import-preview-footer-buttons {
display: flex;
gap: var(--spacing-04);
}
.bibtex-import-preview-footer-count {
font-weight: 600;
}
.bibtex-import-preview-footer-warning {
align-items: center;
color: var(--content-danger-themed);
display: flex;
justify-content: flex-start;
padding-bottom: var(--spacing-04);
}
.bibtex-import-preview-footer-warning span {
margin-bottom: var(--spacing-02);
margin-left: var(--spacing-02);
}
.bibtex-import-preview-footer-actions {
display: flex;
justify-content: space-between;
width: 100%;
}
.bibtex-import-preview-empty {
color: var(--content-secondary-themed);
padding: var(--spacing-10) var(--spacing-09);
text-align: center;
}
.bibtex-import-preview-header {
display: flex;
}
.bibtex-add-button {
margin-left: auto;
}
.bibtex-bulk-actions-bar {
align-items: center;
background-color: var(--bg-secondary-themed);
border: 2px solid transparent;
border-bottom-color: var(--border-divider-themed);
border-radius: 0;
box-sizing: border-box;
display: flex;
margin-bottom: var(--spacing-05);
margin-top: var(--spacing-05);
padding: var(--spacing-04) var(--spacing-05);
position: sticky;
top: 0;
z-index: 3;
}
.bibtex-bulk-actions-bar.bibtex-bulk-actions-bar-selected {
background-color: var(--bibtex-card-selected-bg);
border-color: var(--bibtex-card-selected-border);
border-radius: var(--border-radius-medium);
}
.bibtex-bulk-actions-bar .btn-ghost {
--bs-btn-bg: transparent;
}
.bibtex-bulk-actions-select-all {
margin-right: var(--spacing-05);
padding: var(--spacing-02);
}
.bibtex-bulk-actions-count {
color: var(--content-primary-themed);
font-family: var(--font-sans);
font-size: var(--font-size-02);
font-style: normal;
font-weight: 600;
line-height: var(--line-height-05);
margin-right: var(--spacing-04);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bibtex-bulk-actions-close {
margin-left: auto;
}
.bibtex-toolbar {
align-items: center;
box-shadow: inset 0 -1px 0 var(--border-divider-themed);
display: flex;
height: 32px;
padding: 0 5px;
}
.bibtex-visual-editor {
background-color: var(--bg-primary-themed);
display: flex;
flex-direction: column;
font-size: 14px;
inset: 0;
position: absolute;
}
.bibtex-visual-editor .bibtex-list-and-preview {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
position: relative;
}
.bibtex-visual-editor .bibtex-scroll-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.bibtex-visual-editor .bibtex-entry-list {
flex-grow: 1;
overflow: hidden auto;
}
.bibtex-visual-editor .bibtex-search {
width: 320px;
}
.bibtex-visual-editor .bibtex-bulk-actions-bar.bibtex-bulk-actions-bar-selected {
border-left-color: transparent;
border-radius: 0;
border-right-color: transparent;
}
.bibtex-visual-editor .bibtex-bulk-actions-bar {
border-width: 1px;
border-left: none;
border-right: none;
margin-bottom: 0;
margin-top: 0;
padding-left: var(--spacing-04);
padding-right: var(--spacing-04);
}
.bibtex-visual-editor .bibtex-bulk-actions-bar .bibtex-bulk-actions-select-all {
padding-left: var(--spacing-03);
padding-right: var(--spacing-03);
}
.bibtex-visual-editor .bibtex-bulk-actions-bar:not(.bibtex-bulk-actions-bar-selected) {
background-color: var(--bg-primary-themed);
border-bottom-color: var(--border-divider-themed);
border-top-color: var(--border-divider-themed);
}
.bibtex-entry-list-panel {
display: flex;
align-items: center;
gap: var(--spacing-04);
padding:var(--spacing-04) var(--spacing-04) var(--spacing-05);
}
.bibtex-entry-syntax-disabled {
opacity: .40;
}
.bibtex-entry-syntax-disabled input.form-control:disabled,
.bibtex-entry-syntax-disabled textarea.form-control:disabled,
.bibtex-entry-syntax-disabled .btn:disabled {
color: var(--content-primary-themed);
background-color: var(--bg-primary-themed);
}
.bibtex-field-with-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0rem 1rem;
.bibtex-form-input {
grid-column: 1;
grid-row: 1;
}
.bibtex-form-actions {
grid-column: 2;
grid-row: 1;
.btn {
padding: 0.4em 1em;
align-self: flex-end;
}
}
> .invalid-feedback {
grid-column: 1;
grid-row: 2;
}
}
.bibtex-form-names {
display: flex;
flex-direction: column;
gap: 0.6rem;
.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) {
grid-template-columns: minmax(0, 1fr);
> .bibtex-form-actions {
display: none;
}
}
.bibtex-form-names .bibtex-field-with-actions .bibtex-form-actions .btn,
.bibtex-optional-field-row .bibtex-form-actions .btn {
background: transparent;
padding: 0;
.icon-small {
font-size: 18px;
}
&:hover {
background-color: var(--author-btn-hover-bg);
}
}
.bibtex-optional-field-row .bibtex-abstract-textarea {
height: auto !important;
min-height: calc(4.5em + 0.4rem + 2px) !important;
}
.bibtex-editor-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: 24px;
color: var(--content-secondary-themed);
}
.bibtex-list-empty {
text-align: center;
padding: 3rem;
color: var(--content-secondary-themed);
font-size: var(--font-size-02);
}
.bibtex-write-failure-banner {
align-items: center;
display: flex;
color: var(--content-danger-themed);
background: var(--bg-tertiary-themed);
padding: 0.4rem 0.8rem;
margin-bottom: 0.5rem;
font-size: var(--font-size-02);
}
.bibtex-write-failure-banner-text {
flex: 1 1 auto;
}
.bibtex-vertical-resize-controls {
position: relative;
flex: 0 0 6px;
}
.bibtex-vertical-resize-controls .custom-toggler-vertical {
width: 50px !important;
height: 6px;
top: 0;
left: 50%;
margin: 0;
padding: 0;
border-width: 0;
transform: translateX(-50%);
z-index: 12;
cursor: pointer !important;
}
.bibtex-vertical-resize-controls .custom-toggler-vertical::before {
inset: -3px 0;
}
/* Viewer controls are blocked by the fieldset, but values keep normal colors. */
.bibtex-entry-read-only .form-control:disabled {
color: var(--color-primary-themed);
-webkit-text-fill-color: var(--color-primary-themed);
background-color: var(--bg-primary-themed);
border-color: var(--bibtex-preview-input-border);
opacity: 1;
}
@@ -0,0 +1,70 @@
/* Keep the works heading visible while only the results area scrolls. */
.modal-dialog-scrollable .orcid-picker-works-body.modal-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.orcid-picker-works-summary {
flex: none;
padding-bottom: var(--spacing-04);
}
.orcid-picker-works-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
/* Author search cards are buttons; keep the preview-card appearance. */
.orcid-picker-author-card {
width: 100%;
text-align: left;
color: inherit;
font: inherit;
background: transparent;
cursor: pointer;
}
.orcid-picker-author-card:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* One child in OLButton's flex layout; the spinner overlays the label's
grid cell, so it cannot add a second flex item or change the button width. */
.orcid-picker-import-content {
display: inline-grid;
place-items: center;
}
.orcid-picker-import-content > * {
grid-area: 1 / 1;
}
/* Keep Close at the right edge, including after all works are imported. */
.orcid-picker-footer-buttons {
margin-left: auto;
}
.orcid-picker-enrichment-titles {
margin: var(--spacing-02) 0;
padding: var(--spacing-06);
max-height: 12rem;
overflow-y: auto;
}
.orcid-picker-enrichment-warning {
margin-top: var(--spacing-03);
}
.orcid-picker-enrichment-danger {
color: var(--content-danger-themed);
}
/* Keep the final import report comfortably spaced. */
.orcid-picker-import-summary {
padding: var(--spacing-04) 0;
}
@@ -0,0 +1,5 @@
import logger from '@overleaf/logger'
logger.debug({}, 'Enable visual bib-editor')
const BibEditorModule = {}
export default BibEditorModule