@iebh/reflib 2.6.2 → 2.6.4

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