@iebh/reflib 2.7.2 → 2.8.1

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,474 +1,410 @@
1
- import camelCase from '../shared/camelCase.js';
2
- import Emitter from '../shared/emitter.js';
3
-
4
- // This import is overwritten by the 'browser' field in package.json with the shimmed version
5
- import { WritableStream as XMLParser } from 'htmlparser2/lib/WritableStream';
6
-
7
- /**
8
- * Read an EndnoteXML file, returning an Emitter analogue
9
- *
10
- * @see modules/inhterface.js
11
- * @param {Stream} stream Stream primative to encapsulate
12
- * @returns {Object} An Emitter analogue defined in `../shared/Emitter.js`
13
- */
14
- export function readStream(stream) {
15
- let emitter = Emitter();
16
-
17
- /**
18
- * The current reference being appended to
19
- * @type {Object}
20
- */
21
- let ref = {};
22
-
23
-
24
- /**
25
- * Stack of nodes we are currently traversed into
26
- * @type {array<Object>}
27
- */
28
- let stack = [];
29
-
30
-
31
- /**
32
- * Whether to append incoming text blocks to the previous block
33
- * This is necessary as XMLParser splits text into multiple calls so we need to know whether to append or treat this item as a continuation of the previous one
34
- * @type {boolean}
35
- */
36
- let textAppend = false;
37
-
38
- /**
39
- * The options/callbacks for the parser
40
- * @type {Object}
41
- */
42
- let parserOptions = {
43
- onopentag(name, attrs) {
44
- textAppend = false;
45
- stack.push({
46
- name: camelCase(name),
47
- attrs,
48
- });
49
- },
50
- onclosetag(name) {
51
- if (name == 'record') {
52
- if (ref.title) ref.title = ref.title // htmlparser2 handles the '<title>' tag in a really bizarre way so we have to pull apart the <style> bits when parsing
53
- .replace(/^.*<style.*>(.*)<\/style>.*$/m, '$1')
54
- .replace(/^\s+/, '')
55
- .replace(/\s+$/, '')
56
- emitter.emit('ref', translateRawToRef(ref));
57
- stack = []; // Trash entire stack when hitting end of <record/> node
58
- ref = {}; // Reset the ref state
59
- } else {
60
- stack.pop();
61
- }
62
- },
63
- ontext(text) {
64
- let parentName = stack.at(-1)?.name;
65
- let gParentName = stack.at(-2)?.name;
66
- if (text && text.startsWith('\n')) { // Need to crop text - likely a prettified XML output or Zotero style XML file
67
- text = text
68
- .replace(/^\n\s*/gm, '')
69
- .replace(/\n\s*$/gm, '')
70
- }
71
-
72
- if (parentName == 'title') {
73
- if (textAppend) {
74
- ref.title += text;
75
- } else {
76
- ref.title = text;
77
- }
78
- } else if (
79
- (parentName == 'style' && gParentName == 'author')
80
- || (parentName == 'author' && text)
81
- ) {
82
- if (!ref.authors) ref.authors = [];
83
- if (textAppend) {
84
- ref.authors[ref.authors.length - 1] += text;
85
- } else {
86
- ref.authors.push(text);
87
- }
88
- } else if (
89
- (parentName == 'style' && gParentName == 'keyword')
90
- || (parentName == 'keyword' && text)
91
- ) {
92
- if (!ref.keywords) ref.keywords = [];
93
- if (textAppend) {
94
- ref.keywords[ref.keywords.length - 1] += text;
95
- } else {
96
- ref.keywords.push(text);
97
- }
98
- } else if (
99
- (parentName == 'style' && gParentName == 'url')
100
- || (parentName == 'url' && text)
101
- ) {
102
- if (!ref.urls) ref.urls = [];
103
- if (textAppend) {
104
- ref.urls[ref.urls.length - 1] += text;
105
- } else {
106
- ref.urls.push(text);
107
- }
108
- } else if (parentName == 'style' && translations.fields.rawMap.has(gParentName)) { // Text within <style/> tag
109
- if (textAppend || ref[gParentName]) { // Text already exists? Append (handles node-expats silly multi-text per escape character "feature")
110
- ref[gParentName] += text;
111
- } else {
112
- ref[gParentName] = text;
113
- }
114
- } else if (['recNumber', 'refType'].includes(parentName)) { // Simple setters like <rec-number/>
115
- if (textAppend || ref[parentName]) {
116
- ref[parentName] += text;
117
- } else {
118
- ref[parentName] = text;
119
- }
120
- } else if (text && translations.fields.rawMap.has(parentName) && !['authors', 'keyword', 'url'].includes(parentName)) { // Zotero style simple field allocation
121
- ref[parentName] = text;
122
- } else if (gParentName == 'titles' && parentName == 'secondaryTitle' && text) { // Zotero "Journal" field translation
123
- ref.secondaryTitle = text;
124
- } // Implied else - ignore node entirely, likely a parent node containing children we actually want to process
125
- textAppend = true; // Always set the next call to the text emitter handler as an append operation
126
- },
127
- onend() {
128
- emitter.emit('end');
129
- }
130
- }
131
-
132
- // Queue up the parser in the next tick (so we can return the emitter first)
133
- setTimeout(() => {
134
- if (typeof stream.pipe === 'function') {
135
- let parser = new XMLParser(parserOptions, {
136
- decodeEntities: false, // htmlparser2 chokes if the input has unescaped '<' or '>' in the input - which Zotero does, so we have to handle this ourselves
137
- xmlMode: true, // Needed to handle self-closing tags
138
- });
139
- stream.on('data', ()=> emitter.emit('progress', stream.bytesRead))
140
- stream.pipe(parser)
141
- return;
142
- } else {
143
- console.error('Error with stream, check "streamEmitter.js" if on browser')
144
- }
145
- })
146
-
147
- return emitter;
148
- }
149
-
150
-
151
- /**
152
- * Write references to a file
153
- *
154
- * @see modules/interface.js
155
- *
156
- * @param {Stream} stream Writable stream to output to
157
- * @param {Object} [options] Additional options to use when parsing
158
- * @param {string} [options.defaultType='journalArticle'] Default citation type to assume when no other type is specified
159
- * @param {string} [options.filePath="c:\\"] "Fake" internal source file path the citation library was exported from, must end with backslashes
160
- * @param {string} [options.fileName="EndNote.enl"] "Fake" internal source file name the citation library was exported from
161
- * @param {function} [options.formatDate] Date formatter to translate between a JS Date object and the EndNote YYYY-MM-DD format
162
- *
163
- * @returns {Object} A writable stream analogue defined in `modules/interface.js`
164
- */
165
- export function writeStream(stream, options) {
166
- let settings = {
167
- defaultType: 'journalArticle',
168
- filePath: 'c:\\',
169
- fileName: 'EndNote.enl',
170
- formatDate: value => value instanceof Date ? value.toISOString().substr(0, 10) : value,
171
- ...options,
172
- };
173
-
174
- // Cached values so we don't need to keep recomputing
175
- let encodedName = xmlEscape(settings.fileName);
176
- let refsSeen = 0;
177
-
178
- return {
179
- start: ()=> {
180
- stream.write('<?xml version="1.0" encoding="UTF-8" ?><xml><records>');
181
- return Promise.resolve();
182
- },
183
- write: ref => {
184
- let refType = translations.types.rlMap.get(ref.type || settings.defaultType);
185
- if (!refType) {
186
- console.warn(`Invalid reference type: "${ref.type}", defaulting to journal article`);
187
- refType = translations.types.rlMap.get('journalArticle')
188
- }
189
-
190
- refsSeen++;
191
- let recNumber = ref.recNumber || refsSeen;
192
-
193
- stream.write(
194
- '<record>'
195
- // Preamble
196
- + `<database name="${settings.fileName}" path="${settings.filePath}${settings.fileName}">${encodedName}</database>`
197
- + `<source-app name="EndNote" version="16.0">EndNote</source-app>`
198
- + `<rec-number>${recNumber}</rec-number>`
199
- + `<foreign-keys><key app="EN" db-id="s55prpsswfsepue0xz25pxai2p909xtzszzv">${recNumber}</key></foreign-keys>`
200
-
201
- // Type
202
- + `<ref-type name="${refType.rawText}">${refType.rawId}</ref-type>`
203
-
204
- // Authors
205
- + '<contributors><authors>'
206
- + (ref.authors || []).map(author => `<author><style face="normal" font="default" size="100%">${xmlEscape(author)}</style></author>`)
207
- + '</authors></contributors>'
208
-
209
- // Titles
210
- + '<titles>'
211
- + (ref.title ? `<title><style face="normal" font="default" size="100%">${xmlEscape(ref.title)}</style></title>` : '')
212
- + (ref.journal ? `<secondary-title><style face="normal" font="default" size="100%">${xmlEscape(ref.journal)}</style></secondary-title>` : '')
213
- + (ref.titleShort ? `<short-title><style face="normal" font="default" size="100%">${xmlEscape(ref.titleShort)}</style></short-title>` : '')
214
- + (ref.journalAlt ? `<alt-title><style face="normal" font="default" size="100%">${xmlEscape(ref.journalAlt)}</style></alt-title>` : '')
215
- + '</titles>'
216
-
217
- // Periodical
218
- + (ref.periodical ? `<periodical><full-title><style face="normal" font="default" size="100%">${xmlEscape(ref.periodical)}</style></full-title></periodical>` : '')
219
-
220
- // Simple field key/vals
221
- + [
222
- ['abstract', 'abstract'],
223
- ['accessDate', 'access-date'],
224
- ['accession', 'accession-num'],
225
- ['address', 'auth-address'],
226
- ['caption', 'caption'],
227
- ['databaseProvider', 'remote-database-provider'],
228
- ['database', 'remote-database-name'],
229
- ['doi', 'electronic-resource-num'],
230
- ['isbn', 'isbn'],
231
- ['accessionNum', 'accession-num'],
232
- ['label', 'label'],
233
- ['language', 'language'],
234
- ['notes', 'notes'],
235
- ['number', 'number'],
236
- ['pages', 'pages'],
237
- ['researchNotes', 'research-notes'],
238
- ['section', 'section'],
239
- ['volume', 'volume'],
240
- ['workType', 'work-type'],
241
- ['custom1', 'custom1'],
242
- ['custom2', 'custom2'],
243
- ['custom3', 'custom3'],
244
- ['custom4', 'custom4'],
245
- ['custom5', 'custom5'],
246
- ['custom6', 'custom6'],
247
- ['custom7', 'custom7'],
248
- ]
249
- .filter(([rlKey]) => ref[rlKey]) // Remove empty fields
250
- .map(([rlKey, rawKey]) =>
251
- `<${rawKey}><style face="normal" font="default" size="100%">${xmlEscape(ref[rlKey])}</style></${rawKey}>`
252
- )
253
- .join('')
254
-
255
- // Dates
256
- + (
257
- ref.date && ref.year && ref.date instanceof Date ?
258
- `<dates><year><style face="normal" font="default" size="100%">${xmlEscape(ref.year)}</style></year>`
259
- + `<pub-dates><date><style face="normal" font="default" size="100%">${settings.formatDate(ref.date)}</style></date></pub-dates></dates>`
260
- : ref.date && ref.year ?
261
- `<dates><year><style face="normal" font="default" size="100%">${xmlEscape(ref.year)}</style></year>`
262
- + `<pub-dates><date><style face="normal" font="default" size="100%">${ref.date}</style></date></pub-dates></dates>`
263
- : ref.date ?
264
- `<dates><pub-dates><date><style face="normal" font="default" size="100%">${xmlEscape(ref.date)}</style></date></pub-dates></dates>`
265
- : ref.year ?
266
- `<dates><year><style face="normal" font="default" size="100%">${xmlEscape(ref.year)}</style></year></dates>`
267
- : ''
268
- )
269
-
270
- // Urls
271
- + (ref.urls ?
272
- '<urls><related-urls>'
273
- + [].concat(ref.urls || [])
274
- .map(url => `<url><style face="normal" font="default" size="100%">${xmlEscape(url)}</style></url>`)
275
- .join('')
276
- + '</related-urls></urls>'
277
- : '')
278
-
279
- // Keywords
280
- + (ref.keywords ?
281
- '<keywords>'
282
- + [].concat(ref.keywords || [])
283
- .map(keyword => `<keyword><style face="normal" font="default" size="100%">${xmlEscape(keyword)}</style></keyword>`)
284
- .join('')
285
- + '</keywords>'
286
- : '')
287
-
288
- + '</record>'
289
- );
290
- return Promise.resolve();
291
- },
292
- end: ()=> {
293
- stream.write('</records></xml>');
294
- return new Promise((resolve, reject) =>
295
- stream.end(err => err ? reject(err) : resolve())
296
- );
297
- },
298
- };
299
- }
300
-
301
-
302
- /**
303
- * Utility function to take the raw XML output object and translate it into a Reflib object
304
- * @param {Object} xRef Raw XML object to process
305
- * @returns {Object} The translated Reflib object output
306
- */
307
- export function translateRawToRef(xRef) {
308
- let recOut = {
309
- ...Object.fromEntries(
310
- translations.fields.collection
311
- .filter(field => xRef[field.raw]) // Only include fields we have a value for
312
- .map(field => [ // Translate Raw -> Reflib spec
313
- field.rl,
314
- Array.isArray(xRef[field.raw]) ? xRef[field.raw].map(xmlUnescape)
315
- : xmlUnescape(xRef[field.raw])
316
- ])
317
- ),
318
- type: translations.types.rawMap.get(+xRef.refType || 17)?.rl,
319
- };
320
-
321
- return recOut;
322
- }
323
-
324
-
325
- /**
326
- * Default string -> XML encoder
327
- * @param {string} str The input string to encode
328
- * @returns {string} The XML "safe" string
329
- */
330
- export function xmlEscape(str) {
331
- return ('' + str)
332
- .replace(/&/g, '&amp;')
333
- .replace(/\r/g, '&#13;')
334
- .replace(/</g, '&lt;')
335
- .replace(/>/g, '&gt;')
336
- .replace(/"/g, '&quot;')
337
- .replace(/'/g, '&apos;');
338
- }
339
-
340
-
341
- /**
342
- * Default XML -> string decodeer
343
- * @param {string} str The input string to decode
344
- * @returns {string} The actual string
345
- */
346
- export function xmlUnescape(str) {
347
- return ('' + str)
348
- .replace(/&amp;/g, '&')
349
- .replace(/&#(xD|13);/g, '\r')
350
- .replace(/&lt;/g, '<')
351
- .replace(/&gt;/g, '>')
352
- .replace(/&quot;/g, '"')
353
- .replace(/&apos;/g, "'")
354
- .replace(/\s+$/gm, '') // Trim line-end whitespace
355
- }
356
-
357
-
358
- /**
359
- * Lookup tables for this module
360
- * @type {Object}
361
- * @property {array<Object>} fields Field translations between Reflib (`rl`) and the raw format (`raw`)
362
- * @property {array<Object>} types Field translations between Reflib (`rl`) and the raw format types as raw text (`rawText`) and numeric ID (`rawId`)
363
- */
364
- export let translations = {
365
- // Field translations {{{
366
- fields: {
367
- collection: [
368
- // Field translations in priority order (EndNote first then Zotero)
369
- {rl: 'recNumber', raw: 'recNumber'},
370
- {rl: 'title', raw: 'title'},
371
- {rl: 'journal', raw: 'secondaryTitle'},
372
- {rl: 'address', raw: 'authAddress'},
373
- {rl: 'researchNotes', raw: 'researchNotes'},
374
- {rl: 'type', raw: 'FIXME'},
375
- {rl: 'authors', raw: 'authors'},
376
- {rl: 'pages', raw: 'pages'},
377
- {rl: 'volume', raw: 'volume'},
378
- {rl: 'number', raw: 'number'},
379
- {rl: 'isbn', raw: 'isbn'},
380
- {rl: 'accessionNum', raw: 'accessionNum'},
381
- {rl: 'abstract', raw: 'abstract'},
382
- {rl: 'label', raw: 'label'},
383
- {rl: 'caption', raw: 'caption'},
384
- {rl: 'notes', raw: 'notes'},
385
- {rl: 'custom1', raw: 'custom1'},
386
- {rl: 'custom2', raw: 'custom2'},
387
- {rl: 'custom3', raw: 'custom3'},
388
- {rl: 'custom4', raw: 'custom4'},
389
- {rl: 'custom5', raw: 'custom5'},
390
- {rl: 'custom6', raw: 'custom6'},
391
- {rl: 'custom7', raw: 'custom7'},
392
- {rl: 'doi', raw: 'electronicResourceNum'},
393
- {rl: 'year', raw: 'year'},
394
- {rl: 'date', raw: 'date'},
395
- {rl: 'keywords', raw: 'keywords'},
396
- {rl: 'urls', raw: 'urls'},
397
- ],
398
- rawMap: new Map(), // Calculated later for quicker lookup
399
- },
400
- // }}}
401
- // Ref type translations {{{
402
- types: {
403
- collection: [
404
- {rl: 'aggregatedDatabase', rawText: 'Aggregated Database', rawId: 55},
405
- {rl: 'ancientText', rawText: 'Ancient Text', rawId: 51},
406
- {rl: 'artwork', rawText: 'Artwork', rawId: 2},
407
- {rl: 'audioVisualMaterial', rawText: 'Audiovisual Material', rawId: 3},
408
- {rl: 'bill', rawText: 'Bill', rawId: 4},
409
- {rl: 'blog', rawText: 'Blog', rawId: 56},
410
- {rl: 'book', rawText: 'Book', rawId: 6},
411
- {rl: 'bookSection', rawText: 'Book Section', rawId: 5},
412
- {rl: 'case', rawText: 'Case', rawId: 7},
413
- {rl: 'catalog', rawText: 'Catalog', rawId: 8},
414
- {rl: 'chartOrTable', rawText: 'Chart or Table', rawId: 38},
415
- {rl: 'classicalWork', rawText: 'Classical Work', rawId: 49},
416
- {rl: 'computerProgram', rawText: 'Computer Program', rawId: 9},
417
- {rl: 'conferencePaper', rawText: 'Conference Paper', rawId: 47},
418
- {rl: 'conferenceProceedings', rawText: 'Conference Proceedings', rawId: 10},
419
- {rl: 'dataset', rawText: 'Dataset', rawId: 59},
420
- {rl: 'dictionary', rawText: 'Dictionary', rawId: 52},
421
- {rl: 'editedBook', rawText: 'Edited Book', rawId: 28},
422
- {rl: 'electronicArticle', rawText: 'Electronic Article', rawId: 43},
423
- {rl: 'electronicBook', rawText: 'Electronic Book', rawId: 44},
424
- {rl: 'electronicBookSection', rawText: 'Electronic Book Section', rawId: 60},
425
- {rl: 'encyclopedia', rawText: 'Encyclopedia', rawId: 53},
426
- {rl: 'equation', rawText: 'Equation', rawId: 39},
427
- {rl: 'figure', rawText: 'Figure', rawId: 37},
428
- {rl: 'filmOrBroadcast', rawText: 'Film or Broadcast', rawId: 21},
429
- {rl: 'generic', rawText: 'Generic', rawId: 13},
430
- {rl: 'governmentDocument', rawText: 'Government Document', rawId: 46},
431
- {rl: 'grant', rawText: 'Grant', rawId: 54},
432
- {rl: 'hearing', rawText: 'Hearing', rawId: 14},
433
- {rl: 'journalArticle', rawText: 'Journal Article', rawId: 17},
434
- {rl: 'legalRuleOrRegulation', rawText: 'Legal Rule or Regulation', rawId: 50},
435
- {rl: 'magazineArticle', rawText: 'Magazine Article', rawId: 19},
436
- {rl: 'manuscript', rawText: 'Manuscript', rawId: 36},
437
- {rl: 'map', rawText: 'Map', rawId: 20},
438
- {rl: 'music', rawText: 'Music', rawId: 61},
439
- {rl: 'newspaperArticle', rawText: 'Newspaper Article', rawId: 23},
440
- {rl: 'onlineDatabase', rawText: 'Online Database', rawId: 45},
441
- {rl: 'onlineMultimedia', rawText: 'Online Multimedia', rawId: 48},
442
- {rl: 'pamphlet', rawText: 'Pamphlet', rawId: 24},
443
- {rl: 'patent', rawText: 'Patent', rawId: 25},
444
- {rl: 'personalCommunication', rawText: 'Personal Communication', rawId: 26},
445
- {rl: 'report', rawText: 'Report', rawId: 27},
446
- {rl: 'serial', rawText: 'Serial', rawId: 57},
447
- {rl: 'standard', rawText: 'Standard', rawId: 58},
448
- {rl: 'statute', rawText: 'Statute', rawId: 31},
449
- {rl: 'thesis', rawText: 'Thesis', rawId: 32},
450
- {rl: 'unpublished', rawText: 'Unpublished Work', rawId: 34},
451
- {rl: 'web', rawText: 'Web Page', rawId: 12},
452
- ],
453
- rlMap: new Map(), // Calculated later for quicker lookup
454
- rawMap: new Map(), // Calculated later for quicker lookup
455
- },
456
- // }}}
457
- };
458
-
459
-
460
- /**
461
- * @see modules/interface.js
462
- */
463
- export function setup() {
464
- // Create lookup object of translations.field translations
465
- translations.fields.collection.forEach(c => {
466
- translations.fields.rawMap.set(c.raw, c);
467
- });
468
-
469
- // Create lookup object of translations.types with key as .rl / val as the full object
470
- translations.types.collection.forEach(c => {
471
- translations.types.rlMap.set(c.rl, c);
472
- translations.types.rawMap.set(c.rawId, c);
473
- });
474
- }
1
+ import Emitter from '../shared/emitter.js';
2
+ import XMLParser from '@iebh/cacx';
3
+
4
+ /**
5
+ * Read an EndnoteXML file, returning an Emitter analogue
6
+ *
7
+ * @see modules/inhterface.js
8
+ * @param {Stream} stream Stream primative to encapsulate
9
+ * @returns {Object} An Emitter analogue defined in `../shared/Emitter.js`
10
+ */
11
+ export function readStream(stream) {
12
+ let emitter = Emitter();
13
+
14
+ /**
15
+ * The current reference being appended to
16
+ * @type {Object}
17
+ */
18
+ let ref = {};
19
+
20
+
21
+ // Setup the XML parser
22
+ let parserOptions = {
23
+ flattenText: false,
24
+ flattenChildren: false,
25
+ flattenAttrs: false,
26
+ onTagClose(node, stack) {
27
+ if (node.tag == 'title' && node.text) {
28
+ ref.title = node.text;
29
+ } else if (node.tag == 'author' && node.text) {
30
+ if (!ref.authors) ref.authors = [];
31
+ ref.authors.push(node.text);
32
+ } else if (node.tag == 'keyword' && node.text) {
33
+ if (!ref.keywords) ref.keywords = [];
34
+ ref.keywords.push(node.text);
35
+ } else if (node.tag == 'url' && node.text) {
36
+ if (!ref.urls) ref.urls = [];
37
+ ref.urls.push(node.text);
38
+ } else if (
39
+ !['authors', 'keywords', 'urls'].includes(node.tag) // Not one of the above collectors
40
+ && translations.fields.rawMap.has(node.tag) // AND other supported field mapping
41
+ && node.text // AND there is some content to populate
42
+ ) {
43
+ ref[translations.fields.rawMap.get(node.tag).rl] = node.text;
44
+ } else if (node.tag == 'ref-type') { // Special EndnoteXML reference lookup
45
+ let rlType = translations.types.rawMap.get(node.attrs.name);
46
+ ref.type = rlType?.rl || 'journalArticle'; // It should never happen that we have an unknown type but default to something sane if we ever see one
47
+ } else if (node.tag == 'secondary-title' && node.text) { // Zotero "Journal" field translation
48
+ let rlType = translations.types.rawMap.get(node.text);
49
+ ref.type = rlType?.rl || 'journalArticle'; // It should never happen that we have an unknown type but default to something sane if we ever see one
50
+ } else if (node.tag == 'style' && node.text) { // Embedded <style> tag Endnote seems to wrap all inner values with these even though they dont serve any purpose
51
+ // Re-call onTagClose() using the parent node instead
52
+ let outerNode = { // Create shallow copy of 'real' parent node
53
+ ...stack.at(-2),
54
+ text: node.text, // ... but copy in this nodes text as if this inner `<style>` wrapper didn't exist
55
+ };
56
+ if (!outerNode) throw new Error('<style> tag with orphaned children!');
57
+ parserOptions.onTagClose(outerNode, stack.slice(0, -1));
58
+ } else if (node.tag == 'record') { // End of record - emit the ref and clear tracking state
59
+ emitter.emit('ref', ref);
60
+ ref = {};
61
+ }
62
+ },
63
+ };
64
+ let parser = new XMLParser(parserOptions);
65
+
66
+
67
+ // Queue up the parser in the next tick (so we can return the emitter first)
68
+ setTimeout(() => {
69
+ if (typeof stream.pipe === 'function') {
70
+ stream.on('data', data => {
71
+ parser.append(data.toString()) // Push new data onto XML decode stack (converting from Buffer -> String)
72
+ emitter.emit('progress', stream.bytesRead);
73
+ })
74
+ stream.on('end', ()=> {
75
+ parser.exec();
76
+ emitter.emit('end');
77
+ });
78
+ stream.on('error', e => emitter.emit('error', e))
79
+ return;
80
+ } else {
81
+ console.error('Error with stream, check "streamEmitter.js" if on browser')
82
+ }
83
+ });
84
+
85
+ return emitter;
86
+ }
87
+
88
+
89
+ /**
90
+ * Write references to a file
91
+ *
92
+ * @see modules/interface.js
93
+ *
94
+ * @param {Stream} stream Writable stream to output to
95
+ * @param {Object} [options] Additional options to use when parsing
96
+ * @param {string} [options.defaultType='journalArticle'] Default citation type to assume when no other type is specified
97
+ * @param {string} [options.filePath="c:\\"] "Fake" internal source file path the citation library was exported from, must end with backslashes
98
+ * @param {string} [options.fileName="Endnote.enl"] "Fake" internal source file name the citation library was exported from
99
+ * @param {function} [options.formatDate] Date formatter to translate between a JS Date object and the Endnote YYYY-MM-DD format
100
+ *
101
+ * @returns {Object} A writable stream analogue defined in `modules/interface.js`
102
+ */
103
+ export function writeStream(stream, options) {
104
+ let settings = {
105
+ defaultType: 'journalArticle',
106
+ filePath: 'c:\\',
107
+ fileName: 'Endnote.enl',
108
+ formatDate: value => value instanceof Date ? value.toISOString().slice(0, 10) : value,
109
+ ...options,
110
+ };
111
+
112
+ // Cached values so we don't need to keep recomputing
113
+ let encodedName = xmlEscape(settings.fileName);
114
+ let refsSeen = 0;
115
+
116
+ return {
117
+ start: ()=> {
118
+ stream.write('<?xml version="1.0" encoding="UTF-8" ?><xml><records>');
119
+ return Promise.resolve();
120
+ },
121
+ write: ref => {
122
+ let refType = translations.types.rlMap.get(ref.type || settings.defaultType);
123
+ if (!refType) {
124
+ console.warn(`Invalid reference type: "${ref.type}", defaulting to journal article`);
125
+ refType = translations.types.rlMap.get('journalArticle')
126
+ }
127
+
128
+ refsSeen++;
129
+ let recNumber = ref.recNumber || refsSeen;
130
+
131
+ stream.write(
132
+ '<record>'
133
+ // Preamble
134
+ + `<database name="${settings.fileName}" path="${settings.filePath}${settings.fileName}">${encodedName}</database>`
135
+ + `<source-app name="EndNote" version="16.0">EndNote</source-app>`
136
+ + `<rec-number>${recNumber}</rec-number>`
137
+ + `<foreign-keys><key app="EN" db-id="s55prpsswfsepue0xz25pxai2p909xtzszzv">${recNumber}</key></foreign-keys>`
138
+
139
+ // Type
140
+ + `<ref-type name="${refType.rawText}">${refType.rawId}</ref-type>`
141
+
142
+ // Authors
143
+ + '<contributors><authors>'
144
+ + (ref.authors || []).map(author => `<author><style face="normal" font="default" size="100%">${xmlEscape(author)}</style></author>`)
145
+ + '</authors></contributors>'
146
+
147
+ // Titles
148
+ + '<titles>'
149
+ + (ref.title ? `<title><style face="normal" font="default" size="100%">${xmlEscape(ref.title)}</style></title>` : '')
150
+ + (ref.journal ? `<secondary-title><style face="normal" font="default" size="100%">${xmlEscape(ref.journal)}</style></secondary-title>` : '')
151
+ + (ref.titleShort ? `<short-title><style face="normal" font="default" size="100%">${xmlEscape(ref.titleShort)}</style></short-title>` : '')
152
+ + (ref.journalAlt ? `<alt-title><style face="normal" font="default" size="100%">${xmlEscape(ref.journalAlt)}</style></alt-title>` : '')
153
+ + '</titles>'
154
+
155
+ // Periodical
156
+ + (ref.periodical ? `<periodical><full-title><style face="normal" font="default" size="100%">${xmlEscape(ref.periodical)}</style></full-title></periodical>` : '')
157
+
158
+ // Simple field key/vals
159
+ + [
160
+ ['abstract', 'abstract'],
161
+ ['accessDate', 'access-date'],
162
+ ['accession', 'accession-num'],
163
+ ['address', 'auth-address'],
164
+ ['caption', 'caption'],
165
+ ['databaseProvider', 'remote-database-provider'],
166
+ ['database', 'remote-database-name'],
167
+ ['doi', 'electronic-resource-num'],
168
+ ['isbn', 'isbn'],
169
+ ['accessionNum', 'accession-num'],
170
+ ['label', 'label'],
171
+ ['language', 'language'],
172
+ ['notes', 'notes'],
173
+ ['number', 'number'],
174
+ ['pages', 'pages'],
175
+ ['researchNotes', 'research-notes'],
176
+ ['section', 'section'],
177
+ ['volume', 'volume'],
178
+ ['workType', 'work-type'],
179
+ ['custom1', 'custom1'],
180
+ ['custom2', 'custom2'],
181
+ ['custom3', 'custom3'],
182
+ ['custom4', 'custom4'],
183
+ ['custom5', 'custom5'],
184
+ ['custom6', 'custom6'],
185
+ ['custom7', 'custom7'],
186
+ ]
187
+ .filter(([rlKey]) => ref[rlKey]) // Remove empty fields
188
+ .map(([rlKey, rawKey]) =>
189
+ `<${rawKey}><style face="normal" font="default" size="100%">${xmlEscape(ref[rlKey])}</style></${rawKey}>`
190
+ )
191
+ .join('')
192
+
193
+ // Dates
194
+ + (
195
+ ref.date && ref.year && ref.date instanceof Date ?
196
+ `<dates><year><style face="normal" font="default" size="100%">${xmlEscape(ref.year)}</style></year>`
197
+ + `<pub-dates><date><style face="normal" font="default" size="100%">${settings.formatDate(ref.date)}</style></date></pub-dates></dates>`
198
+ : ref.date && ref.year ?
199
+ `<dates><year><style face="normal" font="default" size="100%">${xmlEscape(ref.year)}</style></year>`
200
+ + `<pub-dates><date><style face="normal" font="default" size="100%">${ref.date}</style></date></pub-dates></dates>`
201
+ : ref.date ?
202
+ `<dates><pub-dates><date><style face="normal" font="default" size="100%">${xmlEscape(ref.date)}</style></date></pub-dates></dates>`
203
+ : ref.year ?
204
+ `<dates><year><style face="normal" font="default" size="100%">${xmlEscape(ref.year)}</style></year></dates>`
205
+ : ''
206
+ )
207
+
208
+ // Urls
209
+ + (ref.urls ?
210
+ '<urls><related-urls>'
211
+ + [].concat(ref.urls || [])
212
+ .map(url => `<url><style face="normal" font="default" size="100%">${xmlEscape(url)}</style></url>`)
213
+ .join('')
214
+ + '</related-urls></urls>'
215
+ : '')
216
+
217
+ // Keywords
218
+ + (ref.keywords ?
219
+ '<keywords>'
220
+ + [].concat(ref.keywords || [])
221
+ .map(keyword => `<keyword><style face="normal" font="default" size="100%">${xmlEscape(keyword)}</style></keyword>`)
222
+ .join('')
223
+ + '</keywords>'
224
+ : '')
225
+
226
+ + '</record>'
227
+ );
228
+ return Promise.resolve();
229
+ },
230
+ end: ()=> {
231
+ stream.write('</records></xml>');
232
+ return new Promise((resolve, reject) =>
233
+ stream.end(err => err ? reject(err) : resolve())
234
+ );
235
+ },
236
+ };
237
+ }
238
+
239
+
240
+ /**
241
+ * Utility function to take the raw XML output object and translate it into a Reflib object
242
+ * @param {Object} xRef Raw XML object to process
243
+ * @returns {Object} The translated Reflib object output
244
+ */
245
+ export function translateRawToRef(xRef) {
246
+ let recOut = {
247
+ ...Object.fromEntries(
248
+ translations.fields.collection
249
+ .filter(field => xRef[field.raw]) // Only include fields we have a value for
250
+ .map(field => [ // Translate Raw -> Reflib spec
251
+ field.rl,
252
+ Array.isArray(xRef[field.raw]) ? xRef[field.raw].map(xmlUnescape)
253
+ : xmlUnescape(xRef[field.raw])
254
+ ])
255
+ ),
256
+ type: translations.types.rawMap.get(+xRef.refType || 17)?.rl,
257
+ };
258
+
259
+ return recOut;
260
+ }
261
+
262
+
263
+ /**
264
+ * Default string -> XML encoder
265
+ * @param {string} str The input string to encode
266
+ * @returns {string} The XML "safe" string
267
+ */
268
+ export function xmlEscape(str) {
269
+ return ('' + str)
270
+ .replace(/&/g, '&amp;')
271
+ .replace(/\r/g, '&#13;')
272
+ .replace(/</g, '&lt;')
273
+ .replace(/>/g, '&gt;')
274
+ .replace(/"/g, '&quot;')
275
+ .replace(/'/g, '&apos;');
276
+ }
277
+
278
+
279
+ /**
280
+ * Default XML -> string decodeer
281
+ * @param {string} str The input string to decode
282
+ * @returns {string} The actual string
283
+ */
284
+ export function xmlUnescape(str) {
285
+ return ('' + str)
286
+ .replace(/&amp;/g, '&')
287
+ .replace(/&#(xD|13);/g, '\r')
288
+ .replace(/&lt;/g, '<')
289
+ .replace(/&gt;/g, '>')
290
+ .replace(/&quot;/g, '"')
291
+ .replace(/&apos;/g, "'")
292
+ .replace(/\s+$/gm, '') // Trim line-end whitespace
293
+ }
294
+
295
+
296
+ /**
297
+ * Lookup tables for this module
298
+ * @type {Object}
299
+ * @property {array<Object>} fields Field translations between Reflib (`rl`) and the raw format (`raw`)
300
+ * @property {array<Object>} types Field translations between Reflib (`rl`) and the raw format types as raw text (`rawText`) and numeric ID (`rawId`)
301
+ */
302
+ export let translations = {
303
+ // Field translations {{{
304
+ fields: {
305
+ collection: [
306
+ // Field translations in priority order (Endnote first then Zotero)
307
+ {rl: 'recNumber', raw: 'rec-number'},
308
+ {rl: 'title', raw: 'title'},
309
+ {rl: 'journal', raw: 'secondary-title'},
310
+ {rl: 'address', raw: 'auth-address'},
311
+ {rl: 'researchNotes', raw: 'research-notes'},
312
+ {rl: 'authors', raw: 'authors'},
313
+ {rl: 'pages', raw: 'pages'},
314
+ {rl: 'volume', raw: 'volume'},
315
+ {rl: 'number', raw: 'number'},
316
+ {rl: 'isbn', raw: 'isbn'},
317
+ {rl: 'accessionNum', raw: 'accession-num'},
318
+ {rl: 'abstract', raw: 'abstract'},
319
+ {rl: 'label', raw: 'label'},
320
+ {rl: 'caption', raw: 'caption'},
321
+ {rl: 'notes', raw: 'notes'},
322
+ {rl: 'custom1', raw: 'custom1'},
323
+ {rl: 'custom2', raw: 'custom2'},
324
+ {rl: 'custom3', raw: 'custom3'},
325
+ {rl: 'custom4', raw: 'custom4'},
326
+ {rl: 'custom5', raw: 'custom5'},
327
+ {rl: 'custom6', raw: 'custom6'},
328
+ {rl: 'custom7', raw: 'custom7'},
329
+ {rl: 'doi', raw: 'electronic-resource-num'},
330
+ {rl: 'year', raw: 'year'},
331
+ {rl: 'date', raw: 'date'},
332
+ {rl: 'language', raw: 'language'},
333
+ ],
334
+ rawMap: new Map(), // Calculated later for quicker lookup
335
+ },
336
+ // }}}
337
+ // Ref type translations {{{
338
+ types: {
339
+ collection: [
340
+ {rl: 'aggregatedDatabase', rawText: 'Aggregated Database', rawId: 55},
341
+ {rl: 'ancientText', rawText: 'Ancient Text', rawId: 51},
342
+ {rl: 'artwork', rawText: 'Artwork', rawId: 2},
343
+ {rl: 'audioVisualMaterial', rawText: 'Audiovisual Material', rawId: 3},
344
+ {rl: 'bill', rawText: 'Bill', rawId: 4},
345
+ {rl: 'blog', rawText: 'Blog', rawId: 56},
346
+ {rl: 'book', rawText: 'Book', rawId: 6},
347
+ {rl: 'bookSection', rawText: 'Book Section', rawId: 5},
348
+ {rl: 'case', rawText: 'Case', rawId: 7},
349
+ {rl: 'catalog', rawText: 'Catalog', rawId: 8},
350
+ {rl: 'chartOrTable', rawText: 'Chart or Table', rawId: 38},
351
+ {rl: 'classicalWork', rawText: 'Classical Work', rawId: 49},
352
+ {rl: 'computerProgram', rawText: 'Computer Program', rawId: 9},
353
+ {rl: 'conferencePaper', rawText: 'Conference Paper', rawId: 47},
354
+ {rl: 'conferenceProceedings', rawText: 'Conference Proceedings', rawId: 10},
355
+ {rl: 'dataset', rawText: 'Dataset', rawId: 59},
356
+ {rl: 'dictionary', rawText: 'Dictionary', rawId: 52},
357
+ {rl: 'editedBook', rawText: 'Edited Book', rawId: 28},
358
+ {rl: 'electronicArticle', rawText: 'Electronic Article', rawId: 43},
359
+ {rl: 'electronicBook', rawText: 'Electronic Book', rawId: 44},
360
+ {rl: 'electronicBookSection', rawText: 'Electronic Book Section', rawId: 60},
361
+ {rl: 'encyclopedia', rawText: 'Encyclopedia', rawId: 53},
362
+ {rl: 'equation', rawText: 'Equation', rawId: 39},
363
+ {rl: 'figure', rawText: 'Figure', rawId: 37},
364
+ {rl: 'filmOrBroadcast', rawText: 'Film or Broadcast', rawId: 21},
365
+ {rl: 'generic', rawText: 'Generic', rawId: 13},
366
+ {rl: 'governmentDocument', rawText: 'Government Document', rawId: 46},
367
+ {rl: 'grant', rawText: 'Grant', rawId: 54},
368
+ {rl: 'hearing', rawText: 'Hearing', rawId: 14},
369
+ {rl: 'journalArticle', rawText: 'Journal Article', rawId: 17},
370
+ {rl: 'legalRuleOrRegulation', rawText: 'Legal Rule or Regulation', rawId: 50},
371
+ {rl: 'magazineArticle', rawText: 'Magazine Article', rawId: 19},
372
+ {rl: 'manuscript', rawText: 'Manuscript', rawId: 36},
373
+ {rl: 'map', rawText: 'Map', rawId: 20},
374
+ {rl: 'music', rawText: 'Music', rawId: 61},
375
+ {rl: 'newspaperArticle', rawText: 'Newspaper Article', rawId: 23},
376
+ {rl: 'onlineDatabase', rawText: 'Online Database', rawId: 45},
377
+ {rl: 'onlineMultimedia', rawText: 'Online Multimedia', rawId: 48},
378
+ {rl: 'pamphlet', rawText: 'Pamphlet', rawId: 24},
379
+ {rl: 'patent', rawText: 'Patent', rawId: 25},
380
+ {rl: 'personalCommunication', rawText: 'Personal Communication', rawId: 26},
381
+ {rl: 'report', rawText: 'Report', rawId: 27},
382
+ {rl: 'serial', rawText: 'Serial', rawId: 57},
383
+ {rl: 'standard', rawText: 'Standard', rawId: 58},
384
+ {rl: 'statute', rawText: 'Statute', rawId: 31},
385
+ {rl: 'thesis', rawText: 'Thesis', rawId: 32},
386
+ {rl: 'unpublished', rawText: 'Unpublished Work', rawId: 34},
387
+ {rl: 'web', rawText: 'Web Page', rawId: 12},
388
+ ],
389
+ rlMap: new Map(), // Calculated later for quicker lookup
390
+ rawMap: new Map(), // Calculated later for quicker lookup
391
+ },
392
+ // }}}
393
+ };
394
+
395
+
396
+ /**
397
+ * @see modules/interface.js
398
+ */
399
+ export function setup() {
400
+ // Create lookup object of translations.field translations
401
+ translations.fields.collection.forEach(c => {
402
+ translations.fields.rawMap.set(c.raw, c);
403
+ });
404
+
405
+ // Create lookup object of translations.types with key as .rl / val as the full object
406
+ translations.types.collection.forEach(c => {
407
+ translations.types.rlMap.set(c.rl, c);
408
+ translations.types.rawMap.set(c.rawId, c);
409
+ });
410
+ }