@uniweb/scholar 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,405 +0,0 @@
1
- /**
2
- * BibTeX Parser
3
- *
4
- * Parses BibTeX format into normalized publication objects and exports
5
- * publication objects back to BibTeX format.
6
- *
7
- * @module @uniweb/scholar/bibliography/parsers/bibtex
8
- */
9
-
10
- import { parseAuthorString } from '../utils/authors.js'
11
-
12
- /**
13
- * Parse a BibTeX field value, handling braces and quotes
14
- *
15
- * @param {string} value - Raw field value
16
- * @returns {string} Cleaned value
17
- */
18
- function parseFieldValue(value) {
19
- if (!value) return ''
20
-
21
- let cleaned = value.trim()
22
-
23
- // Remove outer braces or quotes
24
- if (
25
- (cleaned.startsWith('{') && cleaned.endsWith('}')) ||
26
- (cleaned.startsWith('"') && cleaned.endsWith('"'))
27
- ) {
28
- cleaned = cleaned.slice(1, -1)
29
- }
30
-
31
- // Remove nested braces (used for preserving case)
32
- cleaned = cleaned.replace(/\{([^{}]*)\}/g, '$1')
33
-
34
- // Handle common LaTeX commands
35
- cleaned = cleaned
36
- .replace(/\\&/g, '&')
37
- .replace(/\\\$/g, '$')
38
- .replace(/\\%/g, '%')
39
- .replace(/\\_/g, '_')
40
- .replace(/\\#/g, '#')
41
- .replace(/~/g, ' ')
42
- .replace(/--/g, '–')
43
- .replace(/``/g, '"')
44
- .replace(/''/g, '"')
45
-
46
- return cleaned.trim()
47
- }
48
-
49
- /**
50
- * Parse a single BibTeX entry
51
- *
52
- * @param {string} entryStr - BibTeX entry string
53
- * @returns {Object|null} Parsed publication object
54
- */
55
- function parseEntry(entryStr) {
56
- // Match @type{key, ...}
57
- const headerMatch = entryStr.match(/@(\w+)\s*\{\s*([^,\s]*)\s*,/)
58
- if (!headerMatch) return null
59
-
60
- const entryType = headerMatch[1].toLowerCase()
61
- const citationKey = headerMatch[2]
62
-
63
- // Extract fields
64
- const fieldsStr = entryStr.slice(headerMatch[0].length)
65
- const fields = {}
66
-
67
- // Parse fields using a state machine approach
68
- let currentField = ''
69
- let currentValue = ''
70
- let braceDepth = 0
71
- let inQuotes = false
72
- let inField = false
73
-
74
- for (let i = 0; i < fieldsStr.length; i++) {
75
- const char = fieldsStr[i]
76
- const prevChar = i > 0 ? fieldsStr[i - 1] : ''
77
-
78
- if (!inField) {
79
- // Looking for field name
80
- if (char === '=') {
81
- currentField = currentValue.trim().toLowerCase()
82
- currentValue = ''
83
- inField = true
84
- } else if (char !== ',' && char !== '}') {
85
- currentValue += char
86
- }
87
- } else {
88
- // Parsing field value
89
- if (char === '{' && !inQuotes) {
90
- braceDepth++
91
- currentValue += char
92
- } else if (char === '}' && !inQuotes) {
93
- if (braceDepth > 0) {
94
- braceDepth--
95
- currentValue += char
96
- } else {
97
- // End of entry
98
- if (currentField) {
99
- fields[currentField] = parseFieldValue(currentValue)
100
- }
101
- break
102
- }
103
- } else if (char === '"' && prevChar !== '\\') {
104
- inQuotes = !inQuotes
105
- currentValue += char
106
- } else if (char === ',' && braceDepth === 0 && !inQuotes) {
107
- // End of field
108
- if (currentField) {
109
- fields[currentField] = parseFieldValue(currentValue)
110
- }
111
- currentField = ''
112
- currentValue = ''
113
- inField = false
114
- } else {
115
- currentValue += char
116
- }
117
- }
118
- }
119
-
120
- // Save last field if any
121
- if (currentField && currentValue) {
122
- fields[currentField] = parseFieldValue(currentValue)
123
- }
124
-
125
- // Normalize to standard publication object
126
- const pub = {
127
- id: citationKey,
128
- type: entryType,
129
- ...normalizeFields(fields, entryType),
130
- }
131
-
132
- return pub
133
- }
134
-
135
- /**
136
- * Normalize BibTeX fields to standard publication object
137
- *
138
- * @param {Object} fields - Raw BibTeX fields
139
- * @param {string} entryType - BibTeX entry type
140
- * @returns {Object} Normalized fields
141
- */
142
- function normalizeFields(fields, entryType) {
143
- const normalized = {}
144
-
145
- // Title
146
- if (fields.title) {
147
- normalized.title = fields.title
148
- }
149
-
150
- // Authors
151
- if (fields.author) {
152
- normalized.authors = parseAuthorString(fields.author)
153
- }
154
-
155
- // Editors
156
- if (fields.editor) {
157
- normalized.editors = parseAuthorString(fields.editor)
158
- }
159
-
160
- // Year
161
- if (fields.year) {
162
- normalized.year = parseInt(fields.year, 10) || fields.year
163
- }
164
-
165
- // Journal/booktitle
166
- if (fields.journal) {
167
- normalized.journal = fields.journal
168
- }
169
- if (fields.booktitle) {
170
- normalized.bookTitle = fields.booktitle
171
- }
172
-
173
- // Volume, issue, pages
174
- if (fields.volume) normalized.volume = fields.volume
175
- if (fields.number) normalized.issue = fields.number
176
- if (fields.pages) normalized.pages = fields.pages.replace('--', '–')
177
-
178
- // Publisher info
179
- if (fields.publisher) normalized.publisher = fields.publisher
180
- if (fields.address) normalized.address = fields.address
181
-
182
- // DOI and URL
183
- if (fields.doi) {
184
- normalized.doi = fields.doi.replace(/^https?:\/\/doi\.org\//, '')
185
- }
186
- if (fields.url) normalized.url = fields.url
187
-
188
- // ISBN/ISSN
189
- if (fields.isbn) normalized.isbn = fields.isbn
190
- if (fields.issn) normalized.issn = fields.issn
191
-
192
- // Abstract and keywords
193
- if (fields.abstract) normalized.abstract = fields.abstract
194
- if (fields.keywords) {
195
- normalized.keywords = fields.keywords.split(/[,;]/).map((k) => k.trim())
196
- }
197
-
198
- // Thesis-specific
199
- if (fields.school) normalized.school = fields.school
200
- if (fields.institution) normalized.institution = fields.institution
201
-
202
- // Edition
203
- if (fields.edition) normalized.edition = fields.edition
204
-
205
- // Note
206
- if (fields.note) normalized.note = fields.note
207
-
208
- return normalized
209
- }
210
-
211
- /**
212
- * Parse BibTeX string into array of publication objects
213
- *
214
- * @param {string} bibtex - BibTeX string
215
- * @returns {Array<Object>} Array of publication objects
216
- *
217
- * @example
218
- * const refs = parseBibtex(`
219
- * @article{smith2024,
220
- * author = {Smith, John and Doe, Jane},
221
- * title = {A Study},
222
- * journal = {Journal of Studies},
223
- * year = {2024}
224
- * }
225
- * `)
226
- */
227
- export function parseBibtex(bibtex) {
228
- if (!bibtex || typeof bibtex !== 'string') {
229
- return []
230
- }
231
-
232
- const results = []
233
-
234
- // Find all entries (@ followed by type and opening brace)
235
- const entryPattern = /@(\w+)\s*\{/g
236
- let match
237
- const entryStarts = []
238
-
239
- while ((match = entryPattern.exec(bibtex)) !== null) {
240
- entryStarts.push(match.index)
241
- }
242
-
243
- // Parse each entry
244
- for (let i = 0; i < entryStarts.length; i++) {
245
- const start = entryStarts[i]
246
- const end = i < entryStarts.length - 1 ? entryStarts[i + 1] : bibtex.length
247
-
248
- const entryStr = bibtex.slice(start, end)
249
- const parsed = parseEntry(entryStr)
250
-
251
- if (parsed) {
252
- results.push(parsed)
253
- }
254
- }
255
-
256
- return results
257
- }
258
-
259
- /**
260
- * Escape a string for BibTeX
261
- *
262
- * @param {string} str - String to escape
263
- * @returns {string} Escaped string
264
- */
265
- function escapeBibtex(str) {
266
- if (!str) return ''
267
- return String(str)
268
- .replace(/&/g, '\\&')
269
- .replace(/%/g, '\\%')
270
- .replace(/_/g, '\\_')
271
- .replace(/#/g, '\\#')
272
- .replace(/\$/g, '\\$')
273
- }
274
-
275
- /**
276
- * Format authors for BibTeX
277
- *
278
- * @param {Array} authors - Array of author objects
279
- * @returns {string} BibTeX author string
280
- */
281
- function formatBibtexAuthors(authors) {
282
- if (!authors?.length) return ''
283
-
284
- return authors
285
- .map((a) => {
286
- if (typeof a === 'string') return a
287
- const suffix = a.suffix ? `, ${a.suffix}` : ''
288
- return a.given ? `${a.family}${suffix}, ${a.given}` : a.family + suffix
289
- })
290
- .join(' and ')
291
- }
292
-
293
- /**
294
- * Export a publication object to BibTeX format
295
- *
296
- * @param {Object} pub - Publication object
297
- * @returns {string} BibTeX string
298
- *
299
- * @example
300
- * const bibtex = exportBibtex({
301
- * id: 'smith2024',
302
- * type: 'article',
303
- * authors: [{ family: 'Smith', given: 'John' }],
304
- * title: 'A Study',
305
- * journal: 'Journal of Studies',
306
- * year: 2024
307
- * })
308
- */
309
- export function exportBibtex(pub) {
310
- if (!pub) return ''
311
-
312
- const type = pub.type || pub.entryType || 'misc'
313
- const key = pub.id || pub.citationKey || generateKey(pub)
314
-
315
- const fields = []
316
-
317
- // Authors
318
- if (pub.authors?.length) {
319
- fields.push(` author = {${formatBibtexAuthors(pub.authors)}}`)
320
- } else if (pub.author) {
321
- fields.push(` author = {${escapeBibtex(pub.author)}}`)
322
- }
323
-
324
- // Editors
325
- if (pub.editors?.length) {
326
- fields.push(` editor = {${formatBibtexAuthors(pub.editors)}}`)
327
- }
328
-
329
- // Title
330
- if (pub.title) {
331
- fields.push(` title = {${escapeBibtex(pub.title)}}`)
332
- }
333
-
334
- // Journal/booktitle
335
- if (pub.journal) {
336
- fields.push(` journal = {${escapeBibtex(pub.journal)}}`)
337
- }
338
- if (pub.bookTitle) {
339
- fields.push(` booktitle = {${escapeBibtex(pub.bookTitle)}}`)
340
- }
341
-
342
- // Year
343
- if (pub.year) {
344
- fields.push(` year = {${pub.year}}`)
345
- }
346
-
347
- // Volume, number, pages
348
- if (pub.volume) fields.push(` volume = {${pub.volume}}`)
349
- if (pub.issue) fields.push(` number = {${pub.issue}}`)
350
- if (pub.pages) fields.push(` pages = {${pub.pages.replace('–', '--')}}`)
351
-
352
- // Publisher
353
- if (pub.publisher) {
354
- fields.push(` publisher = {${escapeBibtex(pub.publisher)}}`)
355
- }
356
- if (pub.address) {
357
- fields.push(` address = {${escapeBibtex(pub.address)}}`)
358
- }
359
-
360
- // DOI and URL
361
- if (pub.doi) fields.push(` doi = {${pub.doi}}`)
362
- if (pub.url) fields.push(` url = {${pub.url}}`)
363
-
364
- // ISBN/ISSN
365
- if (pub.isbn) fields.push(` isbn = {${pub.isbn}}`)
366
- if (pub.issn) fields.push(` issn = {${pub.issn}}`)
367
-
368
- // Abstract
369
- if (pub.abstract) {
370
- fields.push(` abstract = {${escapeBibtex(pub.abstract)}}`)
371
- }
372
-
373
- // Keywords
374
- if (pub.keywords?.length) {
375
- fields.push(` keywords = {${pub.keywords.join(', ')}}`)
376
- }
377
-
378
- // School (for thesis)
379
- if (pub.school) {
380
- fields.push(` school = {${escapeBibtex(pub.school)}}`)
381
- }
382
-
383
- // Edition
384
- if (pub.edition) {
385
- fields.push(` edition = {${pub.edition}}`)
386
- }
387
-
388
- return `@${type}{${key},\n${fields.join(',\n')}\n}`
389
- }
390
-
391
- /**
392
- * Generate a citation key from publication data
393
- *
394
- * @param {Object} pub - Publication object
395
- * @returns {string} Generated key
396
- */
397
- function generateKey(pub) {
398
- const author =
399
- pub.authors?.[0]?.family ||
400
- (typeof pub.author === 'string' ? pub.author.split(/[,\s]/)[0] : 'unknown')
401
- const year = pub.year || 'nd'
402
- const title = pub.title?.split(/\s+/)[0]?.toLowerCase() || ''
403
-
404
- return `${author.toLowerCase()}${year}${title}`.replace(/[^a-z0-9]/gi, '')
405
- }
@@ -1,277 +0,0 @@
1
- /**
2
- * Author Name Utilities
3
- *
4
- * Functions for parsing and formatting author names in various citation styles.
5
- *
6
- * @module @uniweb/scholar/bibliography/utils/authors
7
- */
8
-
9
- /**
10
- * Parse a single author name string into structured parts
11
- *
12
- * @param {string} name - Author name (e.g., "Smith, John A." or "John A. Smith")
13
- * @returns {{ family: string, given: string, suffix?: string }}
14
- */
15
- export function parseAuthorName(name) {
16
- if (!name || typeof name !== 'string') {
17
- return { family: '', given: '' }
18
- }
19
-
20
- const trimmed = name.trim()
21
-
22
- // Check for "Last, First" format
23
- if (trimmed.includes(',')) {
24
- const parts = trimmed.split(',').map((p) => p.trim())
25
- const family = parts[0]
26
- const givenAndSuffix = parts.slice(1).join(' ').trim()
27
-
28
- // Check for suffix (Jr., Sr., III, etc.)
29
- const suffixMatch = givenAndSuffix.match(/\s+(Jr\.?|Sr\.?|III?|IV|V)$/i)
30
- if (suffixMatch) {
31
- return {
32
- family,
33
- given: givenAndSuffix.slice(0, suffixMatch.index).trim(),
34
- suffix: suffixMatch[1],
35
- }
36
- }
37
-
38
- return { family, given: givenAndSuffix }
39
- }
40
-
41
- // "First Last" format - last word is family name
42
- const words = trimmed.split(/\s+/)
43
- if (words.length === 1) {
44
- return { family: words[0], given: '' }
45
- }
46
-
47
- // Check if last word is a suffix
48
- const lastWord = words[words.length - 1]
49
- if (/^(Jr\.?|Sr\.?|III?|IV|V)$/i.test(lastWord)) {
50
- return {
51
- family: words[words.length - 2] || '',
52
- given: words.slice(0, -2).join(' '),
53
- suffix: lastWord,
54
- }
55
- }
56
-
57
- return {
58
- family: words[words.length - 1],
59
- given: words.slice(0, -1).join(' '),
60
- }
61
- }
62
-
63
- /**
64
- * Parse a string of multiple authors
65
- *
66
- * @param {string} authorString - Authors separated by "and", ";", or "&"
67
- * @returns {Array<{ family: string, given: string, suffix?: string }>}
68
- */
69
- export function parseAuthorString(authorString) {
70
- if (!authorString || typeof authorString !== 'string') {
71
- return []
72
- }
73
-
74
- // Split by "and", ";", or "&"
75
- const authors = authorString
76
- .split(/\s+and\s+|;\s*|&\s*/i)
77
- .map((a) => a.trim())
78
- .filter(Boolean)
79
-
80
- return authors.map(parseAuthorName)
81
- }
82
-
83
- /**
84
- * Get author initials
85
- *
86
- * @param {string} given - Given name(s)
87
- * @param {Object} [options]
88
- * @param {boolean} [options.periods=true] - Include periods after initials
89
- * @param {boolean} [options.spaces=true] - Include spaces between initials
90
- * @returns {string}
91
- */
92
- export function getInitials(given, options = {}) {
93
- const { periods = true, spaces = true } = options
94
-
95
- if (!given) return ''
96
-
97
- const names = given.split(/\s+/)
98
- const initials = names
99
- .map((name) => name.charAt(0).toUpperCase())
100
- .map((initial) => (periods ? `${initial}.` : initial))
101
-
102
- return initials.join(spaces ? ' ' : '')
103
- }
104
-
105
- /**
106
- * Format authors for APA style
107
- *
108
- * @param {Array} authors - Array of parsed author objects
109
- * @param {number} [maxAuthors=20] - Maximum authors before using et al.
110
- * @returns {string}
111
- */
112
- export function formatApaAuthors(authors, maxAuthors = 20) {
113
- if (!authors?.length) return ''
114
-
115
- const formatOne = (author) => {
116
- const initials = getInitials(author.given, { periods: true, spaces: true })
117
- const suffix = author.suffix ? `, ${author.suffix}` : ''
118
- return initials
119
- ? `${author.family}${suffix}, ${initials}`
120
- : author.family + suffix
121
- }
122
-
123
- if (authors.length === 1) {
124
- return formatOne(authors[0])
125
- }
126
-
127
- if (authors.length === 2) {
128
- return `${formatOne(authors[0])} & ${formatOne(authors[1])}`
129
- }
130
-
131
- if (authors.length <= maxAuthors) {
132
- const allButLast = authors.slice(0, -1).map(formatOne).join(', ')
133
- return `${allButLast}, & ${formatOne(authors[authors.length - 1])}`
134
- }
135
-
136
- // More than maxAuthors: first 19, ..., last
137
- const first19 = authors.slice(0, 19).map(formatOne).join(', ')
138
- return `${first19}, ... ${formatOne(authors[authors.length - 1])}`
139
- }
140
-
141
- /**
142
- * Format authors for MLA style
143
- *
144
- * @param {Array} authors - Array of parsed author objects
145
- * @returns {string}
146
- */
147
- export function formatMlaAuthors(authors) {
148
- if (!authors?.length) return ''
149
-
150
- const formatFirst = (author) => {
151
- const suffix = author.suffix ? `, ${author.suffix}` : ''
152
- return author.given
153
- ? `${author.family}${suffix}, ${author.given}`
154
- : author.family + suffix
155
- }
156
-
157
- const formatSubsequent = (author) => {
158
- const suffix = author.suffix ? `, ${author.suffix}` : ''
159
- return author.given
160
- ? `${author.given} ${author.family}${suffix}`
161
- : author.family + suffix
162
- }
163
-
164
- if (authors.length === 1) {
165
- return formatFirst(authors[0])
166
- }
167
-
168
- if (authors.length === 2) {
169
- return `${formatFirst(authors[0])}, and ${formatSubsequent(authors[1])}`
170
- }
171
-
172
- if (authors.length === 3) {
173
- return `${formatFirst(authors[0])}, ${formatSubsequent(authors[1])}, and ${formatSubsequent(authors[2])}`
174
- }
175
-
176
- // 4+ authors: first author et al.
177
- return `${formatFirst(authors[0])}, et al.`
178
- }
179
-
180
- /**
181
- * Format authors for Chicago style (author-date)
182
- *
183
- * @param {Array} authors - Array of parsed author objects
184
- * @returns {string}
185
- */
186
- export function formatChicagoAuthors(authors) {
187
- if (!authors?.length) return ''
188
-
189
- const formatOne = (author, isFirst = false) => {
190
- const suffix = author.suffix ? `, ${author.suffix}` : ''
191
- if (isFirst) {
192
- return author.given
193
- ? `${author.family}${suffix}, ${author.given}`
194
- : author.family + suffix
195
- }
196
- return author.given
197
- ? `${author.given} ${author.family}${suffix}`
198
- : author.family + suffix
199
- }
200
-
201
- if (authors.length === 1) {
202
- return formatOne(authors[0], true)
203
- }
204
-
205
- if (authors.length === 2) {
206
- return `${formatOne(authors[0], true)} and ${formatOne(authors[1])}`
207
- }
208
-
209
- if (authors.length === 3) {
210
- return `${formatOne(authors[0], true)}, ${formatOne(authors[1])}, and ${formatOne(authors[2])}`
211
- }
212
-
213
- // 4+ authors: first author et al.
214
- return `${formatOne(authors[0], true)} et al.`
215
- }
216
-
217
- /**
218
- * Format authors for IEEE style
219
- *
220
- * @param {Array} authors - Array of parsed author objects
221
- * @returns {string}
222
- */
223
- export function formatIeeeAuthors(authors) {
224
- if (!authors?.length) return ''
225
-
226
- const formatOne = (author) => {
227
- const initials = getInitials(author.given, { periods: true, spaces: true })
228
- const suffix = author.suffix ? ` ${author.suffix}` : ''
229
- return initials
230
- ? `${initials} ${author.family}${suffix}`
231
- : author.family + suffix
232
- }
233
-
234
- if (authors.length === 1) {
235
- return formatOne(authors[0])
236
- }
237
-
238
- if (authors.length === 2) {
239
- return `${formatOne(authors[0])} and ${formatOne(authors[1])}`
240
- }
241
-
242
- // 3+ authors in IEEE: first author et al. in citations, all in references
243
- const allButLast = authors.slice(0, -1).map(formatOne).join(', ')
244
- return `${allButLast}, and ${formatOne(authors[authors.length - 1])}`
245
- }
246
-
247
- /**
248
- * Format authors for a specific style
249
- *
250
- * @param {Array|string} authors - Authors array or string
251
- * @param {Object} [options]
252
- * @param {string} [options.style='apa'] - Citation style
253
- * @param {number} [options.max=20] - Max authors before et al.
254
- * @returns {string}
255
- */
256
- export function formatAuthors(authors, options = {}) {
257
- const { style = 'apa', max = 20 } = options
258
-
259
- // Parse if string
260
- const parsed =
261
- typeof authors === 'string' ? parseAuthorString(authors) : authors
262
-
263
- if (!parsed?.length) return ''
264
-
265
- switch (style) {
266
- case 'apa':
267
- return formatApaAuthors(parsed, max)
268
- case 'mla':
269
- return formatMlaAuthors(parsed)
270
- case 'chicago':
271
- return formatChicagoAuthors(parsed)
272
- case 'ieee':
273
- return formatIeeeAuthors(parsed)
274
- default:
275
- return formatApaAuthors(parsed, max)
276
- }
277
- }