@larsgw/formica 0.4.3 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ # [0.5.0](https://github.com/identification-resources/formica/compare/v0.4.3...v0.5.0) (2023-10-24)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **catalog:** make validation more robust ([522db8a](https://github.com/identification-resources/formica/commit/522db8ad428c2d4645569a7f541fd203f5d628e8))
7
+ * **csv:** fix csv formatting ([064e5e5](https://github.com/identification-resources/formica/commit/064e5e57b2a642d22850501ab2a0b95879779b85))
8
+ * **resources:** do not count abbreviations as species ([ac0cc5a](https://github.com/identification-resources/formica/commit/ac0cc5a7cfd1f68df9b8d1d1c543a2f593839daa))
9
+ * **resources:** fix typos in output column names ([0d02823](https://github.com/identification-resources/formica/commit/0d0282378b21e92fbe028a53a9d121f42e791cc5))
10
+
11
+
12
+ ### Features
13
+
14
+ * **resources:** encode names with spaces with underscores ([9c7efd4](https://github.com/identification-resources/formica/commit/9c7efd4daf80660be98f2fec8ef9faa5d5b42d67))
15
+ * **resources:** include verbatimIdentification column ([4a2e920](https://github.com/identification-resources/formica/commit/4a2e9201467347e900f5690d2301a3b231ded822))
16
+ * **resources:** move "scope" metadata to "catalog" ([286c5b2](https://github.com/identification-resources/formica/commit/286c5b223ce41336f3ebd63c1797403aad21aef6))
17
+ * **resources:** update processor configuration ([64daa26](https://github.com/identification-resources/formica/commit/64daa26d70c96df24298c7b78f2b93ef0f5b2a48))
18
+
19
+
20
+ ### BREAKING CHANGES
21
+
22
+ * **resources:** - "intragenericEpithet" is now "infragenericEpithet"
23
+ - "intraspecificEpithet" is now "infraspecificEpithet"
24
+ * **resources:** Adds additional column, between "higherClassification" and "colTaxonID".
25
+ * **resources:** - Instead of "-u, --update" use "-s modified"
26
+ - Instead of "--update-mapping" use "-s all"
27
+ * **resources:** Index will no longer contain "scope" array. "scope" field in YAML header
28
+ does no longer pass validation. Use the "taxon_scope" and "scope" fields
29
+ in "catalog" instead.
30
+
31
+
32
+
1
33
  ## [0.4.3](https://github.com/identification-resources/formica/compare/v0.4.2...v0.4.3) (2023-09-30)
2
34
 
3
35
 
@@ -1,2 +1,6 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare enum ResourceProcessorSource {
3
+ All = "all",
4
+ Unprocessed = "unprocessed",
5
+ Modified = "modified"
6
+ }
@@ -48,20 +48,27 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
48
48
  }
49
49
  };
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.ResourceProcessorSource = void 0;
51
52
  var fs_1 = require("fs");
52
53
  var path = require("path");
53
54
  var child_process_1 = require("child_process");
54
55
  var util = require("util");
55
56
  var index_1 = require("../index");
56
57
  var util_1 = require("./util");
58
+ var ResourceProcessorSource;
59
+ (function (ResourceProcessorSource) {
60
+ ResourceProcessorSource["All"] = "all";
61
+ ResourceProcessorSource["Unprocessed"] = "unprocessed";
62
+ ResourceProcessorSource["Modified"] = "modified";
63
+ })(ResourceProcessorSource = exports.ResourceProcessorSource || (exports.ResourceProcessorSource = {}));
57
64
  var DWC_FIELDS = [
58
65
  'scientificNameID',
59
66
  'scientificName',
60
67
  'scientificNameAuthorship',
61
68
  'genericName',
62
- 'intragenericEpithet',
69
+ 'infragenericEpithet',
63
70
  'specificEpithet',
64
- 'intraspecificEpithet',
71
+ 'infraspecificEpithet',
65
72
  'taxonRank',
66
73
  'taxonRemarks',
67
74
  'collectionCode',
@@ -79,6 +86,7 @@ var DWC_FIELDS = [
79
86
  'genus',
80
87
  'subgenus',
81
88
  'higherClassification',
89
+ 'verbatimIdentification',
82
90
  'colTaxonID',
83
91
  'gbifTaxonID',
84
92
  'colAcceptedTaxonID',
@@ -122,6 +130,36 @@ function runGnverifier(names) {
122
130
  proc.stdin.end();
123
131
  });
124
132
  }
133
+ function listFiles(directory) {
134
+ return __awaiter(this, void 0, void 0, function () {
135
+ var input;
136
+ return __generator(this, function (_a) {
137
+ switch (_a.label) {
138
+ case 0: return [4 /*yield*/, fs_1.promises.readdir(directory)];
139
+ case 1:
140
+ input = _a.sent();
141
+ return [2 /*return*/, input.map(function (file) { return path.basename(file, '.txt'); }).sort(util_1.numericSort)];
142
+ }
143
+ });
144
+ });
145
+ }
146
+ function listUnprocessedFiles(directory, outputDirectory) {
147
+ return __awaiter(this, void 0, void 0, function () {
148
+ var input, output, _a;
149
+ return __generator(this, function (_b) {
150
+ switch (_b.label) {
151
+ case 0: return [4 /*yield*/, listFiles(directory)];
152
+ case 1:
153
+ input = _b.sent();
154
+ _a = Set.bind;
155
+ return [4 /*yield*/, fs_1.promises.readdir(outputDirectory)];
156
+ case 2:
157
+ output = new (_a.apply(Set, [void 0, _b.sent()]))();
158
+ return [2 /*return*/, input.filter(function (file) { return output.has(file + '-1'); })];
159
+ }
160
+ });
161
+ });
162
+ }
125
163
  function listChangedFiles(directory) {
126
164
  return __awaiter(this, void 0, void 0, function () {
127
165
  var output;
@@ -132,7 +170,7 @@ function listChangedFiles(directory) {
132
170
  })];
133
171
  case 1:
134
172
  output = _a.sent();
135
- return [2 /*return*/, output.trimEnd().split('\n').sort(util_1.numericSort)];
173
+ return [2 /*return*/, output.trimEnd().split('\n').map(function (file) { return path.basename(file, '.txt'); }).sort(util_1.numericSort)];
136
174
  }
137
175
  });
138
176
  });
@@ -162,72 +200,23 @@ var ResourceProcessor = /** @class */ (function () {
162
200
  this.DIR_DWC = path.join(this.DIR_ROOT, 'dwc');
163
201
  this.FILE_PROBLEMS = path.join(this.DIR_ROOT, 'problems.csv');
164
202
  }
165
- ResourceProcessor.prototype.run = function () {
203
+ ResourceProcessor.prototype.run = function (source, config) {
166
204
  return __awaiter(this, void 0, void 0, function () {
167
- var input, output, ids, _loop_1, this_1, _i, ids_1, id;
205
+ var ids, _i, ids_1, id;
168
206
  return __generator(this, function (_a) {
169
207
  switch (_a.label) {
170
- case 0: return [4 /*yield*/, fs_1.promises.readdir(this.DIR_TXT)];
208
+ case 0: return [4 /*yield*/, this.listWorks(source)];
171
209
  case 1:
172
- input = _a.sent();
173
- return [4 /*yield*/, fs_1.promises.readdir(this.DIR_DWC)];
174
- case 2:
175
- output = _a.sent();
176
- ids = input
177
- .map(function (file) { return path.basename(file, '.txt'); })
178
- .sort(function (a, b) { return parseInt(a.slice(1)) - parseInt(b.slice(1)); });
179
- _loop_1 = function (id) {
180
- return __generator(this, function (_b) {
181
- switch (_b.label) {
182
- case 0:
183
- // Skip existing files
184
- if (output.some(function (file) { return file.startsWith(id + '-'); })) {
185
- return [2 /*return*/, "continue"];
186
- }
187
- return [4 /*yield*/, this_1.processWork(id)];
188
- case 1:
189
- _b.sent();
190
- return [2 /*return*/];
191
- }
192
- });
193
- };
194
- this_1 = this;
210
+ ids = _a.sent();
195
211
  _i = 0, ids_1 = ids;
196
- _a.label = 3;
197
- case 3:
198
- if (!(_i < ids_1.length)) return [3 /*break*/, 6];
199
- id = ids_1[_i];
200
- return [5 /*yield**/, _loop_1(id)];
201
- case 4:
202
- _a.sent();
203
- _a.label = 5;
204
- case 5:
205
- _i++;
206
- return [3 /*break*/, 3];
207
- case 6: return [2 /*return*/];
208
- }
209
- });
210
- });
211
- };
212
- ResourceProcessor.prototype.runUpdate = function () {
213
- return __awaiter(this, void 0, void 0, function () {
214
- var _i, _a, file, id;
215
- return __generator(this, function (_b) {
216
- switch (_b.label) {
217
- case 0:
218
- _i = 0;
219
- return [4 /*yield*/, listChangedFiles(this.DIR_TXT)];
220
- case 1:
221
- _a = _b.sent();
222
- _b.label = 2;
212
+ _a.label = 2;
223
213
  case 2:
224
- if (!(_i < _a.length)) return [3 /*break*/, 5];
225
- file = _a[_i];
226
- id = path.basename(file, '.txt');
227
- return [4 /*yield*/, this.processWork(id, true)];
214
+ if (!(_i < ids_1.length)) return [3 /*break*/, 5];
215
+ id = ids_1[_i];
216
+ return [4 /*yield*/, this.processWork(id, config)];
228
217
  case 3:
229
- _b.sent();
230
- _b.label = 4;
218
+ _a.sent();
219
+ _a.label = 4;
231
220
  case 4:
232
221
  _i++;
233
222
  return [3 /*break*/, 2];
@@ -236,52 +225,41 @@ var ResourceProcessor = /** @class */ (function () {
236
225
  });
237
226
  });
238
227
  };
239
- ResourceProcessor.prototype.runMappingsUpdate = function () {
228
+ ResourceProcessor.prototype.listWorks = function (source) {
240
229
  return __awaiter(this, void 0, void 0, function () {
241
- var input, ids, _i, ids_2, id;
242
230
  return __generator(this, function (_a) {
243
- switch (_a.label) {
244
- case 0: return [4 /*yield*/, fs_1.promises.readdir(this.DIR_TXT)];
245
- case 1:
246
- input = _a.sent();
247
- ids = input
248
- .map(function (file) { return path.basename(file, '.txt'); })
249
- .sort(function (a, b) { return parseInt(a.slice(1)) - parseInt(b.slice(1)); });
250
- _i = 0, ids_2 = ids;
251
- _a.label = 2;
252
- case 2:
253
- if (!(_i < ids_2.length)) return [3 /*break*/, 5];
254
- id = ids_2[_i];
255
- return [4 /*yield*/, this.processWork(id, true)];
256
- case 3:
257
- _a.sent();
258
- _a.label = 4;
259
- case 4:
260
- _i++;
261
- return [3 /*break*/, 2];
262
- case 5: return [2 /*return*/];
231
+ switch (source) {
232
+ case ResourceProcessorSource.All:
233
+ return [2 /*return*/, listFiles(this.DIR_TXT)];
234
+ case ResourceProcessorSource.Unprocessed:
235
+ return [2 /*return*/, listUnprocessedFiles(this.DIR_TXT, this.DIR_DWC)];
236
+ case ResourceProcessorSource.Modified:
237
+ return [2 /*return*/, listChangedFiles(this.DIR_TXT)];
238
+ default:
239
+ return [2 /*return*/, []];
263
240
  }
241
+ return [2 /*return*/];
264
242
  });
265
243
  });
266
244
  };
267
- ResourceProcessor.prototype.processWork = function (id, update) {
245
+ ResourceProcessor.prototype.processWork = function (id, config) {
268
246
  return __awaiter(this, void 0, void 0, function () {
269
247
  var resources;
270
248
  var _this = this;
271
249
  return __generator(this, function (_a) {
272
250
  switch (_a.label) {
273
- case 0: return [4 /*yield*/, this.processResources(id, update)];
251
+ case 0: return [4 /*yield*/, this.processResources(id, config)];
274
252
  case 1:
275
253
  resources = _a.sent();
276
254
  return [4 /*yield*/, Promise.all(resources.map(function (resource) {
277
255
  var header = DWC_FIELDS;
278
256
  var table = [header];
279
- var _loop_2 = function (id_1) {
257
+ var _loop_1 = function (id_1) {
280
258
  var taxon = resource.taxa[id_1];
281
259
  table.push(header.map(function (column) { return taxon[column] || ''; }));
282
260
  };
283
261
  for (var id_1 in resource.taxa) {
284
- _loop_2(id_1);
262
+ _loop_1(id_1);
285
263
  }
286
264
  return fs_1.promises.writeFile(path.join(_this.DIR_DWC, "".concat(resource.file, ".csv")), index_1.csv.formatCsv(table, ',').trim());
287
265
  }))];
@@ -292,12 +270,12 @@ var ResourceProcessor = /** @class */ (function () {
292
270
  });
293
271
  });
294
272
  };
295
- ResourceProcessor.prototype.processResources = function (id, update) {
273
+ ResourceProcessor.prototype.processResources = function (id, config) {
296
274
  return __awaiter(this, void 0, void 0, function () {
297
275
  var resources, amendedResources, _i, resources_1, resource, results, skip, correct, choice, _a, reason;
298
276
  return __generator(this, function (_b) {
299
277
  switch (_b.label) {
300
- case 0: return [4 /*yield*/, this.processResourceText(id, update)];
278
+ case 0: return [4 /*yield*/, this.processResourceText(id, config)];
301
279
  case 1:
302
280
  resources = _b.sent();
303
281
  amendedResources = [];
@@ -306,7 +284,7 @@ var ResourceProcessor = /** @class */ (function () {
306
284
  case 2:
307
285
  if (!(_i < resources_1.length)) return [3 /*break*/, 11];
308
286
  resource = resources_1[_i];
309
- return [4 /*yield*/, this.processResourceDwc(resource)];
287
+ return [4 /*yield*/, this.processResourceDwc(resource, config)];
310
288
  case 3:
311
289
  results = _b.sent();
312
290
  return [4 /*yield*/, this.shouldBeSkipped(resource.id)];
@@ -339,7 +317,7 @@ var ResourceProcessor = /** @class */ (function () {
339
317
  case 8:
340
318
  {
341
319
  console.log("".concat(resource.workId, ": retrying ").concat(resource.id));
342
- return [2 /*return*/, this.processResources(id, update)];
320
+ return [2 /*return*/, this.processResources(id, config)];
343
321
  }
344
322
  _b.label = 9;
345
323
  case 9:
@@ -353,7 +331,7 @@ var ResourceProcessor = /** @class */ (function () {
353
331
  });
354
332
  });
355
333
  };
356
- ResourceProcessor.prototype.processResourceText = function (id, update) {
334
+ ResourceProcessor.prototype.processResourceText = function (id, config) {
357
335
  return __awaiter(this, void 0, void 0, function () {
358
336
  var filePath, file, old, dwc, _i, _a, file_1, filePath_1, _b, _c, _d, _e, resources, error_1, prefix, file;
359
337
  var _f;
@@ -367,7 +345,7 @@ var ResourceProcessor = /** @class */ (function () {
367
345
  case 1:
368
346
  file = _g.sent();
369
347
  old = undefined;
370
- if (!update) return [3 /*break*/, 8];
348
+ if (!config.update) return [3 /*break*/, 8];
371
349
  dwc = [];
372
350
  _i = 0;
373
351
  return [4 /*yield*/, fs_1.promises.readdir(this.DIR_DWC)];
@@ -412,19 +390,43 @@ var ResourceProcessor = /** @class */ (function () {
412
390
  delete require.cache[file];
413
391
  }
414
392
  }
415
- return [2 /*return*/, this.processResourceText(id, update)];
393
+ return [2 /*return*/, this.processResourceText(id, config)];
416
394
  case 12: return [2 /*return*/];
417
395
  }
418
396
  });
419
397
  });
420
398
  };
421
- ResourceProcessor.prototype.processResourceDwc = function (resource) {
399
+ ResourceProcessor.prototype.processResourceDwc = function (resource, config) {
422
400
  return __awaiter(this, void 0, void 0, function () {
423
- var filteredResults, taxonNames, names, id, name_1, result, _i, _a, results, _b, name_2, matches, _c, matches_1, match, source, currentRank, _d, _e, loirId, taxon, _f, amendResource, groupNameMatches, groupedNameMatches, amendedResource, _g, _h, _j, _k, source, matches;
424
- return __generator(this, function (_l) {
425
- switch (_l.label) {
401
+ var file, _a, header_1, rows, _b, _c, _i, rows_1, row, oldTaxon, taxon, filteredResults, taxonNames, names, id, name_1, result, _d, _e, results, _f, name_2, matches, _g, matches_1, match, source, currentRank, _h, _j, loirId, taxon, _k, amendResource, groupNameMatches, groupedNameMatches, amendedResource, _l, _m, _o, _p, source, matches;
402
+ return __generator(this, function (_q) {
403
+ switch (_q.label) {
426
404
  case 0:
427
405
  console.log("".concat(resource.workId, ": matching ").concat(resource.id));
406
+ if (!!config.updateMappings) return [3 /*break*/, 3];
407
+ file = path.join(this.DIR_DWC, resource.file + '.csv');
408
+ if (!(0, fs_1.existsSync)(file)) return [3 /*break*/, 2];
409
+ _c = (_b = index_1.csv).parseCsv;
410
+ return [4 /*yield*/, fs_1.promises.readFile(file, 'utf-8')];
411
+ case 1:
412
+ _a = _c.apply(_b, [_q.sent()]), header_1 = _a[0], rows = _a.slice(1);
413
+ for (_i = 0, rows_1 = rows; _i < rows_1.length; _i++) {
414
+ row = rows_1[_i];
415
+ oldTaxon = row.reduce(function (taxon, value, index) {
416
+ taxon[header_1[index]] = value;
417
+ return taxon;
418
+ }, {});
419
+ taxon = resource.taxa[oldTaxon.scientificNameID];
420
+ if (taxon) {
421
+ taxon.colTaxonID = oldTaxon.colTaxonID;
422
+ taxon.colAcceptedTaxonID = oldTaxon.colAcceptedTaxonID;
423
+ taxon.gbifTaxonID = oldTaxon.gbifTaxonID;
424
+ taxon.gbifAcceptedTaxonID = oldTaxon.gbifAcceptedTaxonID;
425
+ }
426
+ }
427
+ _q.label = 2;
428
+ case 2: return [2 /*return*/, resource];
429
+ case 3:
428
430
  filteredResults = {};
429
431
  taxonNames = {};
430
432
  names = new Set();
@@ -438,16 +440,16 @@ var ResourceProcessor = /** @class */ (function () {
438
440
  filteredResults[id] = [];
439
441
  }
440
442
  return [4 /*yield*/, runGnverifier(Array.from(names).join('\n'))];
441
- case 1:
442
- result = _l.sent();
443
- for (_i = 0, _a = result.trim().split('\n'); _i < _a.length; _i++) {
444
- results = _a[_i];
445
- _b = JSON.parse(results), name_2 = _b.name, matches = _b.results;
443
+ case 4:
444
+ result = _q.sent();
445
+ for (_d = 0, _e = result.trim().split('\n'); _d < _e.length; _d++) {
446
+ results = _e[_d];
447
+ _f = JSON.parse(results), name_2 = _f.name, matches = _f.results;
446
448
  if (!matches) {
447
449
  continue;
448
450
  }
449
- for (_c = 0, matches_1 = matches; _c < matches_1.length; _c++) {
450
- match = matches_1[_c];
451
+ for (_g = 0, matches_1 = matches; _g < matches_1.length; _g++) {
452
+ match = matches_1[_g];
451
453
  source = match.dataSourceId;
452
454
  currentRank = match.classificationRanks.split('|').pop();
453
455
  if (match.scoreDetails.cardinalityScore === 0) {
@@ -458,8 +460,8 @@ var ResourceProcessor = /** @class */ (function () {
458
460
  // GBIF species like "Nomada spec"
459
461
  continue;
460
462
  }
461
- for (_d = 0, _e = taxonNames[name_2]; _d < _e.length; _d++) {
462
- loirId = _e[_d];
463
+ for (_h = 0, _j = taxonNames[name_2]; _h < _j.length; _h++) {
464
+ loirId = _j[_h];
463
465
  taxon = resource.taxa[loirId];
464
466
  if (source === 11 && !GBIF_RANKS.includes(taxon.taxonRank)) {
465
467
  // Exclude GBIF matches for ranks that are not in GBIF
@@ -483,30 +485,30 @@ var ResourceProcessor = /** @class */ (function () {
483
485
  }
484
486
  }
485
487
  return [4 /*yield*/, Promise.resolve().then(function () { return require('../index'); })];
486
- case 2:
487
- _f = (_l.sent()).taxonNames, amendResource = _f.amendResource, groupNameMatches = _f.groupNameMatches;
488
+ case 5:
489
+ _k = (_q.sent()).taxonNames, amendResource = _k.amendResource, groupNameMatches = _k.groupNameMatches;
488
490
  groupedNameMatches = groupNameMatches(filteredResults);
489
491
  amendedResource = __assign(__assign({}, resource), { taxa: __assign({}, resource.taxa) });
490
- _g = groupedNameMatches;
491
- _h = [];
492
- for (_j in _g)
493
- _h.push(_j);
494
- _k = 0;
495
- _l.label = 3;
496
- case 3:
497
- if (!(_k < _h.length)) return [3 /*break*/, 6];
498
- _j = _h[_k];
499
- if (!(_j in _g)) return [3 /*break*/, 5];
500
- source = _j;
492
+ _l = groupedNameMatches;
493
+ _m = [];
494
+ for (_o in _l)
495
+ _m.push(_o);
496
+ _p = 0;
497
+ _q.label = 6;
498
+ case 6:
499
+ if (!(_p < _m.length)) return [3 /*break*/, 9];
500
+ _o = _m[_p];
501
+ if (!(_o in _l)) return [3 /*break*/, 8];
502
+ source = _o;
501
503
  return [4 /*yield*/, this.selectPrefixes(resource, groupedNameMatches, source)];
502
- case 4:
503
- matches = _l.sent();
504
+ case 7:
505
+ matches = _q.sent();
504
506
  amendResource(amendedResource, source, matches);
505
- _l.label = 5;
506
- case 5:
507
- _k++;
508
- return [3 /*break*/, 3];
509
- case 6: return [2 /*return*/, amendedResource];
507
+ _q.label = 8;
508
+ case 8:
509
+ _p++;
510
+ return [3 /*break*/, 6];
511
+ case 9: return [2 /*return*/, amendedResource];
510
512
  }
511
513
  });
512
514
  });
@@ -635,12 +637,14 @@ var ResourceProcessor = /** @class */ (function () {
635
637
  function main() {
636
638
  var args = util.parseArgs({
637
639
  options: {
638
- update: {
639
- type: 'boolean',
640
- short: 'u'
640
+ source: {
641
+ type: 'string',
642
+ short: 's',
643
+ default: 'unprocessed'
641
644
  },
642
- 'update-mappings': {
643
- type: 'boolean'
645
+ 'keep-mappings': {
646
+ type: 'boolean',
647
+ short: 'k'
644
648
  }
645
649
  },
646
650
  allowPositionals: true
@@ -649,17 +653,12 @@ function main() {
649
653
  process.on('exit', function () {
650
654
  process.stdout.write('\n');
651
655
  });
652
- var task;
653
- if (args.values.update) {
654
- task = processor.runUpdate();
655
- }
656
- else if (args.values['update-mappings']) {
657
- task = processor.runMappingsUpdate();
658
- }
659
- else {
660
- task = processor.run();
661
- }
662
- task.catch(function (error) {
656
+ var source = args.values.source;
657
+ var config = {
658
+ update: source !== 'unprocessed',
659
+ updateMappings: !args.values['keep-mappings']
660
+ };
661
+ processor.run(source, config).catch(function (error) {
663
662
  console.error(error);
664
663
  process.exit(1);
665
664
  });
@@ -22,10 +22,10 @@ exports.FORMATS = {
22
22
  };
23
23
  exports.CHECK = {
24
24
  MULTILANG: function (entry) {
25
- return entry.language.length > 1;
25
+ return Array.isArray(entry.language) && entry.language.length > 1;
26
26
  },
27
27
  ISBN: function (entry) {
28
- if (entry.ISBN.length < 2) {
28
+ if (!Array.isArray(entry.ISBN) || entry.ISBN.length < 2) {
29
29
  return false;
30
30
  }
31
31
  var a = entry.ISBN[0].length === 10;
package/lib/csv.js CHANGED
@@ -25,7 +25,7 @@ function formatCsv(table, delimiter) {
25
25
  .map(function (row) {
26
26
  return row.map(function (value) {
27
27
  if (/["\n]/.test(value) || value.includes(delimiter)) {
28
- return "\"".concat(value.replace(/"/, '"""'), "\"");
28
+ return "\"".concat(value.replace(/"/g, '""'), "\"");
29
29
  }
30
30
  else {
31
31
  return value;
@@ -13,6 +13,7 @@ var __assign = (this && this.__assign) || function () {
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.parseFileHeader = exports.parseFile = void 0;
15
15
  var yaml = require("js-yaml");
16
+ var work_1 = require("../catalog/tables/work");
16
17
  var diff_resource_1 = require("./diff-resource");
17
18
  var RANKS = [
18
19
  'class',
@@ -122,9 +123,9 @@ var NAME_PATTERN = new RegExp('^' +
122
123
  * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
123
124
  * $1.1 genus: ([A-Z]\S+)
124
125
  * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
125
- * $2 species: ((?:x )?[a-z]\S+)
126
+ * $2 species: ((?:x )?[a-z][^\s.]+)
126
127
  */
127
- var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z]\S+)(?= |$)/;
128
+ var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/;
128
129
  function compareRanks(a, b) {
129
130
  return RANKS.indexOf(a) - RANKS.indexOf(b);
130
131
  }
@@ -174,55 +175,62 @@ function parseName(name, rank, parent) {
174
175
  if (/^\[(_|\d+)\] /.test(name)) {
175
176
  name = name.replace(/^\[(_|\d+)\] /, '');
176
177
  }
178
+ // Set verbatim identification after subsequent syntax is removed.
179
+ item.verbatimIdentification = name;
177
180
  // Parent context is used for parsing and formatting binomial names.
178
- var parentContext = __assign({}, parent);
179
- if (parent.incorrect) {
180
- parentContext.incorrect = __assign({}, parent.incorrect);
181
- }
182
- // The parent context should be amended in the two cases where binomial names
183
- // are truly accepted: synonyms and species (and below) without parents (resp.
184
- // genera and genera and species) to provide parts of the name.
181
+ // For formatting, it needs to match external databases (i.e. be correct).
182
+ // For parsing, it needs to match the current file. If relevant parents
183
+ // (i.e. genus, species) had mistakes that were corrected, the uncorrected
184
+ // genus and species names need to be used.
185
+ var parentContext = {
186
+ genus: parent.genus,
187
+ subgenus: parent.subgenus,
188
+ specificEpithet: parent.specificEpithet,
189
+ incorrect: {
190
+ genus: parent.incorrect && parent.incorrect.genus,
191
+ specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
192
+ }
193
+ };
194
+ // Both contexts should be amended in the two cases where binomial names
195
+ // are fully used: (1) synonyms and (2) multinomial taxa without parents to
196
+ // provide parts of the name (e.g. bare species without a genus parent, or
197
+ // even subspecies without a species or genus parent).
185
198
  if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
186
199
  var _a = name.match(BINAME_PATTERN) || [], genus = _a[1], subgenus = _a[2], species = _a[3];
187
200
  if (genus) {
188
- parentContext.genus = capitalize(genus);
189
- if (parentContext.incorrect)
190
- parentContext.incorrect.genus = capitalize(genus);
201
+ parentContext.genus = parentContext.incorrect.genus = capitalize(genus);
191
202
  }
192
203
  if (subgenus) {
193
204
  parentContext.subgenus = capitalize(subgenus);
194
- if (parentContext.incorrect)
195
- parentContext.incorrect.subgenus = capitalize(subgenus);
196
205
  }
197
206
  else if (genus) {
198
- // If a genus is given but no subgenus, remove it from the parent context
207
+ // If a genus is given but no subgenus, remove any existing subgenus
208
+ // from the parent context.
199
209
  delete parentContext.subgenus;
200
- if (parentContext.incorrect)
201
- delete parentContext.incorrect.subgenus;
202
210
  }
203
211
  if (species) {
204
- parentContext.specificEpithet = species;
205
- if (parentContext.incorrect)
206
- parentContext.incorrect.specificEpithet = species;
212
+ parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species;
207
213
  }
208
214
  }
209
215
  // In taxa of group, species or lower, the name should just contain the
210
- // (inter)specific epithet and the author information & remarks when processing
216
+ // (infra)specific epithet and the author information & remarks when processing
211
217
  // further.
212
218
  if (compareRanks('group', rank) <= 0) {
213
- var parseContext = parentContext.incorrect || parentContext;
214
- if (!parseContext.genus) {
215
- parseContext.genus = name.split(' ', 1)[0];
216
- }
217
- var genusPrefix = new RegExp("^".concat(parentContext.genus, " (\\(.*?\\) )?"), 'i');
218
- if (name[0] === parentContext.genus[0]) {
219
- name = name.replace(genusPrefix, '');
220
- }
219
+ // Remove genus
220
+ var genus = parentContext.incorrect.genus || parentContext.genus || '';
221
+ if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
222
+ name = name.slice(genus.length + 1);
223
+ }
224
+ // Remove subgenus
225
+ name = name.replace(/^\(.*?\) /, '');
226
+ // Infraspecific taxa
221
227
  if (compareRanks('species', rank) < 0) {
222
- var speciesPrefix = parseContext.specificEpithet + ' ';
223
- if (name.startsWith(speciesPrefix)) {
224
- name = name.slice(speciesPrefix.length);
228
+ // Remove specific epithet
229
+ var species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || '';
230
+ if (name.startsWith(species + ' ')) {
231
+ name = name.slice(species.length + 1);
225
232
  }
233
+ // Remove rank abbreviations
226
234
  name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '');
227
235
  }
228
236
  }
@@ -236,6 +244,11 @@ function parseName(name, rank, parent) {
236
244
  if (!nameParts) {
237
245
  throw new Error("Taxon \"".concat(name, "\" could not be parsed"));
238
246
  }
247
+ // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
248
+ // underscores are used, which are replaced here.
249
+ if (nameParts[1].includes('_')) {
250
+ nameParts[1] = nameParts[1].replace(/_/g, ' ');
251
+ }
239
252
  var _ = nameParts[0], taxon = nameParts[1], _b = nameParts[2], citation = _b === void 0 ? '' : _b, notes = nameParts[3];
240
253
  item.scientificNameAuthorship = capitalizeAuthors(citation);
241
254
  item.taxonRemarks = notes;
@@ -284,21 +297,21 @@ function parseName(name, rank, parent) {
284
297
  item.genericName = parentContext.genus;
285
298
  item.infragenericEpithet = parentContext.subgenus;
286
299
  item.specificEpithet = parentContext.specificEpithet;
287
- item.intraspecificEpithet = taxon.toLowerCase();
300
+ item.infraspecificEpithet = taxon.toLowerCase();
288
301
  // If possible, names below species should have abbreviations for ranks,
289
302
  // like "subsp."
290
303
  var nameParts_1 = [
291
304
  item.genericName,
292
305
  item.specificEpithet,
293
- item.intraspecificEpithet
306
+ item.infraspecificEpithet
294
307
  ];
295
308
  if (item.taxonRank in RANK_LABELS) {
296
309
  nameParts_1.splice(2, 0, RANK_LABELS[item.taxonRank]);
297
310
  }
298
311
  item.scientificName = nameParts_1.join(' ');
299
- if (item.intraspecificEpithet !== taxon) {
312
+ if (item.infraspecificEpithet !== taxon) {
300
313
  console.log(item, taxon);
301
- throw new Error("Intraspecific epithet should be lowercase: \"".concat(taxon, "\""));
314
+ throw new Error("Infraspecific epithet should be lowercase: \"".concat(taxon, "\""));
302
315
  }
303
316
  }
304
317
  // Re-add authorship information
@@ -335,15 +348,8 @@ function parseHeader(header) {
335
348
  else {
336
349
  levels = config.levels;
337
350
  }
338
- var scope;
339
- if (!('scope' in config)) {
340
- scope = [];
341
- }
342
- else if (!Array.isArray(config.scope)) {
343
- throw new SyntaxError('"scope" should be an array');
344
- }
345
- else {
346
- scope = config.scope;
351
+ if ('scope' in config) {
352
+ throw new SyntaxError('"scope" data should go in "catalog"');
347
353
  }
348
354
  // No taxon ranks
349
355
  if (levels.length === 0) {
@@ -354,9 +360,36 @@ function parseHeader(header) {
354
360
  if (invalidTaxonRanks.length) {
355
361
  throw new SyntaxError("\"levels\" contains invalid values: ".concat(invalidTaxonRanks.join(', ')));
356
362
  }
357
- var metadata = { levels: levels, scope: scope };
363
+ var metadata = { levels: levels };
358
364
  if ('catalog' in config && typeof config.catalog === 'object' && config.catalog !== null) {
359
- metadata.catalog = config.catalog;
365
+ var catalog = {};
366
+ for (var key in config.catalog) {
367
+ var value = config.catalog[key];
368
+ if (typeof value === 'number') {
369
+ catalog[key] = value.toString();
370
+ }
371
+ else if (typeof value === 'string') {
372
+ catalog[key] = value;
373
+ }
374
+ else {
375
+ throw new SyntaxError("\"catalog\" should contain only strings (\"".concat(key, "\")"));
376
+ }
377
+ }
378
+ var work = new work_1.Work(catalog);
379
+ var errors = work.validate().filter(function (_a) {
380
+ var error = _a.error;
381
+ return error !== 'Value(s) required but missing';
382
+ });
383
+ if (errors.length > 0) {
384
+ throw new SyntaxError("\"catalog\" contains errors: ".concat(errors.map(function (_a) {
385
+ var field = _a.field, error = _a.error;
386
+ return "[".concat(field, "] ").concat(error);
387
+ }).join('; ')));
388
+ }
389
+ metadata.catalog = {};
390
+ for (var key in work.fields) {
391
+ metadata.catalog[key] = work.get(key);
392
+ }
360
393
  }
361
394
  return metadata;
362
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "SDK and tools for data from the Library of Identification Resources",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { promises as fs } from 'fs'
3
+ import { promises as fs, existsSync as doesFileExist } from 'fs'
4
4
  import * as path from 'path'
5
5
  import { spawn } from 'child_process'
6
6
  import * as util from 'util'
@@ -8,14 +8,20 @@ import * as util from 'util'
8
8
  import { csv } from '../index'
9
9
  import { prompt, promptForAnswers, numericSort, runCommand } from './util'
10
10
 
11
- const DWC_FIELDS: string[] = [
11
+ export enum ResourceProcessorSource {
12
+ All = 'all',
13
+ Unprocessed = 'unprocessed',
14
+ Modified = 'modified'
15
+ }
16
+
17
+ const DWC_FIELDS: (keyof AmendedTaxon)[] = [
12
18
  'scientificNameID',
13
19
  'scientificName',
14
20
  'scientificNameAuthorship',
15
21
  'genericName',
16
- 'intragenericEpithet',
22
+ 'infragenericEpithet',
17
23
  'specificEpithet',
18
- 'intraspecificEpithet',
24
+ 'infraspecificEpithet',
19
25
 
20
26
  'taxonRank',
21
27
  'taxonRemarks',
@@ -36,6 +42,7 @@ const DWC_FIELDS: string[] = [
36
42
  'genus',
37
43
  'subgenus',
38
44
  'higherClassification',
45
+ 'verbatimIdentification',
39
46
 
40
47
  'colTaxonID',
41
48
  'gbifTaxonID',
@@ -43,7 +50,7 @@ const DWC_FIELDS: string[] = [
43
50
  'gbifAcceptedTaxonID'
44
51
  ]
45
52
 
46
- const DISPLAY_FIELDS: string[] = [
53
+ const DISPLAY_FIELDS: (keyof AmendedTaxon)[] = [
47
54
  'scientificNameID',
48
55
  'taxonRank',
49
56
  'scientificName',
@@ -83,11 +90,22 @@ function runGnverifier (names: string): Promise<string> {
83
90
  })
84
91
  }
85
92
 
93
+ async function listFiles (directory: string): Promise<string[]> {
94
+ const input = await fs.readdir(directory)
95
+ return input.map(file => path.basename(file, '.txt')).sort(numericSort)
96
+ }
97
+
98
+ async function listUnprocessedFiles (directory: string, outputDirectory: string): Promise<string[]> {
99
+ const input = await listFiles(directory)
100
+ const output = new Set(await fs.readdir(outputDirectory))
101
+ return input.filter(file => output.has(file + '-1'))
102
+ }
103
+
86
104
  async function listChangedFiles (directory: string): Promise<string[]> {
87
105
  const output = await runCommand('git', ['diff', '--name-only', 'HEAD', '--', directory], {
88
106
  cwd: directory
89
107
  })
90
- return output.trimEnd().split('\n').sort(numericSort)
108
+ return output.trimEnd().split('\n').map(file => path.basename(file, '.txt')).sort(numericSort)
91
109
  }
92
110
 
93
111
  async function getOldFile (file: string): Promise<string> {
@@ -111,49 +129,32 @@ class ResourceProcessor {
111
129
  this.FILE_PROBLEMS = path.join(this.DIR_ROOT, 'problems.csv')
112
130
  }
113
131
 
114
- async run (): Promise<void> {
115
- const input = await fs.readdir(this.DIR_TXT)
116
- const output = await fs.readdir(this.DIR_DWC)
117
-
118
- const ids = input
119
- .map(file => path.basename(file, '.txt'))
120
- .sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)))
121
-
132
+ async run (source: ResourceProcessorSource, config: ResourceProcessorConfig): Promise<void> {
133
+ const ids = await this.listWorks(source)
122
134
  for (const id of ids) {
123
- // Skip existing files
124
- if (output.some(file => file.startsWith(id + '-'))) {
125
- continue
126
- }
127
-
128
- await this.processWork(id)
129
- }
130
- }
131
-
132
- async runUpdate (): Promise<void> {
133
- for (const file of await listChangedFiles(this.DIR_TXT)) {
134
- const id = path.basename(file, '.txt')
135
- await this.processWork(id, true)
135
+ await this.processWork(id, config)
136
136
  }
137
137
  }
138
138
 
139
- async runMappingsUpdate (): Promise<void> {
140
- const input = await fs.readdir(this.DIR_TXT)
141
-
142
- const ids = input
143
- .map(file => path.basename(file, '.txt'))
144
- .sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)))
145
-
146
- for (const id of ids) {
147
- await this.processWork(id, true)
139
+ async listWorks (source: ResourceProcessorSource): Promise<string[]> {
140
+ switch (source) {
141
+ case ResourceProcessorSource.All:
142
+ return listFiles(this.DIR_TXT)
143
+ case ResourceProcessorSource.Unprocessed:
144
+ return listUnprocessedFiles(this.DIR_TXT, this.DIR_DWC)
145
+ case ResourceProcessorSource.Modified:
146
+ return listChangedFiles(this.DIR_TXT)
147
+ default:
148
+ return []
148
149
  }
149
150
  }
150
151
 
151
- async processWork (id: WorkId, update?: boolean): Promise<void> {
152
- const resources = await this.processResources(id, update)
152
+ async processWork (id: WorkId, config: ResourceProcessorConfig): Promise<void> {
153
+ const resources = await this.processResources(id, config)
153
154
 
154
155
  await Promise.all(resources.map(resource => {
155
156
  const header = DWC_FIELDS
156
- const table = [header]
157
+ const table: string[][] = [header]
157
158
 
158
159
  for (const id in resource.taxa) {
159
160
  const taxon = resource.taxa[id] as unknown as Record<string, string | undefined>
@@ -164,12 +165,12 @@ class ResourceProcessor {
164
165
  }))
165
166
  }
166
167
 
167
- async processResources (id: WorkId, update?: boolean): Promise<AmendedResource[]> {
168
- const resources = await this.processResourceText(id, update)
168
+ async processResources (id: WorkId, config: ResourceProcessorConfig): Promise<AmendedResource[]> {
169
+ const resources = await this.processResourceText(id, config)
169
170
 
170
171
  const amendedResources = []
171
172
  for (const resource of resources) {
172
- const results = await this.processResourceDwc(resource)
173
+ const results = await this.processResourceDwc(resource, config)
173
174
 
174
175
  const skip = await this.shouldBeSkipped(resource.id)
175
176
 
@@ -198,7 +199,7 @@ class ResourceProcessor {
198
199
  case 'r':
199
200
  case 'R': {
200
201
  console.log(`${resource.workId}: retrying ${resource.id}`)
201
- return this.processResources(id, update)
202
+ return this.processResources(id, config)
202
203
  }
203
204
  }
204
205
  }
@@ -210,14 +211,14 @@ class ResourceProcessor {
210
211
  return amendedResources
211
212
  }
212
213
 
213
- async processResourceText (id: WorkId, update?: boolean): Promise<Resource[]> {
214
+ async processResourceText (id: WorkId, config: ResourceProcessorConfig): Promise<Resource[]> {
214
215
  try {
215
216
  console.log(`${id}: generating Darwin Core`)
216
217
  const filePath = path.join(this.DIR_TXT, id + '.txt')
217
218
  const file = await fs.readFile(filePath, 'utf-8')
218
219
 
219
220
  let old = undefined
220
- if (update) {
221
+ if (config.update) {
221
222
  const dwc = []
222
223
  for (const file of await fs.readdir(this.DIR_DWC)) {
223
224
  if (file.startsWith(id + '-')) {
@@ -243,13 +244,34 @@ class ResourceProcessor {
243
244
  }
244
245
  }
245
246
 
246
- return this.processResourceText(id, update)
247
+ return this.processResourceText(id, config)
247
248
  }
248
249
  }
249
250
 
250
- async processResourceDwc (resource: Resource): Promise<AmendedResource> {
251
+ async processResourceDwc (resource: Resource, config: ResourceProcessorConfig): Promise<AmendedResource> {
251
252
  console.log(`${resource.workId}: matching ${resource.id}`)
252
253
 
254
+ if (!config.updateMappings) {
255
+ const file = path.join(this.DIR_DWC, resource.file + '.csv')
256
+ if (doesFileExist(file)) {
257
+ const [header, ...rows] = csv.parseCsv(await fs.readFile(file, 'utf-8'))
258
+ for (const row of rows) {
259
+ const oldTaxon = row.reduce((taxon, value, index) => {
260
+ taxon[header[index]] = value
261
+ return taxon
262
+ }, {} as Record<string, string>)
263
+ const taxon = resource.taxa[oldTaxon.scientificNameID] as AmendedTaxon
264
+ if (taxon) {
265
+ taxon.colTaxonID = oldTaxon.colTaxonID
266
+ taxon.colAcceptedTaxonID = oldTaxon.colAcceptedTaxonID
267
+ taxon.gbifTaxonID = oldTaxon.gbifTaxonID
268
+ taxon.gbifAcceptedTaxonID = oldTaxon.gbifAcceptedTaxonID
269
+ }
270
+ }
271
+ }
272
+ return resource as AmendedResource
273
+ }
274
+
253
275
  const filteredResults: Record<TaxonId, TaxonMatch[]> = {}
254
276
  const taxonNames: Record<string, TaxonId[]> = {}
255
277
  const names = new Set()
@@ -429,12 +451,14 @@ class ResourceProcessor {
429
451
  function main (): void {
430
452
  const args = util.parseArgs({
431
453
  options: {
432
- update: {
433
- type: 'boolean',
434
- short: 'u'
454
+ source: {
455
+ type: 'string',
456
+ short: 's',
457
+ default: 'unprocessed'
435
458
  },
436
- 'update-mappings': {
437
- type: 'boolean'
459
+ 'keep-mappings': {
460
+ type: 'boolean',
461
+ short: 'k'
438
462
  }
439
463
  },
440
464
  allowPositionals: true
@@ -445,16 +469,13 @@ function main (): void {
445
469
  process.stdout.write('\n')
446
470
  })
447
471
 
448
- let task
449
- if (args.values.update) {
450
- task = processor.runUpdate()
451
- } else if (args.values['update-mappings']) {
452
- task = processor.runMappingsUpdate()
453
- } else {
454
- task = processor.run()
472
+ const source = args.values.source as ResourceProcessorSource
473
+ const config: ResourceProcessorConfig = {
474
+ update: source !== 'unprocessed',
475
+ updateMappings: !args.values['keep-mappings']
455
476
  }
456
477
 
457
- task.catch(error => {
478
+ processor.run(source, config).catch(error => {
458
479
  console.error(error)
459
480
  process.exit(1)
460
481
  })
@@ -24,11 +24,11 @@ export const FORMATS = {
24
24
 
25
25
  export const CHECK = {
26
26
  MULTILANG (entry: Record<string, Value>) {
27
- return entry.language.length > 1
27
+ return Array.isArray(entry.language) && entry.language.length > 1
28
28
  },
29
29
 
30
30
  ISBN (entry: Record<string, Value>) {
31
- if (entry.ISBN.length < 2) { return false }
31
+ if (!Array.isArray(entry.ISBN) || entry.ISBN.length < 2) { return false }
32
32
  const a = entry.ISBN[0].length === 10
33
33
  const b = entry.ISBN[0].length === 13
34
34
  const c = entry.ISBN[1].length === 10
package/src/csv.ts CHANGED
@@ -23,7 +23,7 @@ export function formatCsv (table: string[][], delimiter = ',') {
23
23
  .map((row: string[]) => {
24
24
  return row.map(value => {
25
25
  if (/["\n]/.test(value) || value.includes(delimiter)) {
26
- return `"${value.replace(/"/, '"""')}"`
26
+ return `"${value.replace(/"/g, '""')}"`
27
27
  } else {
28
28
  return value
29
29
  }
package/src/module.d.ts CHANGED
@@ -34,7 +34,7 @@ interface WorkingTaxon {
34
34
  genericName?: string,
35
35
  infragenericEpithet?: string,
36
36
  specificEpithet?: string,
37
- intraspecificEpithet?: string,
37
+ infraspecificEpithet?: string,
38
38
 
39
39
  taxonRank?: Rank,
40
40
  taxonRemarks?: string,
@@ -55,6 +55,7 @@ interface WorkingTaxon {
55
55
  genus?: string,
56
56
  subgenus?: string,
57
57
  higherClassification?: string,
58
+ verbatimIdentification?: string,
58
59
 
59
60
  // Non-standard
60
61
  scientificNameOnly?: string,
@@ -66,13 +67,13 @@ interface Taxon extends WorkingTaxon {
66
67
  scientificName: string,
67
68
  taxonRank: Rank,
68
69
  collectionCode: ResourceId,
69
- taxonomicStatus: string
70
+ taxonomicStatus: string,
71
+ verbatimIdentification: string
70
72
  }
71
73
 
72
74
  interface ResourceMetadata {
73
75
  levels: Rank[],
74
- scope: string[],
75
- catalog?: object
76
+ catalog?: Record<string, Value>
76
77
  }
77
78
 
78
79
  interface Resource {
@@ -123,3 +124,14 @@ interface TaxonMatch {
123
124
  }
124
125
 
125
126
  type GroupedNameMatches = Record<string, Record<string, Record<TaxonId, TaxonMatch>>>
127
+
128
+ declare enum ResourceProcessorSource {
129
+ All = 'all',
130
+ Unprocessed = 'unprocessed',
131
+ Modified = 'modified'
132
+ }
133
+
134
+ interface ResourceProcessorConfig {
135
+ update: boolean,
136
+ updateMappings: boolean
137
+ }
@@ -1,4 +1,5 @@
1
1
  import * as yaml from 'js-yaml'
2
+ import { Work } from '../catalog/tables/work'
2
3
  import { createDiff, ResourceDiffType } from './diff-resource'
3
4
 
4
5
  const RANKS: Rank[] = [
@@ -120,9 +121,9 @@ const NAME_PATTERN = new RegExp(
120
121
  * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
121
122
  * $1.1 genus: ([A-Z]\S+)
122
123
  * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
123
- * $2 species: ((?:x )?[a-z]\S+)
124
+ * $2 species: ((?:x )?[a-z][^\s.]+)
124
125
  */
125
- const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z]\S+)(?= |$)/
126
+ const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/
126
127
 
127
128
  function compareRanks (a: Rank, b: Rank): number {
128
129
  return RANKS.indexOf(a) - RANKS.indexOf(b)
@@ -178,49 +179,67 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
178
179
  name = name.replace(/^\[(_|\d+)\] /, '')
179
180
  }
180
181
 
182
+ // Set verbatim identification after subsequent syntax is removed.
183
+ item.verbatimIdentification = name
184
+
181
185
  // Parent context is used for parsing and formatting binomial names.
182
- const parentContext = { ...parent }
183
- if (parent.incorrect) { parentContext.incorrect = { ...parent.incorrect } }
186
+ // For formatting, it needs to match external databases (i.e. be correct).
187
+ // For parsing, it needs to match the current file. If relevant parents
188
+ // (i.e. genus, species) had mistakes that were corrected, the uncorrected
189
+ // genus and species names need to be used.
190
+ const parentContext = {
191
+ genus: parent.genus,
192
+ subgenus: parent.subgenus,
193
+ specificEpithet: parent.specificEpithet,
194
+ incorrect: {
195
+ genus: parent.incorrect && parent.incorrect.genus,
196
+ specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
197
+ }
198
+ }
184
199
 
185
- // The parent context should be amended in the two cases where binomial names
186
- // are truly accepted: synonyms and species (and below) without parents (resp.
187
- // genera and genera and species) to provide parts of the name.
200
+ // Both contexts should be amended in the two cases where binomial names
201
+ // are fully used: (1) synonyms and (2) multinomial taxa without parents to
202
+ // provide parts of the name (e.g. bare species without a genus parent, or
203
+ // even subspecies without a species or genus parent).
188
204
  if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
189
205
  const [, genus, subgenus, species] = name.match(BINAME_PATTERN) || []
190
206
  if (genus) {
191
- parentContext.genus = capitalize(genus)
192
- if (parentContext.incorrect) parentContext.incorrect.genus = capitalize(genus)
207
+ parentContext.genus = parentContext.incorrect.genus = capitalize(genus)
193
208
  }
194
209
  if (subgenus) {
195
210
  parentContext.subgenus = capitalize(subgenus)
196
- if (parentContext.incorrect) parentContext.incorrect.subgenus = capitalize(subgenus)
197
211
  } else if (genus) {
198
- // If a genus is given but no subgenus, remove it from the parent context
212
+ // If a genus is given but no subgenus, remove any existing subgenus
213
+ // from the parent context.
199
214
  delete parentContext.subgenus
200
- if (parentContext.incorrect) delete parentContext.incorrect.subgenus
201
215
  }
202
216
  if (species) {
203
- parentContext.specificEpithet = species
204
- if (parentContext.incorrect) parentContext.incorrect.specificEpithet = species
217
+ parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species
205
218
  }
206
219
  }
207
220
 
208
221
  // In taxa of group, species or lower, the name should just contain the
209
- // (inter)specific epithet and the author information & remarks when processing
222
+ // (infra)specific epithet and the author information & remarks when processing
210
223
  // further.
211
224
  if (compareRanks('group', rank) <= 0) {
212
- const parseContext = parentContext.incorrect || parentContext
213
- if (!parseContext.genus) { parseContext.genus = name.split(' ', 1)[0] }
214
- const genusPrefix = new RegExp(`^${parentContext.genus} (\\(.*?\\) )?`, 'i')
215
- if (name[0] === (parentContext.genus as string)[0]) {
216
- name = name.replace(genusPrefix, '')
225
+ // Remove genus
226
+ const genus = parentContext.incorrect.genus || parentContext.genus || ''
227
+ if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
228
+ name = name.slice(genus.length + 1)
217
229
  }
218
230
 
231
+ // Remove subgenus
232
+ name = name.replace(/^\(.*?\) /, '')
233
+
234
+ // Infraspecific taxa
219
235
  if (compareRanks('species', rank) < 0) {
220
- const speciesPrefix = parseContext.specificEpithet + ' '
221
- if (name.startsWith(speciesPrefix)) {
222
- name = name.slice(speciesPrefix.length)
236
+ // Remove specific epithet
237
+ const species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || ''
238
+ if (name.startsWith(species + ' ')) {
239
+ name = name.slice(species.length + 1)
223
240
  }
241
+
242
+ // Remove rank abbreviations
224
243
  name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '')
225
244
  }
226
245
  }
@@ -236,6 +255,13 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
236
255
  if (!nameParts) {
237
256
  throw new Error(`Taxon "${name}" could not be parsed`)
238
257
  }
258
+
259
+ // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
260
+ // underscores are used, which are replaced here.
261
+ if (nameParts[1].includes('_')) {
262
+ nameParts[1] = nameParts[1].replace(/_/g, ' ')
263
+ }
264
+
239
265
  const [_, taxon, citation = '', notes] = nameParts
240
266
  item.scientificNameAuthorship = capitalizeAuthors(citation)
241
267
  item.taxonRemarks = notes
@@ -282,23 +308,23 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
282
308
  item.genericName = parentContext.genus
283
309
  item.infragenericEpithet = parentContext.subgenus
284
310
  item.specificEpithet = parentContext.specificEpithet
285
- item.intraspecificEpithet = taxon.toLowerCase()
311
+ item.infraspecificEpithet = taxon.toLowerCase()
286
312
 
287
313
  // If possible, names below species should have abbreviations for ranks,
288
314
  // like "subsp."
289
315
  const nameParts = [
290
316
  item.genericName,
291
317
  item.specificEpithet,
292
- item.intraspecificEpithet
318
+ item.infraspecificEpithet
293
319
  ]
294
320
  if (item.taxonRank in RANK_LABELS) {
295
321
  nameParts.splice(2, 0, RANK_LABELS[item.taxonRank])
296
322
  }
297
323
  item.scientificName = nameParts.join(' ')
298
324
 
299
- if (item.intraspecificEpithet !== taxon) {
325
+ if (item.infraspecificEpithet !== taxon) {
300
326
  console.log(item, taxon)
301
- throw new Error(`Intraspecific epithet should be lowercase: "${taxon}"`)
327
+ throw new Error(`Infraspecific epithet should be lowercase: "${taxon}"`)
302
328
  }
303
329
  }
304
330
 
@@ -341,13 +367,8 @@ function parseHeader (header: string): ResourceMetadata {
341
367
  levels = config.levels
342
368
  }
343
369
 
344
- let scope
345
- if (!('scope' in config)) {
346
- scope = []
347
- } else if (!Array.isArray(config.scope)) {
348
- throw new SyntaxError('"scope" should be an array')
349
- } else {
350
- scope = config.scope
370
+ if ('scope' in config) {
371
+ throw new SyntaxError('"scope" data should go in "catalog"')
351
372
  }
352
373
 
353
374
  // No taxon ranks
@@ -361,10 +382,29 @@ function parseHeader (header: string): ResourceMetadata {
361
382
  throw new SyntaxError(`"levels" contains invalid values: ${invalidTaxonRanks.join(', ')}`)
362
383
  }
363
384
 
364
- const metadata: ResourceMetadata = { levels, scope }
385
+ const metadata: ResourceMetadata = { levels }
365
386
 
366
387
  if ('catalog' in config && typeof config.catalog === 'object' && config.catalog !== null) {
367
- metadata.catalog = config.catalog
388
+ const catalog: Record<string, string> = {}
389
+ for (const key in config.catalog) {
390
+ const value = config.catalog[key as keyof object]
391
+ if (typeof value === 'number') {
392
+ catalog[key] = (value as number).toString()
393
+ } else if (typeof value === 'string') {
394
+ catalog[key] = value
395
+ } else {
396
+ throw new SyntaxError(`"catalog" should contain only strings ("${key}")`)
397
+ }
398
+ }
399
+ const work = new Work(catalog)
400
+ const errors = work.validate().filter(({ error }) => error !== 'Value(s) required but missing')
401
+ if (errors.length > 0) {
402
+ throw new SyntaxError(`"catalog" contains errors: ${errors.map(({ field, error }) => `[${field}] ${error}`).join('; ')}`)
403
+ }
404
+ metadata.catalog = {}
405
+ for (const key in work.fields) {
406
+ metadata.catalog[key] = work.get(key) as Value
407
+ }
368
408
  }
369
409
 
370
410
  return metadata
package/test/resources.js CHANGED
@@ -100,4 +100,28 @@ Nematus fåhraei Thomson
100
100
  `, 'T1')
101
101
  assert.strictEqual(resource.taxa['T1:1:1'].scientificName, 'Nematus fåhraei Thomson')
102
102
  })
103
+
104
+ await t.test('parses synonyms in different genera', (t) => {
105
+ const [resource] = resources.parseTextFile(`---
106
+ levels: [species]
107
+ ---
108
+
109
+ Katamenes arbustorum subsp. burlinii
110
+ > arbustorum subsp. burlinii
111
+ = Eumenes arbustorum var. burlinii
112
+ `, 'T1')
113
+ assert.strictEqual(resource.taxa['T1:1:2'].scientificName, 'Eumenes arbustorum var. burlinii')
114
+ })
115
+
116
+ await t.test('parses species without generic names', (t) => {
117
+ const [resource] = resources.parseTextFile(`---
118
+ levels: [genus, subgenus, species]
119
+ ---
120
+
121
+ Microdynerus Thomson, 1874
122
+ Alastorynerus Blüthgen, 1938
123
+ microdynerus (Dalla Torre, 1889)
124
+ `, 'T1')
125
+ assert.strictEqual(resource.taxa['T1:1:3'].scientificName, 'Microdynerus microdynerus (Dalla Torre, 1889)')
126
+ })
103
127
  })