@uniweb/scholar 0.1.0

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Bibliography Module
3
+ *
4
+ * Utilities for formatting bibliographic references in various citation styles.
5
+ *
6
+ * @module @uniweb/scholar/bibliography
7
+ */
8
+
9
+ // Formatters
10
+ export {
11
+ formatReference,
12
+ formatApa,
13
+ formatMla,
14
+ formatChicago,
15
+ formatIeee,
16
+ formatters,
17
+ } from './formatters/index.js'
18
+
19
+ // Parsers
20
+ export { parseBibtex, exportBibtex } from './parsers/index.js'
21
+
22
+ // Utilities
23
+ export {
24
+ formatAuthors,
25
+ parseAuthorName,
26
+ parseAuthorString,
27
+ getInitials,
28
+ formatApaAuthors,
29
+ formatMlaAuthors,
30
+ formatChicagoAuthors,
31
+ formatIeeeAuthors,
32
+ } from './utils/authors.js'
33
+
34
+ export {
35
+ formatDate,
36
+ parseDate,
37
+ getYear,
38
+ formatApaDate,
39
+ formatMlaDate,
40
+ formatChicagoDate,
41
+ formatIeeeDate,
42
+ } from './utils/dates.js'
@@ -0,0 +1,405 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Bibliography Parsers
3
+ *
4
+ * @module @uniweb/scholar/bibliography/parsers
5
+ */
6
+
7
+ export { parseBibtex, exportBibtex } from './bibtex.js'