Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion services/web/config/settings.defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,12 @@ module.exports = {
ssoCertificateInfo: [],
v1ImportDataScreen: [],
snapshotUtils: [],
visualEditorProviders: [],
visualEditorProviders: [
Path.resolve(
__dirname,
'../modules/bibtex-editor/frontend/js/bibtex-visual-editor-provider'
),
],
usGovBanner: [],
rollingBuildsUpdatedAlert: [],
offlineModeToolbarButtons: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useCallback } from 'react'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { BibtexEditor } from './components/BibtexEditor'
import BibtexEditorSwitch from './components/BibtexEditorSwitch'

// Bridges the open .bib document to the visual editor. Core renders this in
// place of CodeMirror (which stays mounted but hidden) when the Visual toggle
// is on, so we read the text from the CodeMirror doc and write edits back with
// a transaction — changes then flow through the normal OT / sync path.
function BibtexVisualEditor() {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const { openDocName } = useEditorOpenDocContext()
const permissions = usePermissionsContext()
const canEdit = permissions.write || permissions.trackedWrite

const value = state.doc.toString()

const onChange = useCallback(
(next: string) => {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: next },
})
},
[view]
)

return (
<BibtexEditor
value={value}
onChange={onChange}
readOnly={!canEdit}
fileName={openDocName ?? undefined}
toolbar={<BibtexEditorSwitch />}
/>
)
}

// Registered via overleafModuleImports.visualEditorProviders. The core
// source-editor (utils/visual-editor.ts) queries these named exports to light
// up the Code/Visual toggle for .bib files and to render our component.
export const id = 'bibtex'

export function isVisualEditorAvailable(filename: string): boolean {
return /\.bib$/i.test(filename)
}

export function getVisualEditorComponent(filename: string) {
return isVisualEditorAvailable(filename) ? BibtexVisualEditor : null
}
173 changes: 173 additions & 0 deletions services/web/modules/bibtex-editor/frontend/js/bibtex/author-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* BibTeX author/editor name parsing.
*
* `AuthorList` splits an `A and B and C` field into `Name`s (handling a trailing
* "others" → "et al."); each `Name` parses the "von Last, Jr, First" /
* "First von Last" forms. Input is the rendered display string (outer braces
* stripped, whitespace collapsed).
*
* Pure, dependency-free.
*/

/** Split "von Last" into a von run + last name. */
function splitVonLast(source: string): { von: string; last: string } {
const words = source.split(/\s+/).filter(Boolean)
if (words.length === 0) return { von: '', last: '' }
if (words.length === 1) return { von: '', last: words[0] }
const last = words[words.length - 1]
const middle = words.slice(0, -1)
// index of the LAST lowercase-initial word in the middle run
let lastLower = -1
for (let i = middle.length - 1; i >= 0; i--) {
if (/^[a-z]/.test(middle[i])) {
lastLower = i
break
}
}
if (lastLower === -1) {
return { von: '', last: words.join(' ') }
}
return {
von: middle.slice(0, lastLower + 1).join(' '),
last: [...middle.slice(lastLower + 1), last].join(' '),
}
}

/** One parsed name (von / last / first / suffix). */
export class Name {
first = ''
von = ''
last = ''
suffix = ''

constructor(source: string) {
const parts = source
.trim()
.split(',')
.map(part => part.trim())

if (parts.length >= 3) {
// "von Last, Jr, First"
const { von, last } = splitVonLast(parts[0])
this.von = von
this.last = last
this.suffix = parts[1]
this.first = parts.slice(2).join(' ').trim()
} else if (parts.length === 2) {
// "von Last, First"
const { von, last } = splitVonLast(parts[0])
this.von = von
this.last = last
this.first = parts[1]
} else {
// "First von Last" (no comma)
const { first, von, last } = this.splitFirstVonLast(source.trim())
this.first = first
this.von = von
this.last = last
}
}

private splitFirstVonLast(source: string) {
const words = source.split(/\s+/).filter(Boolean)
if (words.length === 0) return { first: '', von: '', last: '' }
if (words.length === 1) return { first: '', von: '', last: words[0] }
const last = words[words.length - 1]
const middle = words.slice(0, -1)
// von = span from the first lowercase-initial middle word to the last one
let firstLower = -1
let lastLower = -1
for (let i = 0; i < middle.length; i++) {
if (/^[a-z]/.test(middle[i])) {
if (firstLower === -1) firstLower = i
lastLower = i
}
}
if (lastLower === -1) {
return { first: middle.join(' '), von: '', last }
}
return {
first: middle.slice(0, firstLower).join(' '),
von: middle.slice(firstLower, lastLower + 1).join(' '),
last: [...middle.slice(lastLower + 1), last].join(' '),
}
}

toFirstLast(): string {
return [this.first, this.von, this.last, this.suffix].filter(Boolean).join(' ')
}

toLast(): string {
return [this.von, this.last].filter(Boolean).join(' ')
}

toLastFirst(): string {
const base = [this.von, this.last].filter(Boolean).join(' ')
return this.first ? `${base}, ${this.first}` : base
}
}

type NameFormatter = (name: Name) => string

