@iebh/reflib 2.6.7 → 2.7.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.
package/modules/bibtex.js CHANGED
@@ -1,392 +1,395 @@
1
- import Emitter from '../shared/emitter.js';
2
- /**
3
- * Generate a citation key from first author + year
4
- * Example: "Roomruangwong2020"
5
- */
6
- function generateCitationKey(ref) {
7
- let author = 'Anon';
8
- if (ref.authors && ref.authors.length > 0) {
9
- author = ref.authors[0].split(',')[0];
10
- }
11
-
12
- let year = 'n.d.';
13
- if (ref.year) {
14
- year = ref.year;
15
- }
16
-
17
- return `${author}${year}`;
18
- }
19
- /**
20
- * Lookup enum for the current parser mode we are in
21
- *
22
- * @type {Object<Number>}
23
- */
24
- const MODES = {
25
- REF: 0,
26
- FIELDS: 1,
27
- FIELD_START: 2,
28
- FIELD_VALUE: 3,
29
- };
30
-
31
-
32
- /**
33
- * Parse a BibTeX file from a readable stream
34
- *
35
- * @see modules/interface.js
36
- *
37
- * @param {Stream} stream The readable stream to accept data from
38
- * @param {Object} [options] Additional options to use when parsing
39
- * @param {Boolean} [options.recNumberNumeric=true] Only process the BibTeX ID into a recNumber if its a finite numeric, otherwise disguard
40
- * @param {Boolean} [options.recNumberRNPrefix=true] Accept `RN${NUMBER}` as recNumber if present
41
- * @param {Boolean} [options.recNumberKey=true] If the reference key cannot be otherwise parsed store it in `key<String>` instead
42
- * @param {Boolean} [options.omitUnkown=false] If true, only keep known reconised fields
43
- * @param {String} [options.fallbackType='unkown'] Reflib fallback type if the incoming type is unrecognised or unsupported
44
- * @param {Set<String>} [options.fieldsOverwrite] Set of field names where the value is clobbered rather than appended if discovered more than once
45
- *
46
- * @returns {Object} A readable stream analogue defined in `modules/interface.js`
47
- */
48
- export function readStream(stream, options) {
49
- let settings = {
50
- recNumberNumeric: true,
51
- recNumberRNPrefix: true,
52
- recNumberKey: true,
53
- omitUnknown: false,
54
- fallbackType: 'unknown',
55
- fieldsOverwrite: new Set(['type']),
56
- ...options,
57
- };
58
-
59
- let emitter = Emitter();
60
- let buffer = '';
61
- let mode = MODES.REF;
62
- let state; // Misc state storage when we're digesting ref data
63
- let ref = {}; // Reference item being constructed
64
-
65
- // Queue up the parser in the next tick (so we can return the emitter first)
66
- setTimeout(()=> {
67
- stream
68
- .on('error', e => emitter.emit('error', e))
69
- .on('end', ()=> emitter.emit('end'))
70
- .on('data', chunkBuffer => {
71
- emitter.emit('progress', stream.bytesRead);
72
- buffer += chunkBuffer.toString(); // Append incomming data to the partial-buffer we're holding in memory
73
-
74
- while (true) {
75
- let match; // Regex storage for match groups
76
- if ((mode == MODES.REF) && (match = /^\s*@(?<type>\w+?)\s*\{(?<id>.*?),/s.exec(buffer))) {
77
- if (settings.recNumberNumeric && isFinite(match.groups.id)) { // Accept numeric recNumber
78
- ref.recNumber = +match.groups.id;
79
- } else if (settings.recNumberRNPrefix && /^RN\d+$/.test(match.groups.id)) {
80
- ref.recNumber = +match.groups.id.slice(2);
81
- } else if (!settings.recNumberNumeric && match.groups.id) { // Non numeric / finite ID - but we're allowed to accept it anyway
82
- ref.recNumber = +match.groups.id;
83
- } else if (settings.recNumberKey) { // Non numeric, custom looking key, stash in 'key' instead
84
- ref.key = match.groups.id;
85
- } // Implied else - No ID, ignore
86
-
87
- ref.type = match.groups.type;
88
- mode = MODES.FIELDS;
89
- state = null;
90
- } else if (mode == MODES.FIELDS && (match = /^\s*(?<field>\w+?)\s*=\s*/s.exec(buffer))) {
91
- mode = MODES.FIELD_START;
92
- state = {field: match.groups.field};
93
- } else if (mode == MODES.FIELDS && (match = /^\s*\}\s*/s.exec(buffer))) { // End of ref
94
- emitter.emit('ref', tidyRef(ref, settings));
95
- mode = MODES.REF;
96
- ref = {};
97
- state = null;
98
- } else if (mode == MODES.FIELD_START && (match = /^\s*(?<fieldWrapper>"|{)\s*/.exec(buffer))) {
99
- mode = MODES.FIELD_VALUE;
100
- state.fieldWrapper = match.groups.fieldWrapper;
101
- } else if (
102
- // TODO: Note that we use `\r?\n` as delimiters for field values, this is a cheat to avoid having to implement a full AST parser
103
- // This is a hack but since most BibTeX files use properly formatted BibTeX this should work in the majority of cases
104
- // This WILL break if given one continuous line of BibTeX though
105
- // - MC 2026-01-02
106
- mode == MODES.FIELD_VALUE
107
- && (
108
- (
109
- state.fieldWrapper == '{'
110
- && (match = /^(?<value>.+?)(?<!\\%)\}\s*,?\s*$/sm.exec(buffer))
111
- )
112
- || (
113
- state.fieldWrapper == '"'
114
- && (match = /^(?<value>.+?)"\s*,?\s*$/sm.exec(buffer))
115
- )
116
- )
117
- ) {
118
- mode = MODES.FIELDS;
119
- if (ref[state.field] !== undefined && settings.fieldsOverwrite.has(state.field)) { // Already have content - and we should overwrite
120
- ref[state.field] = unescape(match.groups.value);
121
- } else if (ref[state.field] !== undefined) { // Already have content - append
122
- ref[state.field] += '\n' + unescape(match.groups.value);
123
- } else { // Populate initial value
124
- ref[state.field] = unescape(match.groups.value);
125
- }
126
- state = null;
127
- } else { // Implied else - No match to buffer, let it fill and process next data block
128
- break;
129
- }
130
-
131
- // Crop start of buffer to last match
132
- buffer = buffer.slice(match[0].length);
133
- }
134
- })
135
- })
136
-
137
- return emitter;
138
- }
139
-
140
-
141
- /**
142
- * Tidy up a raw BibTeX reference before emitting
143
- *
144
- * @param {Object} ref The input raw ref to tidy
145
- *
146
- * @param {Object} settings Optimized settings object for fast access
147
- *
148
- * @returns {Object} The tidied ref
149
- */
150
- export function tidyRef(ref, settings) {
151
- return Object.fromEntries(
152
- Object.entries(ref)
153
- .map(([key, val]) => {
154
- let rlField = translations.fields.btMap.get(key.toLowerCase());
155
-
156
- if (key == 'type') { // Special conversion for type
157
- let rlType = ref.type && translations.types.btMap.get(val.toLowerCase());
158
- return rlType
159
- ? [key, rlType.rl] // Can translate incoming type to Reflib type
160
- : [key, settings.fallbackType] // Unknown Reflib type varient
161
- } else if (settings.omitUnkown && !rlField) { // Omit unknown fields
162
- return;
163
- } else if (rlField && rlField.array) { // Field needs array casting
164
- return [rlField.rl, val.split(/\n*\s+and\s+/)];
165
- } else if (rlField && rlField.rl) { // Known BT field but different RL field
166
- return [rlField.rl, val];
167
- } else if (!settings.omitUnkown) { // Everything else - add field
168
- return [key, val];
169
- }
170
- })
171
- .filter(Boolean) // Remove duds
172
- );
173
- }
174
-
175
-
176
- /**
177
- * Translate a BibTeX encoded string into a regular JS String
178
- *
179
- * @param {String} str Input BibTeX encoded string
180
- * @returns {String} Regular JS output string
181
- */
182
- export function unescape(str) {
183
- return str
184
- .replace(/\/\*/g, '\n')
185
- .replace(/\{\\\&\}/g, '&')
186
- .replace(/\{\\\%\}/g, '%')
187
- }
188
-
189
-
190
- /**
191
- * Translate a JS string into a BibTeX encoded string
192
- *
193
- * @param {String} str Input regular JS String
194
- * @returns {String} BibTeX encoded string
195
- */
196
- export function escape(str) {
197
- return (''+str)
198
- .replace(/\&/g, '{\\&}')
199
- .replace(/%/g, '{\\%}')
200
- }
201
-
202
-
203
- /**
204
- * Write a RIS file to a writable stream
205
- *
206
- * @see modules/interface.js
207
- *
208
- * @param {Stream} stream The writable stream to write to
209
- *
210
- * @param {Object} [options] Additional options to use when parsing
211
- * @param {string} [options.defaultType='Misc'] Default citation type to assume when no other type is specified
212
- * @param {string} [options.delimeter='\r'] How to split multi-line items
213
- * @param {Boolean} [options.omitUnkown=false] If true, only keep known reconised fields
214
- * @param {Set} [options.omitFields] Set of special fields to always omit, either because we are ignoring or because we have special treatment for them
215
- * @param {Boolean} [options.recNumberRNPrefix=true] Rewrite recNumber fields as `RN${NUMBER}`
216
- * @param {Boolean} [options.recNumberKey=true] If the reference `recNumber` is empty use `key<String>` instead
217
- *
218
- * @returns {Object} A writable stream analogue defined in `modules/interface.js`
219
- */
220
- export function writeStream(stream, options) {
221
- let settings = {
222
- defaultType: 'Misc',
223
- delimeter: '\n',
224
- omitUnkown: false,
225
- omitFields: new Set(['recNumber', 'type']),
226
- recNumberRNPrefix: true,
227
- recNumberKey: true,
228
- ...options,
229
- };
230
-
231
- return {
232
- start() {
233
- return Promise.resolve();
234
- },
235
- write: ref => {
236
- if (!ref.key) {
237
- ref.key = generateCitationKey(ref);
238
- }
239
- // console.log("Here is the id",ref.key)
240
- // Fetch Reflib type definition
241
- let rlType = (ref.type || settings.defaultType) && translations.types.rlMap.get(ref.type.toLowerCase());
242
- let btType = rlType?.bt || settings.defaultType;
243
-
244
- stream.write(
245
- '@' + btType + '{'
246
- + (
247
- ref.recNumber && settings.recNumberRNPrefix ? `RN${ref.recNumber},`
248
- : ref.recNumber ? `${ref.recNumber},`
249
- : ref.key ? `${ref.key},`
250
- : ''
251
- ) + '\n'
252
- + Object.entries(ref)
253
- .filter(([key, val]) =>
254
- val // We have a non-nullish val
255
- && !settings.omitFields.has(key)
256
- )
257
- .reduce((buf, [rawKey, rawVal], keyIndex, keys) => {
258
- // Fetch Reflib field definition
259
- let rlField = translations.fields.rlMap.get(rawKey)
260
- if (!rlField && settings.omitUnkown) return buf; // Unknown field mapping - skip if were omitting unknown fields
261
-
262
- let key = rlField ? rlField.bt : rawKey; // Use Reflib->BibTeX field mapping if we have one, otherwise use raw key
263
- let val = escape( // Escape input value, either as an Array via join or as a flat string
264
- rawKey == 'authors' && Array.isArray(rawVal) ? rawVal.join('\nand ') // Special joining conditions for author field
265
- : Array.isArray(rawVal) ? rawVal.join(', ') // Treat other arrays as a CSV
266
- : rawVal // Splat everything else as a string
267
- );
268
-
269
- return buf + // Return string buffer of ref under construction
270
- `${key}={${val}}` // Append ref key=val pair to buffer
271
- + (keyIndex < keys.length-1 ? ',' : '') // Append comma (if non-last)
272
- + '\n' // Finish each field with a newline
273
- }, '')
274
- + '}\n'
275
- );
276
-
277
- return Promise.resolve();
278
- },
279
- middle() {
280
- stream.write('\n');
281
- },
282
- end() {
283
- return new Promise((resolve, reject) =>
284
- stream.end(err => err ? reject(err) : resolve())
285
- );
286
- },
287
- };
288
- }
289
-
290
-
291
- /**
292
- * Lookup tables for this module
293
- * @type {Object}
294
- * @property {Array<Object>} fields Field translations between Reflib (`rl`) and BibTeX format (`bt`)
295
- */
296
- export let translations = {
297
- // Field translations {{{
298
- fields: {
299
- collection: [
300
- // Order by priority (highest at top)
301
- {rl: 'address', bt: 'address'},
302
- {rl: 'authors', bt: 'author', array: true},
303
- {rl: 'doi', bt: 'doi'},
304
- {rl: 'edition', bt: 'edition'},
305
- {rl: 'editor', bt: 'editor'},
306
- {rl: 'journal', bt: 'journal'},
307
- {rl: 'notes', bt: 'note'},
308
- {rl: 'number', bt: 'number'},
309
- {rl: 'pages', bt: 'pages'},
310
- {rl: 'title', bt: 'booktitle'},
311
- {rl: 'title', bt: 'title'},
312
- {rl: 'volume', bt: 'volume'},
313
- {rl: 'isbn', bt: 'issn'},
314
-
315
- // Misc
316
- {bt: 'month'}, // Combined into {rl:'date'}
317
- {bt: 'type'}, // Ignored
318
- {bt: 'year'}, // Combined into {rl:'date'}
319
-
320
- // Nonestandard but used anyway
321
- {rl: 'abstract', bt: 'abstract'},
322
- {rl: 'language', bt: 'language'},
323
- {rl: 'keywords', bt: 'keywords', array: true},
324
- {rl: 'urls', bt: 'url', array: true},
325
-
326
- // Unknown how to translate these
327
- // {bt: 'annote'},
328
- // {bt: 'email'},
329
- // {bt: 'chapter'},
330
- // {bt: 'crossref'},
331
- // {bt: 'howpublished'},
332
- // {bt: 'institution'},
333
- // {bt: 'key'},
334
- // {bt: 'organization'},
335
- // {bt: 'publisher'},
336
- // {bt: 'school'},
337
- // {bt: 'series'},
338
- ],
339
- rlMap: new Map(),
340
- btMap: new Map(),
341
- },
342
- // }}}
343
- // Ref type translations {{{
344
- types: {
345
- collection: [
346
- // Order by priority (highest at top)
347
- {rl: 'journalArticle', bt: 'Article'},
348
- {rl: 'book', bt: 'Book'},
349
- {rl: 'bookSection', bt: 'InBook'},
350
- {rl: 'conferencePaper', bt: 'Conference'},
351
- {rl: 'conferenceProceedings', bt: 'InProceedings'},
352
- {rl: 'report', bt: 'TechReport'},
353
- {rl: 'thesis', bt: 'PHDThesis'},
354
- {rl: 'unknown', bt: 'Misc'},
355
- {rl: 'unpublished', bt: 'Unpublished'},
356
-
357
- // Type aliases
358
- {rl: 'journalArticle', bt: 'Journal Article'},
359
-
360
- // Unknown how to translate these
361
- {rl: 'Misc', bt: 'Booklet'},
362
- {rl: 'Misc', bt: 'InCollection'},
363
- {rl: 'Misc', bt: 'Manual'},
364
- {rl: 'Misc', bt: 'MastersThesis'},
365
- {rl: 'Misc', bt: 'Proceedings'},
366
- ],
367
- rlMap: new Map(),
368
- btMap: new Map(),
369
- },
370
- // }}}
371
- };
372
-
373
-
374
- /**
375
- * @see modules/interface.js
376
- */
377
- export function setup() {
378
- // Create lookup object of translations.fields with key as .rl / val as the full object
379
- translations.fields.collection.forEach(c => {
380
- if (c.rl) translations.fields.rlMap.set(c.rl.toLowerCase(), c);
381
- if (c.bt) translations.fields.btMap.set(c.bt, c);
382
- });
383
-
384
- // Create lookup object of ref.types with key as .rl / val as the full object
385
- translations.types.collection.forEach(c => {
386
- // Append each type to the set, accepting the first in each case as the priority
387
- let rlLc = c.rl.toLowerCase();
388
- let btLc = c.bt.toLowerCase();
389
- if (c.rl && !translations.types.rlMap.has(rlLc)) translations.types.rlMap.set(rlLc, c);
390
- if (c.bt && !translations.types.btMap.has(btLc)) translations.types.btMap.set(btLc, c);
391
- });
392
- }
1
+ import Emitter from '../shared/emitter.js';
2
+
3
+ /**
4
+ * Lookup enum for the current parser mode we are in
5
+ *
6
+ * @type {Object<Number>}
7
+ */
8
+ const MODES = {
9
+ REF: 0,
10
+ FIELDS: 1,
11
+ FIELD_START: 2,
12
+ FIELD_VALUE: 3,
13
+ };
14
+
15
+
16
+ /**
17
+ * Parse a BibTeX file from a readable stream
18
+ *
19
+ * @see modules/interface.js
20
+ *
21
+ * @param {Stream} stream The readable stream to accept data from
22
+ * @param {Object} [options] Additional options to use when parsing
23
+ * @param {Boolean} [options.recNumberNumeric=true] Only process the BibTeX ID into a recNumber if its a finite numeric, otherwise disguard
24
+ * @param {Boolean} [options.recNumberRNPrefix=true] Accept `RN${NUMBER}` as recNumber if present
25
+ * @param {Boolean} [options.recNumberKey=true] If the reference key cannot be otherwise parsed store it in `key<String>` instead
26
+ * @param {Boolean} [options.omitUnkown=false] If true, only keep known reconised fields
27
+ * @param {String} [options.fallbackType='unkown'] Reflib fallback type if the incoming type is unrecognised or unsupported
28
+ * @param {Set<String>} [options.fieldsOverwrite] Set of field names where the value is clobbered rather than appended if discovered more than once
29
+ *
30
+ * @returns {Object} A readable stream analogue defined in `modules/interface.js`
31
+ */
32
+ export function readStream(stream, options) {
33
+ let settings = {
34
+ recNumberNumeric: true,
35
+ recNumberRNPrefix: true,
36
+ recNumberKey: true,
37
+ omitUnknown: false,
38
+ fallbackType: 'unknown',
39
+ fieldsOverwrite: new Set(['type']),
40
+ ...options,
41
+ };
42
+
43
+ let emitter = Emitter();
44
+ let buffer = '';
45
+ let mode = MODES.REF;
46
+ let state; // Misc state storage when we're digesting ref data
47
+ let ref = {}; // Reference item being constructed
48
+
49
+ // Queue up the parser in the next tick (so we can return the emitter first)
50
+ setTimeout(()=> {
51
+ stream
52
+ .on('error', e => emitter.emit('error', e))
53
+ .on('end', ()=> emitter.emit('end'))
54
+ .on('data', chunkBuffer => {
55
+ emitter.emit('progress', stream.bytesRead);
56
+ buffer += chunkBuffer.toString(); // Append incomming data to the partial-buffer we're holding in memory
57
+
58
+ while (true) {
59
+ let match; // Regex storage for match groups
60
+ if ((mode == MODES.REF) && (match = /^\s*@(?<type>\w+?)\s*\{(?<id>.*?),/s.exec(buffer))) {
61
+ if (settings.recNumberNumeric && isFinite(match.groups.id)) { // Accept numeric recNumber
62
+ ref.recNumber = +match.groups.id;
63
+ } else if (settings.recNumberRNPrefix && /^RN\d+$/.test(match.groups.id)) {
64
+ ref.recNumber = +match.groups.id.slice(2);
65
+ } else if (!settings.recNumberNumeric && match.groups.id) { // Non numeric / finite ID - but we're allowed to accept it anyway
66
+ ref.recNumber = +match.groups.id;
67
+ } else if (settings.recNumberKey) { // Non numeric, custom looking key, stash in 'key' instead
68
+ ref.key = match.groups.id;
69
+ } // Implied else - No ID, ignore
70
+
71
+ ref.type = match.groups.type;
72
+ mode = MODES.FIELDS;
73
+ state = null;
74
+ } else if (mode == MODES.FIELDS && (match = /^\s*(?<field>\w+?)\s*=\s*/s.exec(buffer))) {
75
+ mode = MODES.FIELD_START;
76
+ state = {field: match.groups.field};
77
+ } else if (mode == MODES.FIELDS && (match = /^\s*\}\s*/s.exec(buffer))) { // End of ref
78
+ emitter.emit('ref', tidyRef(ref, settings));
79
+ mode = MODES.REF;
80
+ ref = {};
81
+ state = null;
82
+ } else if (mode == MODES.FIELD_START && (match = /^\s*(?<fieldWrapper>"|{)\s*/.exec(buffer))) {
83
+ mode = MODES.FIELD_VALUE;
84
+ state.fieldWrapper = match.groups.fieldWrapper;
85
+ } else if (
86
+ // TODO: Note that we use `\r?\n` as delimiters for field values, this is a cheat to avoid having to implement a full AST parser
87
+ // This is a hack but since most BibTeX files use properly formatted BibTeX this should work in the majority of cases
88
+ // This WILL break if given one continuous line of BibTeX though
89
+ // - MC 2026-01-02
90
+ mode == MODES.FIELD_VALUE
91
+ && (
92
+ (
93
+ state.fieldWrapper == '{'
94
+ && (match = /^(?<value>.+?)(?<!\\%)\}\s*,?\s*$/sm.exec(buffer))
95
+ )
96
+ || (
97
+ state.fieldWrapper == '"'
98
+ && (match = /^(?<value>.+?)"\s*,?\s*$/sm.exec(buffer))
99
+ )
100
+ )
101
+ ) {
102
+ mode = MODES.FIELDS;
103
+ if (ref[state.field] !== undefined && settings.fieldsOverwrite.has(state.field)) { // Already have content - and we should overwrite
104
+ ref[state.field] = unescape(match.groups.value);
105
+ } else if (ref[state.field] !== undefined) { // Already have content - append
106
+ ref[state.field] += '\n' + unescape(match.groups.value);
107
+ } else { // Populate initial value
108
+ ref[state.field] = unescape(match.groups.value);
109
+ }
110
+ state = null;
111
+ } else { // Implied else - No match to buffer, let it fill and process next data block
112
+ break;
113
+ }
114
+
115
+ // Crop start of buffer to last match
116
+ buffer = buffer.slice(match[0].length);
117
+ }
118
+ })
119
+ })
120
+
121
+ return emitter;
122
+ }
123
+
124
+
125
+ /**
126
+ * Tidy up a raw BibTeX reference before emitting
127
+ *
128
+ * @param {Object} ref The input raw ref to tidy
129
+ *
130
+ * @param {Object} settings Optimized settings object for fast access
131
+ *
132
+ * @returns {Object} The tidied ref
133
+ */
134
+ export function tidyRef(ref, settings) {
135
+ return Object.fromEntries(
136
+ Object.entries(ref)
137
+ .map(([key, val]) => {
138
+ let rlField = translations.fields.btMap.get(key.toLowerCase());
139
+
140
+ if (key == 'type') { // Special conversion for type
141
+ let rlType = ref.type && translations.types.btMap.get(val.toLowerCase());
142
+ return rlType
143
+ ? [key, rlType.rl] // Can translate incoming type to Reflib type
144
+ : [key, settings.fallbackType] // Unknown Reflib type varient
145
+ } else if (settings.omitUnkown && !rlField) { // Omit unknown fields
146
+ return;
147
+ } else if (rlField && rlField.array) { // Field needs array casting
148
+ return [rlField.rl, val.split(/\n*\s+and\s+/)];
149
+ } else if (rlField && rlField.rl) { // Known BT field but different RL field
150
+ return [rlField.rl, val];
151
+ } else if (!settings.omitUnkown) { // Everything else - add field
152
+ return [key, val];
153
+ }
154
+ })
155
+ .filter(Boolean) // Remove duds
156
+ );
157
+ }
158
+
159
+
160
+ /**
161
+ * Translate a BibTeX encoded string into a regular JS String
162
+ *
163
+ * @param {String} str Input BibTeX encoded string
164
+ * @returns {String} Regular JS output string
165
+ */
166
+ export function unescape(str) {
167
+ return str
168
+ .replace(/\/\*/g, '\n')
169
+ .replace(/\{\\\&\}/g, '&')
170
+ .replace(/\{\\\%\}/g, '%')
171
+ }
172
+
173
+
174
+ /**
175
+ * Translate a JS string into a BibTeX encoded string
176
+ *
177
+ * @param {String} str Input regular JS String
178
+ * @returns {String} BibTeX encoded string
179
+ */
180
+ export function escape(str) {
181
+ return (''+str)
182
+ .replace(/\&/g, '{\\&}')
183
+ .replace(/%/g, '{\\%}')
184
+ }
185
+
186
+
187
+ /**
188
+ * Write a RIS file to a writable stream
189
+ *
190
+ * @see modules/interface.js
191
+ *
192
+ * @param {Stream} stream The writable stream to write to
193
+ *
194
+ * @param {Object} [options] Additional options to use when parsing
195
+ * @param {string} [options.defaultType='Misc'] Default citation type to assume when no other type is specified
196
+ * @param {string} [options.delimeter='\r'] How to split multi-line items
197
+ * @param {Boolean} [options.omitUnkown=false] If true, only keep known reconised fields
198
+ * @param {Set} [options.omitFields] Set of special fields to always omit, either because we are ignoring or because we have special treatment for them
199
+ * @param {Boolean} [options.recNumberRNPrefix=true] Rewrite recNumber fields as `RN${NUMBER}`
200
+ * @param {Boolean} [options.recNumberKey=true] If the reference `recNumber` is empty use `key<String>` instead
201
+ *
202
+ * @returns {Object} A writable stream analogue defined in `modules/interface.js`
203
+ */
204
+ export function writeStream(stream, options) {
205
+ let settings = {
206
+ defaultType: 'Misc',
207
+ delimeter: '\n',
208
+ omitUnkown: false,
209
+ omitFields: new Set(['recNumber', 'type']),
210
+ recNumberRNPrefix: true,
211
+ recNumberKey: true,
212
+ ...options,
213
+ };
214
+
215
+ return {
216
+ start() {
217
+ return Promise.resolve();
218
+ },
219
+ write: ref => {
220
+ if (!ref.key) {
221
+ ref.key = generateCitationKey(ref);
222
+ }
223
+ // console.log("Here is the id",ref.key)
224
+ // Fetch Reflib type definition
225
+ let rlType = (ref.type || settings.defaultType) && translations.types.rlMap.get(ref.type.toLowerCase());
226
+ let btType = rlType?.bt || settings.defaultType;
227
+
228
+ stream.write(
229
+ '@' + btType + '{'
230
+ + (
231
+ ref.recNumber && settings.recNumberRNPrefix ? `RN${ref.recNumber},`
232
+ : ref.recNumber ? `${ref.recNumber},`
233
+ : ref.key ? `${ref.key},`
234
+ : ''
235
+ ) + '\n'
236
+ + Object.entries(ref)
237
+ .filter(([key, val]) =>
238
+ val // We have a non-nullish val
239
+ && !settings.omitFields.has(key)
240
+ )
241
+ .reduce((buf, [rawKey, rawVal], keyIndex, keys) => {
242
+ // Fetch Reflib field definition
243
+ let rlField = translations.fields.rlMap.get(rawKey)
244
+ if (!rlField && settings.omitUnkown) return buf; // Unknown field mapping - skip if were omitting unknown fields
245
+
246
+ let key = rlField ? rlField.bt : rawKey; // Use Reflib->BibTeX field mapping if we have one, otherwise use raw key
247
+ let val = escape( // Escape input value, either as an Array via join or as a flat string
248
+ rawKey == 'authors' && Array.isArray(rawVal) ? rawVal.join('\nand ') // Special joining conditions for author field
249
+ : Array.isArray(rawVal) ? rawVal.join(', ') // Treat other arrays as a CSV
250
+ : rawVal // Splat everything else as a string
251
+ );
252
+
253
+ return buf + // Return string buffer of ref under construction
254
+ `${key}={${val}}` // Append ref key=val pair to buffer
255
+ + (keyIndex < keys.length-1 ? ',' : '') // Append comma (if non-last)
256
+ + '\n' // Finish each field with a newline
257
+ }, '')
258
+ + '}\n'
259
+ );
260
+
261
+ return Promise.resolve();
262
+ },
263
+ middle() {
264
+ stream.write('\n');
265
+ },
266
+ end() {
267
+ return new Promise((resolve, reject) =>
268
+ stream.end(err => err ? reject(err) : resolve())
269
+ );
270
+ },
271
+ };
272
+ }
273
+
274
+
275
+ /**
276
+ * Generate a citation key from first author + year
277
+ * Example: "Roomruangwong2020"
278
+ */
279
+ function generateCitationKey(ref) {
280
+ let author = 'Anon';
281
+ if (ref.authors && ref.authors.length > 0) {
282
+ author = ref.authors[0].split(',')[0];
283
+ }
284
+
285
+ let year = 'n.d.';
286
+ if (ref.year) {
287
+ year = ref.year;
288
+ }
289
+
290
+ return `${author}${year}`;
291
+ }
292
+
293
+
294
+ /**
295
+ * Lookup tables for this module
296
+ * @type {Object}
297
+ * @property {Array<Object>} fields Field translations between Reflib (`rl`) and BibTeX format (`bt`)
298
+ */
299
+ export let translations = {
300
+ // Field translations {{{
301
+ fields: {
302
+ collection: [
303
+ // Order by priority (highest at top)
304
+ {rl: 'address', bt: 'address'},
305
+ {rl: 'authors', bt: 'author', array: true},
306
+ {rl: 'doi', bt: 'doi'},
307
+ {rl: 'edition', bt: 'edition'},
308
+ {rl: 'editor', bt: 'editor'},
309
+ {rl: 'journal', bt: 'journal'},
310
+ {rl: 'notes', bt: 'note'},
311
+ {rl: 'number', bt: 'number'},
312
+ {rl: 'pages', bt: 'pages'},
313
+ {rl: 'title', bt: 'booktitle'},
314
+ {rl: 'title', bt: 'title'},
315
+ {rl: 'volume', bt: 'volume'},
316
+ {rl: 'isbn', bt: 'issn'},
317
+
318
+ // Misc
319
+ {bt: 'month'}, // Combined into {rl:'date'}
320
+ {bt: 'type'}, // Ignored
321
+ {bt: 'year'}, // Combined into {rl:'date'}
322
+
323
+ // Nonestandard but used anyway
324
+ {rl: 'abstract', bt: 'abstract'},
325
+ {rl: 'language', bt: 'language'},
326
+ {rl: 'keywords', bt: 'keywords', array: true},
327
+ {rl: 'urls', bt: 'url', array: true},
328
+
329
+ // Unknown how to translate these
330
+ // {bt: 'annote'},
331
+ // {bt: 'email'},
332
+ // {bt: 'chapter'},
333
+ // {bt: 'crossref'},
334
+ // {bt: 'howpublished'},
335
+ // {bt: 'institution'},
336
+ // {bt: 'key'},
337
+ // {bt: 'organization'},
338
+ // {bt: 'publisher'},
339
+ // {bt: 'school'},
340
+ // {bt: 'series'},
341
+ ],
342
+ rlMap: new Map(),
343
+ btMap: new Map(),
344
+ },
345
+ // }}}
346
+ // Ref type translations {{{
347
+ types: {
348
+ collection: [
349
+ // Order by priority (highest at top)
350
+ {rl: 'journalArticle', bt: 'Article'},
351
+ {rl: 'book', bt: 'Book'},
352
+ {rl: 'bookSection', bt: 'InBook'},
353
+ {rl: 'conferencePaper', bt: 'Conference'},
354
+ {rl: 'conferenceProceedings', bt: 'InProceedings'},
355
+ {rl: 'report', bt: 'TechReport'},
356
+ {rl: 'thesis', bt: 'PHDThesis'},
357
+ {rl: 'unknown', bt: 'Misc'},
358
+ {rl: 'unpublished', bt: 'Unpublished'},
359
+
360
+ // Type aliases
361
+ {rl: 'journalArticle', bt: 'Journal Article'},
362
+
363
+ // Unknown how to translate these
364
+ {rl: 'Misc', bt: 'Booklet'},
365
+ {rl: 'Misc', bt: 'InCollection'},
366
+ {rl: 'Misc', bt: 'Manual'},
367
+ {rl: 'Misc', bt: 'MastersThesis'},
368
+ {rl: 'Misc', bt: 'Proceedings'},
369
+ ],
370
+ rlMap: new Map(),
371
+ btMap: new Map(),
372
+ },
373
+ // }}}
374
+ };
375
+
376
+
377
+ /**
378
+ * @see modules/interface.js
379
+ */
380
+ export function setup() {
381
+ // Create lookup object of translations.fields with key as .rl / val as the full object
382
+ translations.fields.collection.forEach(c => {
383
+ if (c.rl) translations.fields.rlMap.set(c.rl.toLowerCase(), c);
384
+ if (c.bt) translations.fields.btMap.set(c.bt, c);
385
+ });
386
+
387
+ // Create lookup object of ref.types with key as .rl / val as the full object
388
+ translations.types.collection.forEach(c => {
389
+ // Append each type to the set, accepting the first in each case as the priority
390
+ let rlLc = c.rl.toLowerCase();
391
+ let btLc = c.bt.toLowerCase();
392
+ if (c.rl && !translations.types.rlMap.has(rlLc)) translations.types.rlMap.set(rlLc, c);
393
+ if (c.bt && !translations.types.btMap.has(btLc)) translations.types.btMap.set(btLc, c);
394
+ });
395
+ }