@larsgw/formica 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ # [0.7.0](https://github.com/identification-resources/formica/compare/v0.6.8...v0.7.0) (2025-03-14)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **resources:** improve diff regarding indet lines ([2615437](https://github.com/identification-resources/formica/commit/26154374f50d680c5f3e187cd6a1a5eadbac6b5d))
7
+
8
+
9
+ ### Features
10
+
11
+ * **resources:** change syntax of "indet" line ([d53363e](https://github.com/identification-resources/formica/commit/d53363e65bfa77a2eb247999316621f9ca0bff59))
12
+
13
+
14
+ ### BREAKING CHANGES
15
+
16
+ * **resources:** "indet" lines now have to be prefixed with "[indet]"
17
+
18
+
19
+
20
+ ## [0.6.8](https://github.com/identification-resources/formica/compare/v0.6.7...v0.6.8) (2025-03-13)
21
+
22
+
23
+ ### Features
24
+
25
+ * **resources:** add support for intergeneric hybrids without parents ([3cb0c0c](https://github.com/identification-resources/formica/commit/3cb0c0cd60fc5a4ba88add623fbadc3b90d8530c))
26
+ * **resources:** create index of CoL identifiers ([3792903](https://github.com/identification-resources/formica/commit/37929035374bc0c67ed2403e775e06a904bdbd4d))
27
+ * **resources:** support intergeneric hybrids ([0a6696c](https://github.com/identification-resources/formica/commit/0a6696c5fd0603a0366c80c2298b6544636ed0c6))
28
+
29
+
30
+
1
31
  ## [0.6.7](https://github.com/identification-resources/formica/compare/v0.6.6...v0.6.7) (2025-02-26)
2
32
 
3
33
 
@@ -52,26 +52,27 @@ var fs_1 = require("fs");
52
52
  var path = require("path");
53
53
  var index_1 = require("../index");
54
54
  var util_1 = require("./util");
55
- /* eslint-disable @typescript-eslint/no-explicit-any */
56
- function sortObject(object) {
55
+ function alphabeticSort(a, b) {
56
+ return a > b ? 1 : a < b ? -1 : 0;
57
+ }
58
+ function sortObject(object, sorter) {
57
59
  var sorted = {};
58
- for (var _i = 0, _a = Object.keys(object).sort(util_1.numericSort); _i < _a.length; _i++) {
60
+ for (var _i = 0, _a = Object.keys(object).sort(sorter !== null && sorter !== void 0 ? sorter : util_1.numericSort); _i < _a.length; _i++) {
59
61
  var key = _a[_i];
60
62
  sorted[key] = object[key];
61
63
  }
62
64
  return sorted;
63
65
  }
64
- /* eslint-enable @typescript-eslint/no-explicit-any */
65
- function addTaxon(gbifIndex, gbifId, taxon) {
66
- if (!(gbifId in gbifIndex)) {
67
- gbifIndex[gbifId] = [];
66
+ function addTaxon(index, id, taxon) {
67
+ if (!(id in index)) {
68
+ index[id] = [];
68
69
  }
69
- gbifIndex[gbifId].push(taxon[0]);
70
- gbifIndex[gbifId].sort(util_1.numericSort);
70
+ index[id].push(taxon[0]);
71
+ index[id].sort(util_1.numericSort);
71
72
  }
72
73
  function main(args) {
73
74
  return __awaiter(this, void 0, void 0, function () {
74
- var REPO_ROOT, files, gbifIndex, resourceIndex;
75
+ var REPO_ROOT, files, gbifIndex, colIndex, resourceIndex;
75
76
  return __generator(this, function (_a) {
76
77
  switch (_a.label) {
77
78
  case 0:
@@ -80,6 +81,7 @@ function main(args) {
80
81
  case 1:
81
82
  files = _a.sent();
82
83
  gbifIndex = {};
84
+ colIndex = {};
83
85
  resourceIndex = {};
84
86
  return [4 /*yield*/, Promise.all(files.map(function (fileName) {
85
87
  return __awaiter(this, void 0, void 0, function () {
@@ -96,7 +98,7 @@ function main(args) {
96
98
  file = _a.sent();
97
99
  return [2 /*return*/, Promise.all(index_1.resources.parseTextFileHeader(file).map(function (resource, index) {
98
100
  return __awaiter(this, void 0, void 0, function () {
99
- var amendedResource, dwcFile, _a, header, dwc, _b, _c, gbifColumn, gbifAcceptedColumn, _i, dwc_1, taxon, gbifId;
101
+ var amendedResource, dwcFile, _a, header, dwc, _b, _c, gbifColumn, gbifAcceptedColumn, colColumn, colAcceptedColumn, _i, dwc_1, taxon, gbifId, colId;
100
102
  return __generator(this, function (_d) {
101
103
  switch (_d.label) {
102
104
  case 0:
@@ -111,6 +113,8 @@ function main(args) {
111
113
  _a = _c.apply(_b, [_d.sent()]), header = _a[0], dwc = _a.slice(1);
112
114
  gbifColumn = header.indexOf('gbifTaxonID');
113
115
  gbifAcceptedColumn = header.indexOf('gbifAcceptedTaxonID');
116
+ colColumn = header.indexOf('colTaxonID');
117
+ colAcceptedColumn = header.indexOf('colAcceptedTaxonID');
114
118
  for (_i = 0, dwc_1 = dwc; _i < dwc_1.length; _i++) {
115
119
  taxon = dwc_1[_i];
116
120
  gbifId = taxon[gbifColumn];
@@ -120,6 +124,13 @@ function main(args) {
120
124
  addTaxon(gbifIndex, taxon[gbifAcceptedColumn], taxon);
121
125
  }
122
126
  }
127
+ colId = taxon[colColumn];
128
+ if (colId) {
129
+ addTaxon(colIndex, colId, taxon);
130
+ if (taxon[colAcceptedColumn] !== taxon[colColumn]) {
131
+ addTaxon(colIndex, taxon[colAcceptedColumn], taxon);
132
+ }
133
+ }
123
134
  amendedResource.taxonCount += 1;
124
135
  }
125
136
  resourceIndex[amendedResource.id] = amendedResource;
@@ -136,6 +147,7 @@ function main(args) {
136
147
  _a.sent();
137
148
  return [4 /*yield*/, Promise.all([
138
149
  fs_1.promises.writeFile(path.join(REPO_ROOT, 'gbif.index.json'), JSON.stringify(sortObject(gbifIndex), null, 2)),
150
+ fs_1.promises.writeFile(path.join(REPO_ROOT, 'col.index.json'), JSON.stringify(sortObject(colIndex, alphabeticSort), null, 2)),
139
151
  fs_1.promises.writeFile(path.join(REPO_ROOT, 'index.json'), JSON.stringify(sortObject(resourceIndex), null, 2))
140
152
  ])];
141
153
  case 3:
@@ -4,4 +4,4 @@ export declare enum ResourceDiffType {
4
4
  Modified = "~",
5
5
  Unchanged = "="
6
6
  }
7
- export declare function createDiff(a: string, b: string, tokenize?: ResourceDiffTokenizer): ResourceDiffPart[];
7
+ export declare function createDiff(a: string, b: string, tokenize?: ResourceDiffTokenizer): ResourceDiff;
@@ -68,6 +68,7 @@ function gitTokenize(text) {
68
68
  return text.match(/\S+|\n|[\r\t\f\v \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g);
69
69
  }
70
70
  function createDiff(a, b, tokenize) {
71
+ var _a, _b;
71
72
  if (tokenize === void 0) { tokenize = gitTokenize; }
72
73
  var X = tokenize(a.trimEnd());
73
74
  var Y = tokenize(b.trimEnd());
@@ -106,7 +107,7 @@ function createDiff(a, b, tokenize) {
106
107
  // Start of line
107
108
  if (line === null) {
108
109
  deletedNewlines = 0;
109
- line = { text: '', type: change.type };
110
+ line = { type: change.type };
110
111
  }
111
112
  // End of line (could be same token)
112
113
  if (change.text === '\n') {
@@ -132,7 +133,8 @@ function createDiff(a, b, tokenize) {
132
133
  // Add placeholders for deleted lines
133
134
  while (deletedNewlines--) {
134
135
  lines.push({
135
- text: '',
136
+ text: undefined,
137
+ original: '',
136
138
  type: ResourceDiffType.Deleted
137
139
  });
138
140
  }
@@ -144,7 +146,10 @@ function createDiff(a, b, tokenize) {
144
146
  line.type = ResourceDiffType.Modified;
145
147
  }
146
148
  if (change.type !== ResourceDiffType.Deleted) {
147
- line.text += change.text;
149
+ line.text = ((_a = line.text) !== null && _a !== void 0 ? _a : '') + change.text;
150
+ }
151
+ if (change.type !== ResourceDiffType.Added) {
152
+ line.original = ((_b = line.original) !== null && _b !== void 0 ? _b : '') + change.text;
148
153
  }
149
154
  }
150
155
  return lines;
@@ -74,13 +74,6 @@ var TAXONOMIC_STATUS = {
74
74
  '+': 'heterotypic synonym',
75
75
  '=': 'synonym'
76
76
  };
77
- var INDET_SUFFIXES = new Set([
78
- 'sp.',
79
- 'spec.',
80
- 'indet.',
81
- 'sp. indet.',
82
- 'spec. indet.'
83
- ]);
84
77
  var RANK_LABELS = {
85
78
  'subspecies': 'subsp.',
86
79
  'variety': 'var.',
@@ -98,6 +91,7 @@ var RANK_LABELS_REVERSE = {
98
91
  'ssp': 'subspecies',
99
92
  'subsp': 'subspecies'
100
93
  };
94
+ var HYBRID_SIGN = '\u00D7';
101
95
  /**
102
96
  * 1. Any number of
103
97
  * - capitalized words
@@ -133,22 +127,30 @@ var NAME_PATTERN = new RegExp('^' +
133
127
  /**
134
128
  * Structure
135
129
  * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
136
- * $1.1 genus: ([A-Z]\S+)
130
+ * $1.1 genus: ((?:x )?[A-Z]\S+)
137
131
  * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
138
- * $2 species: ((?:x )?[a-z][^\s.]+)
132
+ * $2 species: (x [a-z]+|[a-z][^\s.]+(?: x [a-z]+)?|[A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+)
133
+ * $2a: x [a-z]+
134
+ * $2b hybrid: [a-z][^\s.]+(?: x [a-z]+)?
135
+ * $2c intergeneric hybrid: [A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+
139
136
  */
140
- var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/;
137
+ var BINAME_PATTERN = /^(?:((?:x )?[A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?(x [a-z]+|[a-z][^\s.]+(?: x [a-z]+)?|[A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+)(?= |$)/;
141
138
  function compareRanks(a, b) {
142
139
  return RANKS.indexOf(a) - RANKS.indexOf(b);
143
140
  }
144
141
  function capitalize(name) {
145
142
  return name[0].toUpperCase() + name.slice(1).toLowerCase();
146
143
  }
144
+ function capitalizeGenericName(name) {
145
+ if (name[0] === HYBRID_SIGN) {
146
+ return HYBRID_SIGN + capitalize(name.slice(1));
147
+ }
148
+ return capitalize(name);
149
+ }
147
150
  function isUpperCase(name) {
148
151
  return name === name.toUpperCase();
149
152
  }
150
153
  function getSynonymRank(name, rank) {
151
- var BINAME_PATTERN = /^([A-Z]\S+ (\([A-Z]\S+\) )?)?(x )?[a-z0-9-]+(?= |$)/;
152
154
  var rest = name.replace(BINAME_PATTERN, '');
153
155
  var rankPrefix = rest.match(/^(?: |^)(st|r|ab|f|var|ssp|subsp)\. /);
154
156
  if (rankPrefix) {
@@ -188,7 +190,7 @@ function parseName(name, rank, parent) {
188
190
  name = name.replace(/^\[(_|\d+)\] /, '');
189
191
  }
190
192
  // Set verbatim identification after subsequent syntax is removed.
191
- item.verbatimIdentification = name;
193
+ item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ');
192
194
  // Parent context is used for parsing and formatting binomial names.
193
195
  // For formatting, it needs to match external databases (i.e. be correct).
194
196
  // For parsing, it needs to match the current file. If relevant parents
@@ -210,7 +212,8 @@ function parseName(name, rank, parent) {
210
212
  if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
211
213
  var _a = name.match(BINAME_PATTERN) || [], genus = _a[1], subgenus = _a[2], species = _a[3];
212
214
  if (genus) {
213
- parentContext.genus = parentContext.incorrect.genus = capitalize(genus);
215
+ parentContext.incorrect.genus = genus;
216
+ parentContext.genus = capitalizeGenericName(genus.replace(/(^| )x /, HYBRID_SIGN));
214
217
  }
215
218
  if (subgenus) {
216
219
  parentContext.subgenus = capitalize(subgenus);
@@ -220,8 +223,9 @@ function parseName(name, rank, parent) {
220
223
  // from the parent context.
221
224
  delete parentContext.subgenus;
222
225
  }
223
- if (species) {
224
- parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species;
226
+ if (species && compareRanks('species', rank) < 0) {
227
+ parentContext.incorrect.specificEpithet = species;
228
+ parentContext.specificEpithet = species.replace(/(^| )x /, HYBRID_SIGN);
225
229
  }
226
230
  }
227
231
  // In taxa of group, species or lower, the name should just contain the
@@ -247,8 +251,11 @@ function parseName(name, rank, parent) {
247
251
  }
248
252
  }
249
253
  // Hybrids
254
+ if (rank === 'genus' && name.startsWith('x ')) {
255
+ name = HYBRID_SIGN + name.slice(2);
256
+ }
250
257
  if (rank === 'species' && /(^| )x /.test(name)) {
251
- name = name.replace(/(^| )x /, '\u00D7');
258
+ name = name.replace(/(^| )x /, HYBRID_SIGN);
252
259
  }
253
260
  // Divide the name into the main scientific name (only the epithet for taxa
254
261
  // lower than genus), the authorship information, and optionally remarks
@@ -257,7 +264,9 @@ function parseName(name, rank, parent) {
257
264
  throw new Error("Taxon \"".concat(name, "\" could not be parsed"));
258
265
  }
259
266
  // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
260
- // underscores are used, which are replaced here.
267
+ // underscores are used, which are replaced here. This is also used for undescribed
268
+ // species (e.g. "Leiobunum species A") and intergeneric hybrids (e.g. "×Festulpia
269
+ // Festuca rubra × Vulpia bromoides")
261
270
  if (nameParts[1].includes('_')) {
262
271
  nameParts[1] = nameParts[1].replace(/_/g, ' ');
263
272
  }
@@ -265,13 +274,19 @@ function parseName(name, rank, parent) {
265
274
  item.scientificNameAuthorship = capitalizeAuthors(citation);
266
275
  item.taxonRemarks = notes;
267
276
  item.taxonRank = rank;
268
- if (/[^\p{L}0-9\u{00D7}\- ]/u.test(taxon) && !INDET_SUFFIXES.has(taxon)) {
277
+ if (/[^\p{L}0-9\u{00D7}\- ]/u.test(taxon)) {
269
278
  throw new Error("Taxon name contains unexpected characters: \"".concat(taxon, "\""));
270
279
  }
271
280
  // Validate names and recompose binomial and trinomial names
272
- if (compareRanks('group', rank) > 0) {
281
+ if (rank === 'genus') {
282
+ item.scientificName = capitalizeGenericName(taxon);
283
+ if (taxon[0].toUpperCase() !== taxon[0] || (taxon[0] === HYBRID_SIGN && taxon[1].toUpperCase() !== taxon[1])) {
284
+ throw new Error("Generic epithet should be capitalized: \"".concat(taxon, "\""));
285
+ }
286
+ }
287
+ else if (compareRanks('group', rank) > 0) {
273
288
  item.scientificName = capitalize(taxon);
274
- if (item.scientificName[0] !== taxon[0]) {
289
+ if (taxon[0].toUpperCase() !== taxon[0]) {
275
290
  throw new Error("Taxon name (".concat(rank, ") should be capitalized: \"").concat(taxon, "\""));
276
291
  }
277
292
  }
@@ -298,12 +313,12 @@ function parseName(name, rank, parent) {
298
313
  else if (rank === 'species') {
299
314
  item.genericName = parentContext.genus;
300
315
  item.infragenericEpithet = parentContext.subgenus;
301
- item.specificEpithet = taxon.toLowerCase();
302
- item.scientificName = "".concat(item.genericName, " ").concat(item.specificEpithet);
303
- if (item.specificEpithet !== taxon) {
316
+ if (taxon.toLowerCase() !== taxon && !/^[A-Z][a-z]+ [a-z]+\xD7[A-Z][a-z]+ [a-z]+$/.test(taxon)) {
304
317
  console.log(item, taxon);
305
318
  throw new Error("Specific epithet should be lowercase: \"".concat(taxon, "\""));
306
319
  }
320
+ item.specificEpithet = taxon;
321
+ item.scientificName = "".concat(item.genericName, " ").concat(item.specificEpithet);
307
322
  }
308
323
  else if (compareRanks('species', rank) < 0) {
309
324
  item.genericName = parentContext.genus;
@@ -435,6 +450,16 @@ function parseResource(resource) {
435
450
  var content = rest.join('');
436
451
  return [config, content];
437
452
  }
453
+ function getIndentation(line) {
454
+ return line.match(/^ */)[0].length;
455
+ }
456
+ function isIndetLine(line, indent) {
457
+ if (indent === undefined) {
458
+ indent = getIndentation(line);
459
+ }
460
+ line = line.slice(indent);
461
+ return line.startsWith('[indet]');
462
+ }
438
463
  function parseResourceContent(content, resource, oldIds) {
439
464
  var idBase = "".concat(resource.id, ":");
440
465
  var data = resource.taxa;
@@ -443,20 +468,19 @@ function parseResourceContent(content, resource, oldIds) {
443
468
  var groupIndent = 0;
444
469
  var previousId = '';
445
470
  var newIdOffset = Math.max.apply(Math, oldIds);
446
- var _loop_1 = function (line, type) {
447
- if (type === diff_resource_1.ResourceDiffType.Deleted) {
448
- id++;
449
- return "continue";
450
- }
451
- // Do not process "indet" lines further, as they only serve to indicate
452
- // that subtaxa are explicitely omitted
453
- if (Array.from(INDET_SUFFIXES).some(function (suffix) { return line.endsWith(' ' + suffix); })) {
454
- return "continue";
471
+ for (var _i = 0, content_1 = content; _i < content_1.length; _i++) {
472
+ var line = content_1[_i];
473
+ if (line.type === diff_resource_1.ResourceDiffType.Deleted) {
474
+ // Increase id counter for removed line unless it was an "indet line"
475
+ if (!isIndetLine(line.original)) {
476
+ id++;
477
+ }
478
+ continue;
455
479
  }
456
- var lineIndent = line.match(/^ */)[0].length;
480
+ var lineIndent = getIndentation(line.text);
457
481
  if (lineIndent > groupIndent) {
458
482
  // Do not count synonyms as parents (unless this is correcting a typo in the synonym)
459
- if (data[previousId] && data[previousId].taxonomicStatus === 'accepted' || /^( {2})+> /.test(line)) {
483
+ if (data[previousId] && data[previousId].taxonomicStatus === 'accepted' || /^( {2})+> /.test(line.text)) {
460
484
  parents.push(previousId);
461
485
  }
462
486
  else {
@@ -478,17 +502,26 @@ function parseResourceContent(content, resource, oldIds) {
478
502
  parents = parents.slice(0, lineIndent / 2);
479
503
  groupIndent = lineIndent;
480
504
  }
505
+ // Do not process "indet" lines further, as they only serve to indicate
506
+ // that subtaxa are explicitely omitted
507
+ if (isIndetLine(line.text, lineIndent)) {
508
+ // If the line was previously not and ndet line, increase the id counter
509
+ if (line.type === diff_resource_1.ResourceDiffType.Modified && !isIndetLine(line.original)) {
510
+ id++;
511
+ }
512
+ continue;
513
+ }
481
514
  var parentId = parents.reduce(function (grandparent, parent) { return parent || grandparent; }, null);
482
515
  var parent_1 = parentId === null ? {} : data[parentId];
483
- var name_1 = line.slice(groupIndent);
516
+ var name_1 = line.text.slice(groupIndent);
484
517
  var rank = resource.metadata.levels[groupIndent / 2];
485
518
  var item = parseName(name_1, rank, parent_1);
486
519
  var isSynonym = item.taxonomicStatus !== 'accepted';
487
520
  // Add higher classification info
488
521
  var itemAsObject = item;
489
522
  var parentAsObject = parent_1;
490
- for (var _b = 0, DWC_RANKS_1 = DWC_RANKS; _b < DWC_RANKS_1.length; _b++) {
491
- var rank_1 = DWC_RANKS_1[_b];
523
+ for (var _a = 0, DWC_RANKS_1 = DWC_RANKS; _a < DWC_RANKS_1.length; _a++) {
524
+ var rank_1 = DWC_RANKS_1[_a];
492
525
  itemAsObject[rank_1] = undefined;
493
526
  if (parentAsObject[rank_1]) {
494
527
  itemAsObject[rank_1] = parentAsObject[rank_1];
@@ -520,10 +553,14 @@ function parseResourceContent(content, resource, oldIds) {
520
553
  parentAsObject[key] = itemAsObject[key];
521
554
  }
522
555
  }
523
- return "continue";
556
+ continue;
524
557
  }
525
558
  // Set identifiers
526
- if (type === diff_resource_1.ResourceDiffType.Added) {
559
+ if (line.type === diff_resource_1.ResourceDiffType.Added) {
560
+ newIdOffset++;
561
+ item.scientificNameID = idBase + newIdOffset.toString();
562
+ }
563
+ else if (line.type === diff_resource_1.ResourceDiffType.Modified && isIndetLine(line.original)) {
527
564
  newIdOffset++;
528
565
  item.scientificNameID = idBase + newIdOffset.toString();
529
566
  }
@@ -538,10 +575,6 @@ function parseResourceContent(content, resource, oldIds) {
538
575
  item.acceptedNameUsage = isSynonym ? parent_1.scientificName : undefined;
539
576
  item.collectionCode = idBase.slice(0, -1);
540
577
  data[item.scientificNameID] = item;
541
- };
542
- for (var _i = 0, content_1 = content; _i < content_1.length; _i++) {
543
- var _a = content_1[_i], line = _a.text, type = _a.type;
544
- _loop_1(line, type);
545
578
  }
546
579
  return resource;
547
580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.6.7",
3
+ "version": "0.7.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",
@@ -11,22 +11,25 @@ interface AmendedResourceMetadata extends ResourceMetadata {
11
11
  taxonCount: number
12
12
  }
13
13
 
14
- /* eslint-disable @typescript-eslint/no-explicit-any */
15
- function sortObject (object: Record<string, any>): Record<string, any> {
16
- const sorted: Record<string, any> = {}
17
- for (const key of Object.keys(object).sort(numericSort)) {
14
+ type SortObjectCallback = (a: string, b: string) => number
15
+ function alphabeticSort (a: string, b: string): number {
16
+ return a > b ? 1 : a < b ? -1 : 0
17
+ }
18
+
19
+ function sortObject (object: Record<string, unknown>, sorter?: SortObjectCallback): Record<string, unknown> {
20
+ const sorted: Record<string, unknown> = {}
21
+ for (const key of Object.keys(object).sort(sorter ?? numericSort)) {
18
22
  sorted[key] = object[key]
19
23
  }
20
24
  return sorted
21
25
  }
22
- /* eslint-enable @typescript-eslint/no-explicit-any */
23
26
 
24
- function addTaxon (gbifIndex: Record<string, TaxonId[]>, gbifId: string, taxon: string[]) {
25
- if (!(gbifId in gbifIndex)) {
26
- gbifIndex[gbifId] = []
27
+ function addTaxon (index: Record<string, TaxonId[]>, id: string, taxon: string[]) {
28
+ if (!(id in index)) {
29
+ index[id] = []
27
30
  }
28
- gbifIndex[gbifId].push(taxon[0])
29
- gbifIndex[gbifId].sort(numericSort)
31
+ index[id].push(taxon[0])
32
+ index[id].sort(numericSort)
30
33
  }
31
34
 
32
35
  async function main (args: string[]): Promise<void> {
@@ -35,6 +38,7 @@ async function main (args: string[]): Promise<void> {
35
38
  const files = await fs.readdir(path.join(REPO_ROOT, 'txt'))
36
39
 
37
40
  const gbifIndex: Record<string, TaxonId[]> = {}
41
+ const colIndex: Record<string, TaxonId[]> = {}
38
42
  const resourceIndex: Record<TaxonId, AmendedResourceMetadata> = {}
39
43
 
40
44
  await Promise.all(files.map(async function (fileName) {
@@ -57,6 +61,8 @@ async function main (args: string[]): Promise<void> {
57
61
  const [header, ...dwc] = csv.parseCsv(await fs.readFile(dwcFile, 'utf-8'))
58
62
  const gbifColumn = header.indexOf('gbifTaxonID')
59
63
  const gbifAcceptedColumn = header.indexOf('gbifAcceptedTaxonID')
64
+ const colColumn = header.indexOf('colTaxonID')
65
+ const colAcceptedColumn = header.indexOf('colAcceptedTaxonID')
60
66
  for (const taxon of dwc) {
61
67
  const gbifId = taxon[gbifColumn]
62
68
  if (gbifId) {
@@ -65,6 +71,15 @@ async function main (args: string[]): Promise<void> {
65
71
  addTaxon(gbifIndex, taxon[gbifAcceptedColumn], taxon)
66
72
  }
67
73
  }
74
+
75
+ const colId = taxon[colColumn]
76
+ if (colId) {
77
+ addTaxon(colIndex, colId, taxon)
78
+ if (taxon[colAcceptedColumn] !== taxon[colColumn]) {
79
+ addTaxon(colIndex, taxon[colAcceptedColumn], taxon)
80
+ }
81
+ }
82
+
68
83
  amendedResource.taxonCount += 1
69
84
  }
70
85
 
@@ -74,6 +89,7 @@ async function main (args: string[]): Promise<void> {
74
89
 
75
90
  await Promise.all([
76
91
  fs.writeFile(path.join(REPO_ROOT, 'gbif.index.json'), JSON.stringify(sortObject(gbifIndex), null, 2)),
92
+ fs.writeFile(path.join(REPO_ROOT, 'col.index.json'), JSON.stringify(sortObject(colIndex, alphabeticSort), null, 2)),
77
93
  fs.writeFile(path.join(REPO_ROOT, 'index.json'), JSON.stringify(sortObject(resourceIndex), null, 2))
78
94
  ])
79
95
  }
package/src/module.d.ts CHANGED
@@ -95,7 +95,8 @@ interface ResourceHistory {
95
95
  type ResourceDiff = ResourceDiffPart[]
96
96
 
97
97
  interface ResourceDiffPart {
98
- text: string,
98
+ text?: string,
99
+ original?: string,
99
100
  type: ResourceDiffType
100
101
  }
101
102
 
@@ -5,7 +5,12 @@ export enum ResourceDiffType {
5
5
  Unchanged = '='
6
6
  }
7
7
 
8
- function LCS (X: string[], Y: string[]): ResourceDiffPart[] {
8
+ interface DiffPart {
9
+ text: string,
10
+ type: ResourceDiffType
11
+ }
12
+
13
+ function LCS (X: string[], Y: string[]): DiffPart[] {
9
14
  const m = X.length
10
15
  const n = Y.length
11
16
 
@@ -57,7 +62,7 @@ function gitTokenize (text: string): string[] {
57
62
  return text.match(/\S+|\n|[\r\t\f\v \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g) as string[]
58
63
  }
59
64
 
60
- export function createDiff (a: string, b: string, tokenize: ResourceDiffTokenizer = gitTokenize): ResourceDiffPart[] {
65
+ export function createDiff (a: string, b: string, tokenize: ResourceDiffTokenizer = gitTokenize): ResourceDiff {
61
66
  const X = tokenize(a.trimEnd())
62
67
  const Y = tokenize(b.trimEnd())
63
68
 
@@ -94,7 +99,7 @@ export function createDiff (a: string, b: string, tokenize: ResourceDiffTokenize
94
99
 
95
100
  // Convert word diff to line diff
96
101
  const lines: ResourceDiff = []
97
- let line = null
102
+ let line: ResourceDiffPart|null = null
98
103
  let deletedNewlines = 0
99
104
  let nextLineNew = false
100
105
 
@@ -102,7 +107,7 @@ export function createDiff (a: string, b: string, tokenize: ResourceDiffTokenize
102
107
  // Start of line
103
108
  if (line === null) {
104
109
  deletedNewlines = 0
105
- line = { text: '', type: change.type }
110
+ line = { type: change.type }
106
111
  }
107
112
 
108
113
  // End of line (could be same token)
@@ -132,7 +137,8 @@ export function createDiff (a: string, b: string, tokenize: ResourceDiffTokenize
132
137
  // Add placeholders for deleted lines
133
138
  while (deletedNewlines--) {
134
139
  lines.push({
135
- text: '',
140
+ text: undefined,
141
+ original: '',
136
142
  type: ResourceDiffType.Deleted
137
143
  })
138
144
  }
@@ -147,7 +153,11 @@ export function createDiff (a: string, b: string, tokenize: ResourceDiffTokenize
147
153
  }
148
154
 
149
155
  if (change.type !== ResourceDiffType.Deleted) {
150
- line.text += change.text
156
+ line.text = (line.text ?? '') + change.text
157
+ }
158
+
159
+ if (change.type !== ResourceDiffType.Added) {
160
+ line.original = (line.original ?? '') + change.text
151
161
  }
152
162
  }
153
163
 
@@ -66,14 +66,6 @@ const TAXONOMIC_STATUS: Record<string, TaxonStatus> = {
66
66
  '=': 'synonym'
67
67
  }
68
68
 
69
- const INDET_SUFFIXES = new Set([
70
- 'sp.',
71
- 'spec.',
72
- 'indet.',
73
- 'sp. indet.',
74
- 'spec. indet.'
75
- ])
76
-
77
69
  const RANK_LABELS: Record<Rank, string> = {
78
70
  'subspecies': 'subsp.',
79
71
  'variety': 'var.',
@@ -93,6 +85,8 @@ const RANK_LABELS_REVERSE: Record<string, Rank> = {
93
85
  'subsp': 'subspecies'
94
86
  }
95
87
 
88
+ const HYBRID_SIGN = '\u00D7'
89
+
96
90
  /**
97
91
  * 1. Any number of
98
92
  * - capitalized words
@@ -133,11 +127,14 @@ const NAME_PATTERN = new RegExp(
133
127
  /**
134
128
  * Structure
135
129
  * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
136
- * $1.1 genus: ([A-Z]\S+)
130
+ * $1.1 genus: ((?:x )?[A-Z]\S+)
137
131
  * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
138
- * $2 species: ((?:x )?[a-z][^\s.]+)
132
+ * $2 species: (x [a-z]+|[a-z][^\s.]+(?: x [a-z]+)?|[A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+)
133
+ * $2a: x [a-z]+
134
+ * $2b hybrid: [a-z][^\s.]+(?: x [a-z]+)?
135
+ * $2c intergeneric hybrid: [A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+
139
136
  */
140
- const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/
137
+ const BINAME_PATTERN = /^(?:((?:x )?[A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?(x [a-z]+|[a-z][^\s.]+(?: x [a-z]+)?|[A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+)(?= |$)/
141
138
 
142
139
  function compareRanks (a: Rank, b: Rank): number {
143
140
  return RANKS.indexOf(a) - RANKS.indexOf(b)
@@ -147,12 +144,19 @@ function capitalize (name: string): string {
147
144
  return name[0].toUpperCase() + name.slice(1).toLowerCase()
148
145
  }
149
146
 
147
+ function capitalizeGenericName (name: string): string {
148
+ if (name[0] === HYBRID_SIGN) {
149
+ return HYBRID_SIGN + capitalize(name.slice(1))
150
+ }
151
+
152
+ return capitalize(name)
153
+ }
154
+
150
155
  function isUpperCase (name: string): boolean {
151
156
  return name === name.toUpperCase()
152
157
  }
153
158
 
154
159
  function getSynonymRank (name: string, rank: Rank): Rank {
155
- const BINAME_PATTERN = /^([A-Z]\S+ (\([A-Z]\S+\) )?)?(x )?[a-z0-9-]+(?= |$)/
156
160
  const rest = name.replace(BINAME_PATTERN, '')
157
161
  const rankPrefix = rest.match(/^(?: |^)(st|r|ab|f|var|ssp|subsp)\. /)
158
162
  if (rankPrefix) {
@@ -194,7 +198,7 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
194
198
  }
195
199
 
196
200
  // Set verbatim identification after subsequent syntax is removed.
197
- item.verbatimIdentification = name
201
+ item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ')
198
202
 
199
203
  // Parent context is used for parsing and formatting binomial names.
200
204
  // For formatting, it needs to match external databases (i.e. be correct).
@@ -218,7 +222,8 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
218
222
  if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
219
223
  const [, genus, subgenus, species] = name.match(BINAME_PATTERN) || []
220
224
  if (genus) {
221
- parentContext.genus = parentContext.incorrect.genus = capitalize(genus)
225
+ parentContext.incorrect.genus = genus
226
+ parentContext.genus = capitalizeGenericName(genus.replace(/(^| )x /, HYBRID_SIGN))
222
227
  }
223
228
  if (subgenus) {
224
229
  parentContext.subgenus = capitalize(subgenus)
@@ -227,8 +232,9 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
227
232
  // from the parent context.
228
233
  delete parentContext.subgenus
229
234
  }
230
- if (species) {
231
- parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species
235
+ if (species && compareRanks('species', rank) < 0) {
236
+ parentContext.incorrect.specificEpithet = species
237
+ parentContext.specificEpithet = species.replace(/(^| )x /, HYBRID_SIGN)
232
238
  }
233
239
  }
234
240
 
@@ -259,8 +265,12 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
259
265
  }
260
266
 
261
267
  // Hybrids
268
+ if (rank === 'genus' && name.startsWith('x ')) {
269
+ name = HYBRID_SIGN + name.slice(2)
270
+ }
271
+
262
272
  if (rank === 'species' && /(^| )x /.test(name)) {
263
- name = name.replace(/(^| )x /, '\u00D7')
273
+ name = name.replace(/(^| )x /, HYBRID_SIGN)
264
274
  }
265
275
 
266
276
  // Divide the name into the main scientific name (only the epithet for taxa
@@ -271,7 +281,9 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
271
281
  }
272
282
 
273
283
  // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
274
- // underscores are used, which are replaced here.
284
+ // underscores are used, which are replaced here. This is also used for undescribed
285
+ // species (e.g. "Leiobunum species A") and intergeneric hybrids (e.g. "×Festulpia
286
+ // Festuca rubra × Vulpia bromoides")
275
287
  if (nameParts[1].includes('_')) {
276
288
  nameParts[1] = nameParts[1].replace(/_/g, ' ')
277
289
  }
@@ -281,14 +293,19 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
281
293
  item.taxonRemarks = notes
282
294
  item.taxonRank = rank
283
295
 
284
- if (/[^\p{L}0-9\u{00D7}\- ]/u.test(taxon) && !INDET_SUFFIXES.has(taxon)) {
296
+ if (/[^\p{L}0-9\u{00D7}\- ]/u.test(taxon)) {
285
297
  throw new Error(`Taxon name contains unexpected characters: "${taxon}"`)
286
298
  }
287
299
 
288
300
  // Validate names and recompose binomial and trinomial names
289
- if (compareRanks('group', rank) > 0) {
301
+ if (rank === 'genus') {
302
+ item.scientificName = capitalizeGenericName(taxon)
303
+ if (taxon[0].toUpperCase() !== taxon[0] || (taxon[0] === HYBRID_SIGN && taxon[1].toUpperCase() !== taxon[1])) {
304
+ throw new Error(`Generic epithet should be capitalized: "${taxon}"`)
305
+ }
306
+ } else if (compareRanks('group', rank) > 0) {
290
307
  item.scientificName = capitalize(taxon)
291
- if (item.scientificName[0] !== taxon[0]) {
308
+ if (taxon[0].toUpperCase() !== taxon[0]) {
292
309
  throw new Error(`Taxon name (${rank}) should be capitalized: "${taxon}"`)
293
310
  }
294
311
  } else if (rank === 'group') {
@@ -312,12 +329,12 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
312
329
  } else if (rank === 'species') {
313
330
  item.genericName = parentContext.genus
314
331
  item.infragenericEpithet = parentContext.subgenus
315
- item.specificEpithet = taxon.toLowerCase()
316
- item.scientificName = `${item.genericName} ${item.specificEpithet}`
317
- if (item.specificEpithet !== taxon) {
332
+ if (taxon.toLowerCase() !== taxon && !/^[A-Z][a-z]+ [a-z]+\xD7[A-Z][a-z]+ [a-z]+$/.test(taxon)) {
318
333
  console.log(item, taxon)
319
334
  throw new Error(`Specific epithet should be lowercase: "${taxon}"`)
320
335
  }
336
+ item.specificEpithet = taxon
337
+ item.scientificName = `${item.genericName} ${item.specificEpithet}`
321
338
  } else if (compareRanks('species', rank) < 0) {
322
339
  item.genericName = parentContext.genus
323
340
  item.infragenericEpithet = parentContext.subgenus
@@ -463,6 +480,20 @@ function parseResource (resource: string): [ResourceMetadata, string] {
463
480
  return [config, content]
464
481
  }
465
482
 
483
+ function getIndentation (line: string): number {
484
+ return (line.match(/^ */) as string[])[0].length
485
+ }
486
+
487
+ function isIndetLine (line: string, indent?: number): boolean {
488
+ if (indent === undefined) {
489
+ indent = getIndentation(line)
490
+ }
491
+
492
+ line = line.slice(indent)
493
+
494
+ return line.startsWith('[indet]')
495
+ }
496
+
466
497
  function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds: number[]): Resource {
467
498
  const idBase = `${resource.id}:`
468
499
 
@@ -473,23 +504,19 @@ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds
473
504
  let previousId = ''
474
505
  let newIdOffset = Math.max(...oldIds)
475
506
 
476
- for (const { text: line, type } of content) {
477
- if (type === ResourceDiffType.Deleted) {
478
- id++
479
- continue
480
- }
481
-
482
- // Do not process "indet" lines further, as they only serve to indicate
483
- // that subtaxa are explicitely omitted
484
- if (Array.from(INDET_SUFFIXES).some(suffix => line.endsWith(' ' + suffix))) {
507
+ for (const line of content) {
508
+ if (line.type === ResourceDiffType.Deleted) {
509
+ // Increase id counter for removed line unless it was an "indet line"
510
+ if (!isIndetLine(line.original as string)) {
511
+ id++
512
+ }
485
513
  continue
486
514
  }
487
515
 
488
- const lineIndent = (line.match(/^ */) as string[])[0].length
489
-
516
+ const lineIndent = getIndentation(line.text as string)
490
517
  if (lineIndent > groupIndent) {
491
518
  // Do not count synonyms as parents (unless this is correcting a typo in the synonym)
492
- if (data[previousId] && data[previousId].taxonomicStatus === 'accepted' || /^( {2})+> /.test(line)) {
519
+ if (data[previousId] && data[previousId].taxonomicStatus === 'accepted' || /^( {2})+> /.test(line.text as string)) {
493
520
  parents.push(previousId)
494
521
  } else {
495
522
  parents.push(null)
@@ -508,10 +535,19 @@ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds
508
535
  groupIndent = lineIndent
509
536
  }
510
537
 
538
+ // Do not process "indet" lines further, as they only serve to indicate
539
+ // that subtaxa are explicitely omitted
540
+ if (isIndetLine(line.text as string, lineIndent)) {
541
+ // If the line was previously not and ndet line, increase the id counter
542
+ if (line.type === ResourceDiffType.Modified && !isIndetLine(line.original as string)) {
543
+ id++
544
+ }
545
+ continue
546
+ }
547
+
511
548
  const parentId = parents.reduce((grandparent, parent) => parent || grandparent, null)
512
549
  const parent = parentId === null ? {} as WorkingTaxon : data[parentId]
513
-
514
- const name = line.slice(groupIndent)
550
+ const name = (line.text as string).slice(groupIndent)
515
551
  const rank = resource.metadata.levels[groupIndent / 2]
516
552
  const item = parseName(name, rank, parent)
517
553
  const isSynonym = item.taxonomicStatus !== 'accepted'
@@ -556,7 +592,10 @@ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds
556
592
  }
557
593
 
558
594
  // Set identifiers
559
- if (type === ResourceDiffType.Added) {
595
+ if (line.type === ResourceDiffType.Added) {
596
+ newIdOffset++
597
+ item.scientificNameID = idBase + newIdOffset.toString()
598
+ } else if (line.type === ResourceDiffType.Modified && isIndetLine(line.original as string)) {
560
599
  newIdOffset++
561
600
  item.scientificNameID = idBase + newIdOffset.toString()
562
601
  } else {
package/test/resources.js CHANGED
@@ -26,9 +26,9 @@ levels: [family, genus, species]
26
26
 
27
27
  Dolichurus haemorrhous A. Costa, 1886
28
28
  Dolichurus A. Costa, 1886
29
- Dolichurus indet.
29
+ [indet]
30
30
  Sphecidae A. Costa, 1886
31
- Sphecidae indet.
31
+ [indet]
32
32
  `, 'T1')
33
33
  assert.deepStrictEqual(Object.values(resource.taxa).map(taxon => taxon.scientificNameAuthorship), Array(3).fill('A. Costa, 1886'))
34
34
  })
@@ -86,7 +86,7 @@ levels: [genus, species]
86
86
  ---
87
87
 
88
88
  Drymus
89
- Unknown sp.
89
+ [indet]
90
90
  `, 'T1')
91
91
  assert.strictEqual(resource.taxa['T1:1:1'].scientificName, 'Drymus')
92
92
  })
@@ -149,4 +149,30 @@ Bogdania Kerzhner, 1964
149
149
  assert.strictEqual(resource.taxa['T1:1:1'].scientificName, 'Bogdiana Kerzhner, 1964')
150
150
  assert.strictEqual(resource.taxa['T1:1:2'].scientificName, 'Bogdiana myrmica Kerzhner, 1964')
151
151
  })
152
+
153
+ await t.test('parses cross-genus hybrids', (t) => {
154
+ const [resource] = resources.parseTextFile(`---
155
+ levels: [genus, species]
156
+ ---
157
+
158
+ x Triticosecale
159
+ [indet]
160
+ x Festulpia
161
+ Festuca_rubra x Vulpia_bromoides
162
+ `, 'T1')
163
+ assert.strictEqual(resource.taxa['T1:1:1'].scientificName, '×Triticosecale')
164
+ assert.strictEqual(resource.taxa['T1:1:2'].scientificName, '×Festulpia')
165
+ assert.strictEqual(resource.taxa['T1:1:3'].scientificName, '×Festulpia Festuca rubra×Vulpia bromoides')
166
+ })
167
+
168
+ await t.test('parses cross-genus hybrids without parent context', (t) => {
169
+ const [resource] = resources.parseTextFile(`---
170
+ levels: [species]
171
+ ---
172
+
173
+ x Festulpia Festuca_rubra x Vulpia_bromoides
174
+ `, 'T1')
175
+ assert.strictEqual(resource.taxa['T1:1:1'].scientificName, '×Festulpia Festuca rubra×Vulpia bromoides')
176
+ assert.strictEqual(resource.taxa['T1:1:1'].verbatimIdentification, '× Festulpia Festuca rubra × Vulpia bromoides')
177
+ })
152
178
  })