/** A parsed `A and B and C` author list. */
export class AuthorList {
readonly names: Name[]
readonly hasOthers: boolean

constructor(source: string) {
if (!source.trim()) {
this.names = []
this.hasOthers = false
return
}
const parts = source
.split(/\s+and\s+/i)
.map(part => part.trim())
.filter(Boolean)
if (parts.length > 1 && parts[parts.length - 1].toLowerCase() === 'others') {
this.hasOthers = true
this.names = parts.slice(0, -1).map(part => new Name(part))
} else {
this.hasOthers = false
this.names = parts.map(part => new Name(part))
}
}

/** Oxford-conjunction display, e.g. "A, B, and C". */
join(format: NameFormatter = name => name.toFirstLast()): string {
const { names, hasOthers } = this
if (names.length === 0) return ''
const formatted = names.map(format)
if (hasOthers) {
return formatted.length === 1
? `${formatted[0]} et al.`
: `${formatted.join(', ')}, et al.`
}
if (formatted.length === 1) return formatted[0]
if (formatted.length === 2) return `${formatted[0]} and ${formatted[1]}`
return `${formatted.slice(0, -1).join(', ')}, and ${formatted[formatted.length - 1]}`
}

/** Compact summary, e.g. "A et al." / "A & B". */
summarize(): string {
const { names, hasOthers } = this
if (names.length === 0) return ''
const lastNames = names.map(name => name.toLast())
if (hasOthers || lastNames.length > 2) {
return `${lastNames[0]} et al.`
}
return lastNames.join(' & ')
}
}

// ---- back-compat helpers (used by other module files / index.ts) ----

/** List of "First von Last" display names. */
export function authorDisplayList(raw: string): string[] {
return new AuthorList(raw).names.map(name => name.toFirstLast())
}

/** Compact summary string. */
export function summarizeAuthors(raw: string): string {
return new AuthorList(raw).summarize()
}
132 changes: 132 additions & 0 deletions services/web/modules/bibtex-editor/frontend/js/bibtex/bibtex-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Immutable BibTeX entry model.
*
* Mirrors the shape used by Overleaf's visual editor: an entry has a type,
* a citation key and an ordered map of fields. Accessors derive the columns
* shown in the table (Citation key / Title / Author / Year).
*
* Pure, dependency-free.
*/
import { AuthorList } from './author-names'

/** One field's value: a resolved display string plus its raw bib source. */
export interface FieldValue {
/** cleaned, macro-resolved text for display */
display: string
/** exact source between `=` and the field terminator (kept so edits round-trip) */
raw: string
}

export const EMPTY_FIELD: FieldValue = { display: '', raw: '' }

export interface EntryRange {
/** char offset of the `@` that starts the entry, in the source document */
from: number
/** char offset just after the entry's closing brace */
to: number
}

export interface BibEntryInit {
type?: string
key?: string
fields?: Map<string, FieldValue>
range?: EntryRange
id?: string
}

let AUTO_ID = 0

export class BibEntry {
readonly type: string
readonly key: string
readonly fields: Map<string, FieldValue>
readonly range?: EntryRange
/** stable id for React keys / selection (falls back to citation key) */
readonly id: string

constructor(init: BibEntryInit = {}) {
this.type = (init.type ?? 'article').toLowerCase()
this.key = init.key ?? ''
this.fields = init.fields ?? new Map()
this.range = init.range
// The id defaults to the citation key so it stays STABLE across re-parses
// (selection, React keys and save-time lookups survive commits). Auto-id is
// only used for keyless entries.
this.id = init.id || this.key || `entry-${AUTO_ID++}`
}

getField(name: string): FieldValue {
return this.fields.get(name.toLowerCase()) ?? EMPTY_FIELD
}

hasField(name: string): boolean {
return this.fields.has(name.toLowerCase())
}

getFieldNames(): string[] {
return Array.from(this.fields.keys())
}

/**
* Author list (falls back to `editor`). Returns an AuthorList whose `.join()`
* produces the "A, B, and C" display and `.summarize()` the compact form.
*/
getAuthors(): AuthorList {
const raw =
this.getField('author').display || this.getField('editor').display
return new AuthorList(raw)
}

/** Title column: falls back to `subtitle`. */
getTitle(): string {
return this.getField('title').display || this.getField('subtitle').display
}

/** Year column: `year`, else the year that STARTS the `date` field. */
getYear(): string {
const y = this.getField('year').display
if (y) return y
const m = this.getField('date').display.match(/^\d{4}/)
return m ? m[0] : ''
}

// ----- immutable updates (return a new entry, preserve id) -----

private clone(patch: Partial<BibEntryInit>): BibEntry {
return new BibEntry({
type: patch.type ?? this.type,
key: patch.key ?? this.key,
fields: patch.fields ?? new Map(this.fields),
range: 'range' in patch ? patch.range : this.range,
id: this.id,
})
}

setType(type: string): BibEntry {
return this.clone({ type })
}

setKey(key: string): BibEntry {
return this.clone({ key })
}

/** Set/replace a field from a plain display string (raw becomes `{value}`). */
setField(name: string, display: string): BibEntry {
const fields = new Map(this.fields)
fields.set(name.toLowerCase(), { display, raw: `{${display}}` })
return this.clone({ fields })
}

/** Set/replace a field keeping an explicit FieldValue (preserves raw source). */
setFieldValue(name: string, value: FieldValue): BibEntry {
const fields = new Map(this.fields)
fields.set(name.toLowerCase(), value)
return this.clone({ fields })
}

removeField(name: string): BibEntry {
const fields = new Map(this.fields)
fields.delete(name.toLowerCase())
return this.clone({ fields })
}
}
Loading
Loading