mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Bib Editor: the initial commit
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user