@larsgw/formica 0.2.1 → 0.3.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,16 @@
1
+ # [0.3.0](https://github.com/identification-resources/formica/compare/v0.2.1...v0.3.0) (2023-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **resources:** add accepted ids to dwc and index ([1b5aba8](https://github.com/identification-resources/formica/commit/1b5aba8ff07bba32fc1bbf49927fef7a880eb35e))
7
+ * **resources:** allow updates of just mappings ([0ea62c1](https://github.com/identification-resources/formica/commit/0ea62c143a49129709960de123c0037973356487))
8
+ * **resources:** improve prefix selection heuristics ([7c2e0c9](https://github.com/identification-resources/formica/commit/7c2e0c99d9de7490314f971e8b4e9e5e190a81c2))
9
+ * **resources:** improve taxon name matching ([78ff480](https://github.com/identification-resources/formica/commit/78ff480485ca42bd1d1f2893f230e61db6cb6be8)), closes [#2](https://github.com/identification-resources/formica/issues/2)
10
+ * **resources:** test for rank mismatch ([0e69b7f](https://github.com/identification-resources/formica/commit/0e69b7f0a6654fa5255e797be7516cd06edb4df3)), closes [#2](https://github.com/identification-resources/formica/issues/2)
11
+
12
+
13
+
1
14
  ## [0.2.1](https://github.com/identification-resources/formica/compare/v0.2.0...v0.2.1) (2023-08-10)
2
15
 
3
16
 
@@ -62,6 +62,13 @@ function sortObject(object) {
62
62
  return sorted;
63
63
  }
64
64
  /* eslint-enable @typescript-eslint/no-explicit-any */
65
+ function addTaxon(gbifIndex, gbifId, taxon) {
66
+ if (!(gbifId in gbifIndex)) {
67
+ gbifIndex[gbifId] = [];
68
+ }
69
+ gbifIndex[gbifId].push(taxon[0]);
70
+ gbifIndex[gbifId].sort(util_1.numericSort);
71
+ }
65
72
  function main(args) {
66
73
  return __awaiter(this, void 0, void 0, function () {
67
74
  var REPO_ROOT, files, gbifIndex, resourceIndex;
@@ -106,11 +113,10 @@ function main(args) {
106
113
  taxon = dwc_1[_i];
107
114
  gbifId = taxon[25];
108
115
  if (gbifId) {
109
- if (!(gbifId in gbifIndex)) {
110
- gbifIndex[gbifId] = [];
116
+ addTaxon(gbifIndex, gbifId, taxon);
117
+ if (taxon[27] !== taxon[25]) {
118
+ addTaxon(gbifIndex, taxon[27], taxon);
111
119
  }
112
- gbifIndex[gbifId].push(taxon[0]);
113
- gbifIndex[gbifId].sort(util_1.numericSort);
114
120
  }
115
121
  amendedResource.taxonCount += 1;
116
122
  }
@@ -80,7 +80,9 @@ var DWC_FIELDS = [
80
80
  'subgenus',
81
81
  'higherClassification',
82
82
  'colTaxonID',
83
- 'gbifTaxonID'
83
+ 'gbifTaxonID',
84
+ 'colAcceptedTaxonID',
85
+ 'gbifAcceptedTaxonID'
84
86
  ];
85
87
  var DISPLAY_FIELDS = [
86
88
  'scientificNameID',
@@ -102,16 +104,9 @@ var GBIF_RANKS = [
102
104
  'subspecies',
103
105
  'variety'
104
106
  ];
105
- var VALID_COMMON_PREFIXES = [
106
- 'Plantae|Tracheophyta',
107
- 'Fungi',
108
- 'Fungi|Ascomycota',
109
- 'Fungi|Basidiomycota',
110
- 'Fungi|Zygomycota'
111
- ];
112
107
  function runGnverifier(names) {
113
108
  return new Promise(function (resolve, reject) {
114
- var proc = (0, child_process_1.spawn)('gnverifier', ['-s', '1,11', '-M']);
109
+ var proc = (0, child_process_1.spawn)('gnverifier', ['-s', '1,11', '-f', 'compact', '-M']);
115
110
  var stdout = '';
116
111
  proc.stdout.on('data', function (data) { stdout += data; });
117
112
  proc.stderr.pipe(process.stdout);
@@ -241,6 +236,34 @@ var ResourceProcessor = /** @class */ (function () {
241
236
  });
242
237
  });
243
238
  };
239
+ ResourceProcessor.prototype.runMappingsUpdate = function () {
240
+ return __awaiter(this, void 0, void 0, function () {
241
+ var input, ids, _i, ids_2, id;
242
+ 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*/];
263
+ }
264
+ });
265
+ });
266
+ };
244
267
  ResourceProcessor.prototype.processWork = function (id, update) {
245
268
  return __awaiter(this, void 0, void 0, function () {
246
269
  var resources;
@@ -271,79 +294,61 @@ var ResourceProcessor = /** @class */ (function () {
271
294
  };
272
295
  ResourceProcessor.prototype.processResources = function (id, update) {
273
296
  return __awaiter(this, void 0, void 0, function () {
274
- var resources, amendedResources, _i, resources_1, resource, _a, results, classifications, _b, _c, _d, _e, source, skip, correct, choice, _f, reason;
275
- return __generator(this, function (_g) {
276
- switch (_g.label) {
297
+ var resources, amendedResources, _i, resources_1, resource, results, skip, correct, choice, _a, reason;
298
+ return __generator(this, function (_b) {
299
+ switch (_b.label) {
277
300
  case 0: return [4 /*yield*/, this.processResourceText(id, update)];
278
301
  case 1:
279
- resources = _g.sent();
302
+ resources = _b.sent();
280
303
  amendedResources = [];
281
304
  _i = 0, resources_1 = resources;
282
- _g.label = 2;
305
+ _b.label = 2;
283
306
  case 2:
284
- if (!(_i < resources_1.length)) return [3 /*break*/, 15];
307
+ if (!(_i < resources_1.length)) return [3 /*break*/, 11];
285
308
  resource = resources_1[_i];
286
309
  return [4 /*yield*/, this.processResourceDwc(resource)];
287
310
  case 3:
288
- _a = _g.sent(), results = _a[0], classifications = _a[1];
289
- _b = classifications;
290
- _c = [];
291
- for (_d in _b)
292
- _c.push(_d);
293
- _e = 0;
294
- _g.label = 4;
311
+ results = _b.sent();
312
+ return [4 /*yield*/, this.shouldBeSkipped(resource.id)];
295
313
  case 4:
296
- if (!(_e < _c.length)) return [3 /*break*/, 7];
297
- _d = _c[_e];
298
- if (!(_d in _b)) return [3 /*break*/, 6];
299
- source = _d;
300
- return [4 /*yield*/, this.checkPrefix(resource, classifications, source)];
301
- case 5:
302
- _g.sent();
303
- _g.label = 6;
304
- case 6:
305
- _e++;
306
- return [3 /*break*/, 4];
307
- case 7: return [4 /*yield*/, this.shouldBeSkipped(resource.id)];
308
- case 8:
309
- skip = _g.sent();
310
- if (!!skip) return [3 /*break*/, 13];
314
+ skip = _b.sent();
315
+ if (!!skip) return [3 /*break*/, 9];
311
316
  correct = this.checkResults(results);
312
- if (!!correct) return [3 /*break*/, 13];
317
+ if (!!correct) return [3 /*break*/, 9];
313
318
  return [4 /*yield*/, (0, util_1.promptForAnswers)("".concat(resource.workId, ": problems found in ").concat(resource.id, ". Skip or retry (s/r)? "), ['s', 'S', 'r', 'R'])];
314
- case 9:
315
- choice = _g.sent();
316
- _f = choice;
317
- switch (_f) {
318
- case 's': return [3 /*break*/, 10];
319
- case 'S': return [3 /*break*/, 10];
320
- case 'r': return [3 /*break*/, 12];
321
- case 'R': return [3 /*break*/, 12];
319
+ case 5:
320
+ choice = _b.sent();
321
+ _a = choice;
322
+ switch (_a) {
323
+ case 's': return [3 /*break*/, 6];
324
+ case 'S': return [3 /*break*/, 6];
325
+ case 'r': return [3 /*break*/, 8];
326
+ case 'R': return [3 /*break*/, 8];
322
327
  }
323
- return [3 /*break*/, 13];
324
- case 10: return [4 /*yield*/, (0, util_1.prompt)('Reason for skipping? ')];
325
- case 11:
326
- reason = _g.sent();
328
+ return [3 /*break*/, 9];
329
+ case 6: return [4 /*yield*/, (0, util_1.prompt)('Reason for skipping? ')];
330
+ case 7:
331
+ reason = _b.sent();
327
332
  fs_1.promises.appendFile(this.FILE_PROBLEMS, index_1.csv.formatCsv([[
328
333
  resource.workId,
329
334
  resource.id,
330
335
  reason
331
336
  ]]));
332
337
  console.log("".concat(resource.workId, ": skipping ").concat(resource.id));
333
- return [3 /*break*/, 13];
334
- case 12:
338
+ return [3 /*break*/, 9];
339
+ case 8:
335
340
  {
336
341
  console.log("".concat(resource.workId, ": retrying ").concat(resource.id));
337
342
  return [2 /*return*/, this.processResources(id, update)];
338
343
  }
339
- _g.label = 13;
340
- case 13:
344
+ _b.label = 9;
345
+ case 9:
341
346
  amendedResources.push(results);
342
- _g.label = 14;
343
- case 14:
347
+ _b.label = 10;
348
+ case 10:
344
349
  _i++;
345
350
  return [3 /*break*/, 2];
346
- case 15: return [2 /*return*/, amendedResources];
351
+ case 11: return [2 /*return*/, amendedResources];
347
352
  }
348
353
  });
349
354
  });
@@ -415,119 +420,174 @@ var ResourceProcessor = /** @class */ (function () {
415
420
  };
416
421
  ResourceProcessor.prototype.processResourceDwc = function (resource) {
417
422
  return __awaiter(this, void 0, void 0, function () {
418
- var taxa, names, id, name_1, result, classifications, _a, header, matches, _i, matches_1, match, name_2, source, id, classification, loirId, taxon, results;
419
- return __generator(this, function (_b) {
420
- switch (_b.label) {
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) {
421
426
  case 0:
422
427
  console.log("".concat(resource.workId, ": matching ").concat(resource.id));
423
- taxa = {};
424
- names = [];
428
+ filteredResults = {};
429
+ taxonNames = {};
430
+ names = new Set();
425
431
  for (id in resource.taxa) {
426
432
  name_1 = resource.taxa[id].scientificName;
427
- if (!taxa[name_1]) {
428
- taxa[name_1] = {};
433
+ if (!taxonNames[name_1]) {
434
+ taxonNames[name_1] = [];
429
435
  }
430
- taxa[name_1][id] = __assign({}, resource.taxa[id]);
431
- names.push(name_1);
436
+ taxonNames[name_1].push(id);
437
+ names.add(name_1);
438
+ filteredResults[id] = [];
432
439
  }
433
- return [4 /*yield*/, runGnverifier(names.join('\n'))];
440
+ return [4 /*yield*/, runGnverifier(Array.from(names).join('\n'))];
434
441
  case 1:
435
- result = _b.sent();
436
- classifications = { '1': [], '11': [] };
437
- _a = index_1.csv.parseCsv(result), header = _a[0], matches = _a.slice(1);
438
- for (_i = 0, matches_1 = matches; _i < matches_1.length; _i++) {
439
- match = matches_1[_i];
440
- name_2 = match[header.indexOf('ScientificName')];
441
- source = match[header.indexOf('DataSourceId')];
442
- id = match[header.indexOf('TaxonId')];
443
- classification = match[header.indexOf('ClassificationPath')];
444
- for (loirId in taxa[name_2]) {
445
- taxon = taxa[name_2][loirId];
446
- if (source === '1' && !taxon.colTaxonID) {
447
- taxon.colTaxonID = id;
448
- classifications[source].push([taxon, classification]);
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;
446
+ if (!matches) {
447
+ continue;
448
+ }
449
+ for (_c = 0, matches_1 = matches; _c < matches_1.length; _c++) {
450
+ match = matches_1[_c];
451
+ source = match.dataSourceId;
452
+ currentRank = match.classificationRanks.split('|').pop();
453
+ if (match.scoreDetails.cardinalityScore === 0) {
454
+ // Rank mismatch
455
+ continue;
456
+ }
457
+ else if (source === 11 && currentRank === 'species' && match.classificationPath.endsWith(' spec')) {
458
+ // GBIF species like "Nomada spec"
459
+ continue;
449
460
  }
450
- if (source === '11' && GBIF_RANKS.includes(taxon.taxonRank) && !taxon.gbifTaxonID) {
451
- taxon.gbifTaxonID = id;
452
- classifications[source].push([taxon, classification]);
461
+ for (_d = 0, _e = taxonNames[name_2]; _d < _e.length; _d++) {
462
+ loirId = _e[_d];
463
+ taxon = resource.taxa[loirId];
464
+ if (source === 11 && !GBIF_RANKS.includes(taxon.taxonRank)) {
465
+ // Exclude GBIF matches for ranks that are not in GBIF
466
+ continue;
467
+ }
468
+ else if (source === 11 && !match.isSynonym && currentRank !== taxon.taxonRank) {
469
+ // Exclude matches with rank mismatches (only possible
470
+ // for non-synonyms).
471
+ continue;
472
+ }
473
+ if (!filteredResults[loirId]) {
474
+ filteredResults[loirId] = [];
475
+ }
476
+ filteredResults[loirId].push({
477
+ source: source,
478
+ id: match.recordId,
479
+ currentId: match.currentRecordId,
480
+ classificationPath: match.classificationPath.split('|')
481
+ });
453
482
  }
454
483
  }
455
484
  }
456
- results = __assign(__assign({}, resource), { taxa: Object.fromEntries(Object.values(resource.taxa).map(function (taxon) { return [
457
- taxon.scientificNameID,
458
- taxa[taxon.scientificName][taxon.scientificNameID]
459
- ]; })) });
460
- return [2 /*return*/, [results, classifications]];
485
+ 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
+ groupedNameMatches = groupNameMatches(filteredResults);
489
+ 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;
501
+ return [4 /*yield*/, this.selectPrefixes(resource, groupedNameMatches, source)];
502
+ case 4:
503
+ matches = _l.sent();
504
+ 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];
461
510
  }
462
511
  });
463
512
  });
464
513
  };
465
- ResourceProcessor.prototype.checkPrefix = function (resource, classifications, source) {
514
+ ResourceProcessor.prototype.selectPrefixes = function (resource, groupedNameMatches, source) {
466
515
  return __awaiter(this, void 0, void 0, function () {
467
- var lists, prefix, _i, _a, _b, taxon, list, parts, i, choice;
516
+ var prefixes, mappedTaxa, _i, prefixes_1, prefix, taxon, missedTaxonCount, choice, i, prefix, taxa, taxonIds, j, taxonId, taxon, match, matches, _a, _b, i, prefix, taxa, id;
468
517
  return __generator(this, function (_c) {
469
518
  switch (_c.label) {
470
519
  case 0:
471
- lists = classifications[source];
472
- if (!lists.length) {
473
- return [2 /*return*/];
520
+ prefixes = Object.keys(groupedNameMatches[source]);
521
+ if (prefixes.length === 0) {
522
+ return [2 /*return*/, {}];
474
523
  }
475
- prefix = lists[0][1].split('|');
476
- _i = 0, _a = lists.slice(1);
477
- _c.label = 1;
478
- case 1:
479
- if (!(_i < _a.length)) return [3 /*break*/, 9];
480
- _b = _a[_i], taxon = _b[0], list = _b[1];
481
- parts = list.split('|');
482
- i = 0;
483
- _c.label = 2;
484
- case 2:
485
- if (!(i < parts.length)) return [3 /*break*/, 8];
486
- if (!(parts[i] !== prefix[i] && i < 3)) return [3 /*break*/, 7];
487
- choice = void 0;
488
- if (!(source === '1' || taxon.taxonomicStatus !== 'accepted')) return [3 /*break*/, 3];
489
- choice = 'd';
524
+ else if (prefixes.length === 1) {
525
+ return [2 /*return*/, groupedNameMatches[source][prefixes[0]]];
526
+ }
527
+ mappedTaxa = {};
528
+ for (_i = 0, prefixes_1 = prefixes; _i < prefixes_1.length; _i++) {
529
+ prefix = prefixes_1[_i];
530
+ for (taxon in groupedNameMatches[source][prefix]) {
531
+ mappedTaxa[taxon] = true;
532
+ }
533
+ }
534
+ missedTaxonCount = Object.keys(mappedTaxa).length - Object.keys(groupedNameMatches[source][prefixes[0]]).length;
535
+ if (missedTaxonCount === 0) {
536
+ // Multiple prefixes but the first one maps all taxa (not counting that are unmapped in all prefixes)
537
+ return [2 /*return*/, groupedNameMatches[source][prefixes[0]]];
538
+ }
539
+ console.error("".concat(resource.workId, ": source ").concat(source, " results in multiple prefixes"));
540
+ if (!(missedTaxonCount <= 5)) return [3 /*break*/, 1];
541
+ console.error(" Most common prefix misses ".concat(missedTaxonCount, " taxa: automatically selecting most common prefix..."));
542
+ choice = '1';
490
543
  return [3 /*break*/, 6];
491
- case 3:
492
- if (!VALID_COMMON_PREFIXES.includes(prefix.slice(0, i).join('|'))) return [3 /*break*/, 4];
493
- choice = 'k';
544
+ case 1:
545
+ if (!(source === '1')) return [3 /*break*/, 2];
546
+ console.error(" Catalogue of Life: automatically selecting most common prefix...");
547
+ choice = '1';
494
548
  return [3 /*break*/, 6];
549
+ case 2:
550
+ for (i = 0; i < prefixes.length; i++) {
551
+ prefix = prefixes[i];
552
+ taxa = groupedNameMatches[source][prefix];
553
+ taxonIds = Object.keys(taxa);
554
+ console.error(" [".concat(i + 1, "] ").concat(prefix, " (").concat(taxonIds.length, " taxa)"));
555
+ for (j = 0; j < Math.min(9, taxonIds.length); j++) {
556
+ taxonId = taxonIds[j];
557
+ taxon = resource.taxa[taxonId];
558
+ match = taxa[taxonId];
559
+ console.error(" taxon: ".concat(taxonId, " \"").concat(taxon.scientificName, "\" - ").concat(match.classificationPath.join('|')));
560
+ }
561
+ if (taxonIds.length > 9) {
562
+ console.error(" ...");
563
+ }
564
+ }
565
+ _c.label = 3;
566
+ case 3: return [4 /*yield*/, (0, util_1.prompt)(" Select prefixes (1-".concat(prefixes.length, ")? "))];
495
567
  case 4:
496
- console.log("".concat(resource.workId, ": source ").concat(source, " results in short prefix \"").concat(prefix.slice(0, i).join('|'), "\" (").concat(i, " taxa)"));
497
- console.log(" taxon: ".concat(taxon.scientificNameID, " \"").concat(taxon.scientificName, "\""));
498
- console.log(" class: ".concat(parts.join('|')));
499
- console.log(" prefx: ".concat(prefix.join('|')));
500
- return [4 /*yield*/, (0, util_1.promptForAnswers)(" Keep or delete (k/d)? ", ['k', 'K', 'd', 'D'])];
501
- case 5:
502
568
  choice = _c.sent();
569
+ _c.label = 5;
570
+ case 5:
571
+ if (!/^(|\d+(,\d+)*)$/.test(choice)) return [3 /*break*/, 3];
503
572
  _c.label = 6;
504
573
  case 6:
505
- switch (choice) {
506
- case 'k':
507
- case 'K': {
508
- console.log(" keeping...");
509
- break;
510
- }
511
- case 'd':
512
- case 'D': {
513
- console.log(" deleting...");
514
- if (source === '1') {
515
- delete taxon.colTaxonID;
516
- }
517
- else if (source === '11') {
518
- delete taxon.gbifTaxonID;
574
+ console.error(" Applying selection...");
575
+ if (choice === '') {
576
+ return [2 /*return*/, {}];
577
+ }
578
+ matches = {};
579
+ for (_a = 0, _b = choice.split(','); _a < _b.length; _a++) {
580
+ i = _b[_a];
581
+ prefix = prefixes[parseInt(i) - 1];
582
+ taxa = groupedNameMatches[source][prefix];
583
+ for (id in taxa) {
584
+ if (id in matches) {
585
+ continue;
519
586
  }
520
- break;
587
+ matches[id] = taxa[id];
521
588
  }
522
589
  }
523
- return [3 /*break*/, 8];
524
- case 7:
525
- i++;
526
- return [3 /*break*/, 2];
527
- case 8:
528
- _i++;
529
- return [3 /*break*/, 1];
530
- case 9: return [2 /*return*/];
590
+ return [2 /*return*/, matches];
531
591
  }
532
592
  });
533
593
  });
@@ -578,6 +638,9 @@ function main() {
578
638
  update: {
579
639
  type: 'boolean',
580
640
  short: 'u'
641
+ },
642
+ 'update-mappings': {
643
+ type: 'boolean'
581
644
  }
582
645
  },
583
646
  allowPositionals: true
@@ -586,7 +649,16 @@ function main() {
586
649
  process.on('exit', function () {
587
650
  process.stdout.write('\n');
588
651
  });
589
- var task = args.values.update ? processor.runUpdate() : processor.run();
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
+ }
590
662
  task.catch(function (error) {
591
663
  console.error(error);
592
664
  process.exit(1);
package/lib/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * as catalog from './catalog/index';
2
2
  export * as resources from './resources/index';
3
+ export * as taxonNames from './taxon-names/index';
3
4
  export * as csv from './csv';
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.csv = exports.resources = exports.catalog = void 0;
3
+ exports.csv = exports.taxonNames = exports.resources = exports.catalog = void 0;
4
4
  exports.catalog = require("./catalog/index");
5
5
  exports.resources = require("./resources/index");
6
+ exports.taxonNames = require("./taxon-names/index");
6
7
  exports.csv = require("./csv");
@@ -0,0 +1,2 @@
1
+ export declare function groupNameMatches(results: Record<TaxonId, TaxonMatch[]>): GroupedNameMatches;
2
+ export declare function amendResource(resource: AmendedResource, source: string, matches: Record<TaxonId, TaxonMatch>): void;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.amendResource = exports.groupNameMatches = void 0;
4
+ var MINIMUM_PREFIX_LENGTH = 3;
5
+ var VALID_COMMON_PREFIXES = new Set([
6
+ 'Plantae|Tracheophyta',
7
+ 'Fungi',
8
+ 'Fungi|Ascomycota',
9
+ 'Fungi|Basidiomycota',
10
+ 'Fungi|Zygomycota'
11
+ ]);
12
+ function getCommonPrefix(a, b) {
13
+ for (var i = 0; i < Math.max(a.length, b.length); i++) {
14
+ if (a[i] !== b[i]) {
15
+ return a.slice(0, i);
16
+ }
17
+ }
18
+ return a.slice();
19
+ }
20
+ function isValidPrefix(a, b) {
21
+ var prefix = getCommonPrefix(a, b);
22
+ return VALID_COMMON_PREFIXES.has(prefix.join('|')) || prefix.length >= MINIMUM_PREFIX_LENGTH;
23
+ }
24
+ function groupNameMatches(results) {
25
+ var prefixes = {};
26
+ for (var scientificNameID in results) {
27
+ var _loop_1 = function (result) {
28
+ if (!prefixes[result.source]) {
29
+ prefixes[result.source] = [];
30
+ }
31
+ var prefix = prefixes[result.source].find(function (prefix) { return isValidPrefix(prefix[0], result.classificationPath); });
32
+ if (!prefix) {
33
+ prefix = [result.classificationPath, {}];
34
+ prefixes[result.source].push(prefix);
35
+ }
36
+ else {
37
+ prefix[0] = getCommonPrefix(prefix[0], result.classificationPath);
38
+ }
39
+ if (scientificNameID in prefix[1]) {
40
+ return "continue";
41
+ }
42
+ prefix[1][scientificNameID] = result;
43
+ };
44
+ for (var _i = 0, _a = results[scientificNameID]; _i < _a.length; _i++) {
45
+ var result = _a[_i];
46
+ _loop_1(result);
47
+ }
48
+ }
49
+ var groupedNameMatches = {};
50
+ for (var source in prefixes) {
51
+ groupedNameMatches[source] = prefixes[source]
52
+ .sort(function (a, b) { return Object.keys(b[1]).length - Object.keys(a[1]).length; })
53
+ .reduce(function (map, _a) {
54
+ var prefix = _a[0], taxa = _a[1];
55
+ map[prefix.join('|')] = taxa;
56
+ return map;
57
+ }, {});
58
+ }
59
+ return groupedNameMatches;
60
+ }
61
+ exports.groupNameMatches = groupNameMatches;
62
+ function amendResource(resource, source, matches) {
63
+ for (var id in matches) {
64
+ var match = matches[id];
65
+ if (source === '1') {
66
+ resource.taxa[id].colTaxonID = match.id;
67
+ if (match.currentId) {
68
+ resource.taxa[id].colAcceptedTaxonID = match.currentId;
69
+ }
70
+ }
71
+ else if (source === '11') {
72
+ resource.taxa[id].gbifTaxonID = match.id;
73
+ if (match.currentId) {
74
+ resource.taxa[id].gbifAcceptedTaxonID = match.currentId;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ exports.amendResource = amendResource;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -21,6 +21,14 @@ function sortObject (object: Record<string, any>): Record<string, any> {
21
21
  }
22
22
  /* eslint-enable @typescript-eslint/no-explicit-any */
23
23
 
24
+ function addTaxon (gbifIndex: Record<string, TaxonId[]>, gbifId: string, taxon: string[]) {
25
+ if (!(gbifId in gbifIndex)) {
26
+ gbifIndex[gbifId] = []
27
+ }
28
+ gbifIndex[gbifId].push(taxon[0])
29
+ gbifIndex[gbifId].sort(numericSort)
30
+ }
31
+
24
32
  async function main (args: string[]): Promise<void> {
25
33
  const REPO_ROOT = path.resolve(args[0])
26
34
 
@@ -50,11 +58,10 @@ async function main (args: string[]): Promise<void> {
50
58
  for (const taxon of dwc) {
51
59
  const gbifId = taxon[25]
52
60
  if (gbifId) {
53
- if (!(gbifId in gbifIndex)) {
54
- gbifIndex[gbifId] = []
61
+ addTaxon(gbifIndex, gbifId, taxon)
62
+ if (taxon[27] !== taxon[25]) {
63
+ addTaxon(gbifIndex, taxon[27], taxon)
55
64
  }
56
- gbifIndex[gbifId].push(taxon[0])
57
- gbifIndex[gbifId].sort(numericSort)
58
65
  }
59
66
  amendedResource.taxonCount += 1
60
67
  }
@@ -8,17 +8,6 @@ import * as util from 'util'
8
8
  import { csv } from '../index'
9
9
  import { prompt, promptForAnswers, numericSort, runCommand } from './util'
10
10
 
11
- interface AmendedTaxon extends Taxon {
12
- colTaxonID?: string,
13
- gbifTaxonID?: string
14
- }
15
-
16
- interface AmendedResource extends Resource {
17
- taxa: Record<TaxonId, AmendedTaxon>
18
- }
19
-
20
- type Classifications = Record<string, Array<[AmendedTaxon, string]>>
21
-
22
11
  const DWC_FIELDS: string[] = [
23
12
  'scientificNameID',
24
13
  'scientificName',
@@ -49,7 +38,9 @@ const DWC_FIELDS: string[] = [
49
38
  'higherClassification',
50
39
 
51
40
  'colTaxonID',
52
- 'gbifTaxonID'
41
+ 'gbifTaxonID',
42
+ 'colAcceptedTaxonID',
43
+ 'gbifAcceptedTaxonID'
53
44
  ]
54
45
 
55
46
  const DISPLAY_FIELDS: string[] = [
@@ -74,17 +65,9 @@ const GBIF_RANKS: Rank[] = [
74
65
  'variety'
75
66
  ]
76
67
 
77
- const VALID_COMMON_PREFIXES = [
78
- 'Plantae|Tracheophyta',
79
- 'Fungi',
80
- 'Fungi|Ascomycota',
81
- 'Fungi|Basidiomycota',
82
- 'Fungi|Zygomycota'
83
- ]
84
-
85
68
  function runGnverifier (names: string): Promise<string> {
86
69
  return new Promise((resolve, reject) => {
87
- const proc = spawn('gnverifier', ['-s', '1,11', '-M'])
70
+ const proc = spawn('gnverifier', ['-s', '1,11', '-f', 'compact', '-M'])
88
71
  let stdout = ''
89
72
  proc.stdout.on('data', data => { stdout += data })
90
73
  proc.stderr.pipe(process.stdout)
@@ -153,6 +136,18 @@ class ResourceProcessor {
153
136
  }
154
137
  }
155
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)
148
+ }
149
+ }
150
+
156
151
  async processWork (id: WorkId, update?: boolean): Promise<void> {
157
152
  const resources = await this.processResources(id, update)
158
153
 
@@ -174,11 +169,7 @@ class ResourceProcessor {
174
169
 
175
170
  const amendedResources = []
176
171
  for (const resource of resources) {
177
- const [results, classifications] = await this.processResourceDwc(resource)
178
-
179
- for (const source in classifications) {
180
- await this.checkPrefix(resource, classifications, source)
181
- }
172
+ const results = await this.processResourceDwc(resource)
182
173
 
183
174
  const skip = await this.shouldBeSkipped(resource.id)
184
175
 
@@ -256,100 +247,153 @@ class ResourceProcessor {
256
247
  }
257
248
  }
258
249
 
259
- async processResourceDwc (resource: Resource): Promise<[AmendedResource, Classifications]> {
250
+ async processResourceDwc (resource: Resource): Promise<AmendedResource> {
260
251
  console.log(`${resource.workId}: matching ${resource.id}`)
261
- const taxa: Record<string, Record<TaxonId, AmendedTaxon>> = {}
262
- const names = []
252
+
253
+ const filteredResults: Record<TaxonId, TaxonMatch[]> = {}
254
+ const taxonNames: Record<string, TaxonId[]> = {}
255
+ const names = new Set()
263
256
  for (const id in resource.taxa) {
264
257
  const name = resource.taxa[id].scientificName
265
258
 
266
- if (!taxa[name]) { taxa[name] = {} }
267
- taxa[name][id] = { ...resource.taxa[id] }
259
+ if (!taxonNames[name]) { taxonNames[name] = [] }
260
+ taxonNames[name].push(id)
268
261
 
269
- names.push(name)
262
+ names.add(name)
263
+ filteredResults[id] = []
270
264
  }
271
265
 
272
- const result = await runGnverifier(names.join('\n'))
273
- const classifications: Classifications = { '1': [], '11': [] }
274
-
275
- const [header, ...matches] = csv.parseCsv(result)
276
- for (const match of matches) {
277
- const name = match[header.indexOf('ScientificName')]
278
- const source = match[header.indexOf('DataSourceId')]
279
- const id = match[header.indexOf('TaxonId')]
280
- const classification = match[header.indexOf('ClassificationPath')]
281
-
282
- for (const loirId in taxa[name]) {
283
- const taxon = taxa[name][loirId]
284
- if (source === '1' && !taxon.colTaxonID) {
285
- taxon.colTaxonID = id
286
- classifications[source].push([taxon, classification])
266
+ const result = await runGnverifier(Array.from(names).join('\n'))
267
+ for (const results of result.trim().split('\n')) {
268
+ const { name, results: matches } = JSON.parse(results)
269
+
270
+ if (!matches) {
271
+ continue
272
+ }
273
+
274
+ for (const match of matches) {
275
+ const source = match.dataSourceId
276
+ const currentRank = match.classificationRanks.split('|').pop()
277
+
278
+ if (match.scoreDetails.cardinalityScore === 0) {
279
+ // Rank mismatch
280
+ continue
281
+ } else if (source === 11 && currentRank === 'species' && match.classificationPath.endsWith(' spec')) {
282
+ // GBIF species like "Nomada spec"
283
+ continue
287
284
  }
288
- if (source === '11' && GBIF_RANKS.includes(taxon.taxonRank) && !taxon.gbifTaxonID) {
289
- taxon.gbifTaxonID = id
290
- classifications[source].push([taxon, classification])
285
+
286
+ for (const loirId of taxonNames[name]) {
287
+ const taxon = resource.taxa[loirId]
288
+
289
+ if (source === 11 && !GBIF_RANKS.includes(taxon.taxonRank)) {
290
+ // Exclude GBIF matches for ranks that are not in GBIF
291
+ continue
292
+ } else if (source === 11 && !match.isSynonym && currentRank !== taxon.taxonRank) {
293
+ // Exclude matches with rank mismatches (only possible
294
+ // for non-synonyms).
295
+ continue
296
+ }
297
+
298
+ if (!filteredResults[loirId]) {
299
+ filteredResults[loirId] = []
300
+ }
301
+
302
+ filteredResults[loirId].push({
303
+ source,
304
+ id: match.recordId,
305
+ currentId: match.currentRecordId,
306
+ classificationPath: match.classificationPath.split('|')
307
+ })
291
308
  }
292
309
  }
293
310
  }
294
311
 
295
- const results = {
296
- ...resource,
297
- taxa: Object.fromEntries(Object.values(resource.taxa).map(taxon => [
298
- taxon.scientificNameID,
299
- taxa[taxon.scientificName][taxon.scientificNameID]
300
- ]))
312
+ const { taxonNames: { amendResource, groupNameMatches } } = await import('../index')
313
+ const groupedNameMatches = groupNameMatches(filteredResults)
314
+
315
+ const amendedResource: AmendedResource = { ...resource, taxa: { ...resource.taxa } }
316
+ for (const source in groupedNameMatches) {
317
+ const matches = await this.selectPrefixes(resource, groupedNameMatches, source)
318
+ amendResource(amendedResource, source, matches)
301
319
  }
302
320
 
303
- return [results, classifications]
321
+ return amendedResource
304
322
  }
305
323
 
306
- async checkPrefix (resource: AmendedResource, classifications: Classifications, source: string): Promise<void> {
307
- const lists = classifications[source]
308
- if (!lists.length) { return }
309
- const prefix = lists[0][1].split('|')
310
-
311
- for (const [taxon, list] of lists.slice(1)) {
312
- const parts = list.split('|')
313
- for (let i = 0; i < parts.length; i++) {
314
- if (parts[i] !== prefix[i] && i < 3) {
315
- let choice
316
-
317
- if (source === '1' || taxon.taxonomicStatus !== 'accepted') {
318
- choice = 'd'
319
- } else if (VALID_COMMON_PREFIXES.includes(prefix.slice(0, i).join('|'))) {
320
- choice = 'k'
321
- } else {
322
- console.log(`${resource.workId}: source ${source} results in short prefix "${prefix.slice(0, i).join('|')}" (${i} taxa)`)
323
- console.log(` taxon: ${taxon.scientificNameID} "${taxon.scientificName}"`)
324
- console.log(` class: ${parts.join('|')}`)
325
- console.log(` prefx: ${prefix.join('|')}`)
326
-
327
- choice = await promptForAnswers(` Keep or delete (k/d)? `, ['k', 'K', 'd', 'D'])
328
- }
324
+ async selectPrefixes (resource: Resource, groupedNameMatches: GroupedNameMatches, source: string): Promise<Record<TaxonId, TaxonMatch>> {
325
+ const prefixes = Object.keys(groupedNameMatches[source])
326
+ if (prefixes.length === 0) {
327
+ return {}
328
+ } else if (prefixes.length === 1) {
329
+ return groupedNameMatches[source][prefixes[0]]
330
+ }
329
331
 
330
- switch (choice) {
331
- case 'k':
332
- case 'K': {
333
- console.log(` keeping...`)
334
- break
335
- }
332
+ // Count total mapped taxa
333
+ const mappedTaxa: Record<TaxonId, boolean> = {}
334
+ for (const prefix of prefixes) {
335
+ for (const taxon in groupedNameMatches[source][prefix]) {
336
+ mappedTaxa[taxon] = true
337
+ }
338
+ }
339
+ const missedTaxonCount = Object.keys(mappedTaxa).length - Object.keys(groupedNameMatches[source][prefixes[0]]).length
336
340
 
337
- case 'd':
338
- case 'D': {
339
- console.log(` deleting...`)
340
- if (source === '1') {
341
- delete taxon.colTaxonID
342
- } else if (source === '11') {
343
- delete taxon.gbifTaxonID
344
- }
345
- break
346
- }
347
- }
341
+ if (missedTaxonCount === 0) {
342
+ // Multiple prefixes but the first one maps all taxa (not counting that are unmapped in all prefixes)
343
+ return groupedNameMatches[source][prefixes[0]]
344
+ }
345
+
346
+ console.error(`${resource.workId}: source ${source} results in multiple prefixes`)
347
+
348
+ let choice
349
+ if (missedTaxonCount <= 5) {
350
+ console.error(` Most common prefix misses ${missedTaxonCount} taxa: automatically selecting most common prefix...`)
351
+ choice = '1'
352
+ } else if (source === '1') {
353
+ console.error(` Catalogue of Life: automatically selecting most common prefix...`)
354
+ choice = '1'
355
+ } else {
356
+ for (let i = 0; i < prefixes.length; i++) {
357
+ const prefix = prefixes[i]
358
+ const taxa = groupedNameMatches[source][prefix]
359
+ const taxonIds = Object.keys(taxa)
360
+
361
+ console.error(` [${i + 1}] ${prefix} (${taxonIds.length} taxa)`)
362
+ for (let j = 0; j < Math.min(9, taxonIds.length); j++) {
363
+ const taxonId = taxonIds[j]
364
+ const taxon = resource.taxa[taxonId]
365
+ const match = taxa[taxonId]
366
+ console.error(` taxon: ${taxonId} "${taxon.scientificName}" - ${match.classificationPath.join('|')}`)
367
+ }
368
+ if (taxonIds.length > 9) {
369
+ console.error(` ...`)
370
+ }
371
+ }
372
+
373
+ do {
374
+ choice = await prompt(` Select prefixes (1-${prefixes.length})? `)
375
+ } while (!/^(|\d+(,\d+)*)$/.test(choice))
376
+ }
377
+
378
+ console.error(` Applying selection...`)
379
+
380
+ if (choice === '') {
381
+ return {}
382
+ }
348
383
 
349
- break
384
+ const matches: Record<TaxonId, TaxonMatch> = {}
385
+ for (const i of choice.split(',')) {
386
+ const prefix = prefixes[parseInt(i) - 1]
387
+ const taxa = groupedNameMatches[source][prefix]
388
+ for (const id in taxa) {
389
+ if (id in matches) {
390
+ continue
350
391
  }
392
+ matches[id] = taxa[id]
351
393
  }
352
394
  }
395
+
396
+ return matches
353
397
  }
354
398
 
355
399
  checkResults (resource: AmendedResource): boolean {
@@ -388,6 +432,9 @@ function main (): void {
388
432
  update: {
389
433
  type: 'boolean',
390
434
  short: 'u'
435
+ },
436
+ 'update-mappings': {
437
+ type: 'boolean'
391
438
  }
392
439
  },
393
440
  allowPositionals: true
@@ -398,7 +445,15 @@ function main (): void {
398
445
  process.stdout.write('\n')
399
446
  })
400
447
 
401
- const task = args.values.update ? processor.runUpdate() : processor.run()
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()
455
+ }
456
+
402
457
  task.catch(error => {
403
458
  console.error(error)
404
459
  process.exit(1)
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * as catalog from './catalog/index'
2
2
  export * as resources from './resources/index'
3
+ export * as taxonNames from './taxon-names/index'
3
4
  export * as csv from './csv'
package/src/module.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  type Schema = Record<string, FieldSpecification>
2
2
  type FieldSpecification = {
3
- required: boolean,
4
- multiple: boolean | FieldSpecificationCallbackMultiple
5
- format?: string[] | RegExp | FieldSpecificationCallbackFormat
3
+ required: boolean,
4
+ multiple: boolean | FieldSpecificationCallbackMultiple
5
+ format?: string[] | RegExp | FieldSpecificationCallbackFormat
6
6
  }
7
7
 
8
8
  type Value = string[] | string
@@ -11,12 +11,12 @@ type FieldSpecificationCallbackMultiple = (entry: Record<string, Value>) => bool
11
11
  type FieldSpecificationCallbackFormat = (value: SingleValue) => boolean;
12
12
 
13
13
  interface FieldError {
14
- field: string,
15
- error: string
14
+ field: string,
15
+ error: string
16
16
  }
17
17
 
18
18
  interface WorkError extends FieldError {
19
- entity: WorkId
19
+ entity: WorkId
20
20
  }
21
21
 
22
22
  type Rank = string
@@ -28,59 +28,59 @@ type ResourceId = string
28
28
  type WorkId = string
29
29
 
30
30
  interface WorkingTaxon {
31
- scientificNameID?: TaxonId,
32
- scientificName?: string,
33
- scientificNameAuthorship?: string,
34
- genericName?: string,
35
- infragenericEpithet?: string,
36
- specificEpithet?: string,
37
- intraspecificEpithet?: string,
38
-
39
- taxonRank?: Rank,
40
- taxonRemarks?: string,
41
- collectionCode?: ResourceId,
42
-
43
- taxonomicStatus?: TaxonStatus,
44
- acceptedNameUsageID?: TaxonId,
45
- acceptedNameUsage?: string,
46
-
47
- parentNameUsageID?: TaxonId,
48
- parentNameUsage?: string,
49
- kingdom?: string,
50
- phylum?: string,
51
- class?: string,
52
- order?: string,
53
- family?: string,
54
- subfamily?: string,
55
- genus?: string,
56
- subgenus?: string,
57
- higherClassification?: string,
58
-
59
- // Non-standard
60
- scientificNameOnly?: string,
61
- incorrect?: WorkingTaxon
31
+ scientificNameID?: TaxonId,
32
+ scientificName?: string,
33
+ scientificNameAuthorship?: string,
34
+ genericName?: string,
35
+ infragenericEpithet?: string,
36
+ specificEpithet?: string,
37
+ intraspecificEpithet?: string,
38
+
39
+ taxonRank?: Rank,
40
+ taxonRemarks?: string,
41
+ collectionCode?: ResourceId,
42
+
43
+ taxonomicStatus?: TaxonStatus,
44
+ acceptedNameUsageID?: TaxonId,
45
+ acceptedNameUsage?: string,
46
+
47
+ parentNameUsageID?: TaxonId,
48
+ parentNameUsage?: string,
49
+ kingdom?: string,
50
+ phylum?: string,
51
+ class?: string,
52
+ order?: string,
53
+ family?: string,
54
+ subfamily?: string,
55
+ genus?: string,
56
+ subgenus?: string,
57
+ higherClassification?: string,
58
+
59
+ // Non-standard
60
+ scientificNameOnly?: string,
61
+ incorrect?: WorkingTaxon
62
62
  }
63
63
 
64
64
  interface Taxon extends WorkingTaxon {
65
- scientificNameID: TaxonId,
66
- scientificName: string,
67
- taxonRank: Rank,
68
- collectionCode: ResourceId,
69
- taxonomicStatus: string
65
+ scientificNameID: TaxonId,
66
+ scientificName: string,
67
+ taxonRank: Rank,
68
+ collectionCode: ResourceId,
69
+ taxonomicStatus: string
70
70
  }
71
71
 
72
72
  interface ResourceMetadata {
73
- levels: Rank[],
74
- scope: string[],
75
- catalog?: object
73
+ levels: Rank[],
74
+ scope: string[],
75
+ catalog?: object
76
76
  }
77
77
 
78
78
  interface Resource {
79
- id: string,
80
- file: string,
81
- workId: string,
82
- metadata: ResourceMetadata,
83
- taxa: Record<TaxonId, Taxon>
79
+ id: string,
80
+ file: string,
81
+ workId: string,
82
+ metadata: ResourceMetadata,
83
+ taxa: Record<TaxonId, Taxon>
84
84
  }
85
85
 
86
86
  interface ResourceHistory {
@@ -103,3 +103,23 @@ declare enum ResourceDiffType {
103
103
  Modified = '~',
104
104
  Unchanged = '='
105
105
  }
106
+
107
+ interface AmendedTaxon extends Taxon {
108
+ colTaxonID?: string,
109
+ colAcceptedTaxonID?: string,
110
+ gbifTaxonID?: string,
111
+ gbifAcceptedTaxonID?: string
112
+ }
113
+
114
+ interface AmendedResource extends Resource {
115
+ taxa: Record<TaxonId, AmendedTaxon>
116
+ }
117
+
118
+ interface TaxonMatch {
119
+ source: number,
120
+ id: string,
121
+ currentId?: string,
122
+ classificationPath: string[]
123
+ }
124
+
125
+ type GroupedNameMatches = Record<string, Record<string, Record<TaxonId, TaxonMatch>>>
@@ -0,0 +1,79 @@
1
+ const MINIMUM_PREFIX_LENGTH = 3
2
+ const VALID_COMMON_PREFIXES = new Set([
3
+ 'Plantae|Tracheophyta',
4
+ 'Fungi',
5
+ 'Fungi|Ascomycota',
6
+ 'Fungi|Basidiomycota',
7
+ 'Fungi|Zygomycota'
8
+ ])
9
+
10
+ function getCommonPrefix (a: string[], b: string[]): string[] {
11
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
12
+ if (a[i] !== b[i]) {
13
+ return a.slice(0, i)
14
+ }
15
+ }
16
+ return a.slice()
17
+ }
18
+
19
+ function isValidPrefix (a: string[], b: string[]): boolean {
20
+ const prefix = getCommonPrefix(a, b)
21
+ return VALID_COMMON_PREFIXES.has(prefix.join('|')) || prefix.length >= MINIMUM_PREFIX_LENGTH
22
+ }
23
+
24
+ export function groupNameMatches (results: Record<TaxonId, TaxonMatch[]>): GroupedNameMatches {
25
+ const prefixes: Record<string, [string[], Record<TaxonId, TaxonMatch>][]> = {}
26
+
27
+ for (const scientificNameID in results) {
28
+ for (const result of results[scientificNameID]) {
29
+ if (!prefixes[result.source]) {
30
+ prefixes[result.source] = []
31
+ }
32
+
33
+ let prefix = prefixes[result.source].find(prefix => isValidPrefix(prefix[0], result.classificationPath))
34
+
35
+ if (!prefix) {
36
+ prefix = [result.classificationPath, {}]
37
+ prefixes[result.source].push(prefix)
38
+ } else {
39
+ prefix[0] = getCommonPrefix(prefix[0], result.classificationPath)
40
+ }
41
+
42
+ if (scientificNameID in prefix[1]) {
43
+ continue
44
+ }
45
+
46
+ prefix[1][scientificNameID] = result
47
+ }
48
+ }
49
+
50
+ const groupedNameMatches: GroupedNameMatches = {}
51
+ for (const source in prefixes) {
52
+ groupedNameMatches[source] = prefixes[source]
53
+ .sort((a, b) => Object.keys(b[1]).length - Object.keys(a[1]).length)
54
+ .reduce((map: Record<string, Record<TaxonId, TaxonMatch>>, [prefix, taxa]) => {
55
+ map[prefix.join('|')] = taxa
56
+ return map
57
+ }, {})
58
+ }
59
+
60
+ return groupedNameMatches
61
+ }
62
+
63
+ export function amendResource (resource: AmendedResource, source: string, matches: Record<TaxonId, TaxonMatch>) {
64
+ for (const id in matches) {
65
+ const match = matches[id]
66
+
67
+ if (source === '1') {
68
+ resource.taxa[id].colTaxonID = match.id
69
+ if (match.currentId) {
70
+ resource.taxa[id].colAcceptedTaxonID = match.currentId
71
+ }
72
+ } else if (source === '11') {
73
+ resource.taxa[id].gbifTaxonID = match.id
74
+ if (match.currentId) {
75
+ resource.taxa[id].gbifAcceptedTaxonID = match.currentId
76
+ }
77
+ }
78
+ }
79
+ }