@larsgw/formica 0.6.7 → 0.6.8
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 +11 -0
- package/lib/bin/process-resources-index.js +23 -11
- package/lib/resources/parse-text.js +37 -15
- package/package.json +1 -1
- package/src/bin/process-resources-index.ts +26 -10
- package/src/resources/parse-text.ts +40 -15
- package/test/resources.js +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## [0.6.8](https://github.com/identification-resources/formica/compare/v0.6.7...v0.6.8) (2025-03-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **resources:** add support for intergeneric hybrids without parents ([3cb0c0c](https://github.com/identification-resources/formica/commit/3cb0c0cd60fc5a4ba88add623fbadc3b90d8530c))
|
|
7
|
+
* **resources:** create index of CoL identifiers ([3792903](https://github.com/identification-resources/formica/commit/37929035374bc0c67ed2403e775e06a904bdbd4d))
|
|
8
|
+
* **resources:** support intergeneric hybrids ([0a6696c](https://github.com/identification-resources/formica/commit/0a6696c5fd0603a0366c80c2298b6544636ed0c6))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
1
12
|
## [0.6.7](https://github.com/identification-resources/formica/compare/v0.6.6...v0.6.7) (2025-02-26)
|
|
2
13
|
|
|
3
14
|
|
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
gbifIndex[gbifId] = [];
|
|
66
|
+
function addTaxon(index, id, taxon) {
|
|
67
|
+
if (!(id in index)) {
|
|
68
|
+
index[id] = [];
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
-
|
|
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:
|
|
@@ -98,6 +98,7 @@ var RANK_LABELS_REVERSE = {
|
|
|
98
98
|
'ssp': 'subspecies',
|
|
99
99
|
'subsp': 'subspecies'
|
|
100
100
|
};
|
|
101
|
+
var HYBRID_SIGN = '\u00D7';
|
|
101
102
|
/**
|
|
102
103
|
* 1. Any number of
|
|
103
104
|
* - capitalized words
|
|
@@ -133,22 +134,30 @@ var NAME_PATTERN = new RegExp('^' +
|
|
|
133
134
|
/**
|
|
134
135
|
* Structure
|
|
135
136
|
* $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
|
|
136
|
-
* $1.1 genus: ([A-Z]\S+)
|
|
137
|
+
* $1.1 genus: ((?:x )?[A-Z]\S+)
|
|
137
138
|
* $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
|
|
138
|
-
* $2 species: (
|
|
139
|
+
* $2 species: (x [a-z]+|[a-z][^\s.]+(?: x [a-z]+)?|[A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+)
|
|
140
|
+
* $2a: x [a-z]+
|
|
141
|
+
* $2b hybrid: [a-z][^\s.]+(?: x [a-z]+)?
|
|
142
|
+
* $2c intergeneric hybrid: [A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+
|
|
139
143
|
*/
|
|
140
|
-
var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?(
|
|
144
|
+
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
145
|
function compareRanks(a, b) {
|
|
142
146
|
return RANKS.indexOf(a) - RANKS.indexOf(b);
|
|
143
147
|
}
|
|
144
148
|
function capitalize(name) {
|
|
145
149
|
return name[0].toUpperCase() + name.slice(1).toLowerCase();
|
|
146
150
|
}
|
|
151
|
+
function capitalizeGenericName(name) {
|
|
152
|
+
if (name[0] === HYBRID_SIGN) {
|
|
153
|
+
return HYBRID_SIGN + capitalize(name.slice(1));
|
|
154
|
+
}
|
|
155
|
+
return capitalize(name);
|
|
156
|
+
}
|
|
147
157
|
function isUpperCase(name) {
|
|
148
158
|
return name === name.toUpperCase();
|
|
149
159
|
}
|
|
150
160
|
function getSynonymRank(name, rank) {
|
|
151
|
-
var BINAME_PATTERN = /^([A-Z]\S+ (\([A-Z]\S+\) )?)?(x )?[a-z0-9-]+(?= |$)/;
|
|
152
161
|
var rest = name.replace(BINAME_PATTERN, '');
|
|
153
162
|
var rankPrefix = rest.match(/^(?: |^)(st|r|ab|f|var|ssp|subsp)\. /);
|
|
154
163
|
if (rankPrefix) {
|
|
@@ -188,7 +197,7 @@ function parseName(name, rank, parent) {
|
|
|
188
197
|
name = name.replace(/^\[(_|\d+)\] /, '');
|
|
189
198
|
}
|
|
190
199
|
// Set verbatim identification after subsequent syntax is removed.
|
|
191
|
-
item.verbatimIdentification = name;
|
|
200
|
+
item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ');
|
|
192
201
|
// Parent context is used for parsing and formatting binomial names.
|
|
193
202
|
// For formatting, it needs to match external databases (i.e. be correct).
|
|
194
203
|
// For parsing, it needs to match the current file. If relevant parents
|
|
@@ -210,7 +219,8 @@ function parseName(name, rank, parent) {
|
|
|
210
219
|
if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
|
|
211
220
|
var _a = name.match(BINAME_PATTERN) || [], genus = _a[1], subgenus = _a[2], species = _a[3];
|
|
212
221
|
if (genus) {
|
|
213
|
-
parentContext.
|
|
222
|
+
parentContext.incorrect.genus = genus;
|
|
223
|
+
parentContext.genus = capitalizeGenericName(genus.replace(/(^| )x /, HYBRID_SIGN));
|
|
214
224
|
}
|
|
215
225
|
if (subgenus) {
|
|
216
226
|
parentContext.subgenus = capitalize(subgenus);
|
|
@@ -220,8 +230,9 @@ function parseName(name, rank, parent) {
|
|
|
220
230
|
// from the parent context.
|
|
221
231
|
delete parentContext.subgenus;
|
|
222
232
|
}
|
|
223
|
-
if (species) {
|
|
224
|
-
parentContext.
|
|
233
|
+
if (species && compareRanks('species', rank) < 0) {
|
|
234
|
+
parentContext.incorrect.specificEpithet = species;
|
|
235
|
+
parentContext.specificEpithet = species.replace(/(^| )x /, HYBRID_SIGN);
|
|
225
236
|
}
|
|
226
237
|
}
|
|
227
238
|
// In taxa of group, species or lower, the name should just contain the
|
|
@@ -247,8 +258,11 @@ function parseName(name, rank, parent) {
|
|
|
247
258
|
}
|
|
248
259
|
}
|
|
249
260
|
// Hybrids
|
|
261
|
+
if (rank === 'genus' && name.startsWith('x ')) {
|
|
262
|
+
name = HYBRID_SIGN + name.slice(2);
|
|
263
|
+
}
|
|
250
264
|
if (rank === 'species' && /(^| )x /.test(name)) {
|
|
251
|
-
name = name.replace(/(^| )x /,
|
|
265
|
+
name = name.replace(/(^| )x /, HYBRID_SIGN);
|
|
252
266
|
}
|
|
253
267
|
// Divide the name into the main scientific name (only the epithet for taxa
|
|
254
268
|
// lower than genus), the authorship information, and optionally remarks
|
|
@@ -257,7 +271,9 @@ function parseName(name, rank, parent) {
|
|
|
257
271
|
throw new Error("Taxon \"".concat(name, "\" could not be parsed"));
|
|
258
272
|
}
|
|
259
273
|
// To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
|
|
260
|
-
// underscores are used, which are replaced here.
|
|
274
|
+
// underscores are used, which are replaced here. This is also used for undescribed
|
|
275
|
+
// species (e.g. "Leiobunum species A") and intergeneric hybrids (e.g. "×Festulpia
|
|
276
|
+
// Festuca rubra × Vulpia bromoides")
|
|
261
277
|
if (nameParts[1].includes('_')) {
|
|
262
278
|
nameParts[1] = nameParts[1].replace(/_/g, ' ');
|
|
263
279
|
}
|
|
@@ -269,9 +285,15 @@ function parseName(name, rank, parent) {
|
|
|
269
285
|
throw new Error("Taxon name contains unexpected characters: \"".concat(taxon, "\""));
|
|
270
286
|
}
|
|
271
287
|
// Validate names and recompose binomial and trinomial names
|
|
272
|
-
if (
|
|
288
|
+
if (rank === 'genus') {
|
|
289
|
+
item.scientificName = capitalizeGenericName(taxon);
|
|
290
|
+
if (taxon[0].toUpperCase() !== taxon[0] || (taxon[0] === HYBRID_SIGN && taxon[1].toUpperCase() !== taxon[1])) {
|
|
291
|
+
throw new Error("Generic epithet should be capitalized: \"".concat(taxon, "\""));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else if (compareRanks('group', rank) > 0) {
|
|
273
295
|
item.scientificName = capitalize(taxon);
|
|
274
|
-
if (
|
|
296
|
+
if (taxon[0].toUpperCase() !== taxon[0]) {
|
|
275
297
|
throw new Error("Taxon name (".concat(rank, ") should be capitalized: \"").concat(taxon, "\""));
|
|
276
298
|
}
|
|
277
299
|
}
|
|
@@ -298,12 +320,12 @@ function parseName(name, rank, parent) {
|
|
|
298
320
|
else if (rank === 'species') {
|
|
299
321
|
item.genericName = parentContext.genus;
|
|
300
322
|
item.infragenericEpithet = parentContext.subgenus;
|
|
301
|
-
|
|
302
|
-
item.scientificName = "".concat(item.genericName, " ").concat(item.specificEpithet);
|
|
303
|
-
if (item.specificEpithet !== taxon) {
|
|
323
|
+
if (taxon.toLowerCase() !== taxon && !/^[A-Z][a-z]+ [a-z]+\xD7[A-Z][a-z]+ [a-z]+$/.test(taxon)) {
|
|
304
324
|
console.log(item, taxon);
|
|
305
325
|
throw new Error("Specific epithet should be lowercase: \"".concat(taxon, "\""));
|
|
306
326
|
}
|
|
327
|
+
item.specificEpithet = taxon;
|
|
328
|
+
item.scientificName = "".concat(item.genericName, " ").concat(item.specificEpithet);
|
|
307
329
|
}
|
|
308
330
|
else if (compareRanks('species', rank) < 0) {
|
|
309
331
|
item.genericName = parentContext.genus;
|
package/package.json
CHANGED
|
@@ -11,22 +11,25 @@ interface AmendedResourceMetadata extends ResourceMetadata {
|
|
|
11
11
|
taxonCount: number
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
25
|
-
if (!(
|
|
26
|
-
|
|
27
|
+
function addTaxon (index: Record<string, TaxonId[]>, id: string, taxon: string[]) {
|
|
28
|
+
if (!(id in index)) {
|
|
29
|
+
index[id] = []
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
|
@@ -93,6 +93,8 @@ const RANK_LABELS_REVERSE: Record<string, Rank> = {
|
|
|
93
93
|
'subsp': 'subspecies'
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const HYBRID_SIGN = '\u00D7'
|
|
97
|
+
|
|
96
98
|
/**
|
|
97
99
|
* 1. Any number of
|
|
98
100
|
* - capitalized words
|
|
@@ -133,11 +135,14 @@ const NAME_PATTERN = new RegExp(
|
|
|
133
135
|
/**
|
|
134
136
|
* Structure
|
|
135
137
|
* $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
|
|
136
|
-
* $1.1 genus: ([A-Z]\S+)
|
|
138
|
+
* $1.1 genus: ((?:x )?[A-Z]\S+)
|
|
137
139
|
* $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
|
|
138
|
-
* $2 species: (
|
|
140
|
+
* $2 species: (x [a-z]+|[a-z][^\s.]+(?: x [a-z]+)?|[A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+)
|
|
141
|
+
* $2a: x [a-z]+
|
|
142
|
+
* $2b hybrid: [a-z][^\s.]+(?: x [a-z]+)?
|
|
143
|
+
* $2c intergeneric hybrid: [A-Z][a-z]+_[a-z]+ x [A-Z][a-z]+_[a-z]+
|
|
139
144
|
*/
|
|
140
|
-
const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?(
|
|
145
|
+
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
146
|
|
|
142
147
|
function compareRanks (a: Rank, b: Rank): number {
|
|
143
148
|
return RANKS.indexOf(a) - RANKS.indexOf(b)
|
|
@@ -147,12 +152,19 @@ function capitalize (name: string): string {
|
|
|
147
152
|
return name[0].toUpperCase() + name.slice(1).toLowerCase()
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
function capitalizeGenericName (name: string): string {
|
|
156
|
+
if (name[0] === HYBRID_SIGN) {
|
|
157
|
+
return HYBRID_SIGN + capitalize(name.slice(1))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return capitalize(name)
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
function isUpperCase (name: string): boolean {
|
|
151
164
|
return name === name.toUpperCase()
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
function getSynonymRank (name: string, rank: Rank): Rank {
|
|
155
|
-
const BINAME_PATTERN = /^([A-Z]\S+ (\([A-Z]\S+\) )?)?(x )?[a-z0-9-]+(?= |$)/
|
|
156
168
|
const rest = name.replace(BINAME_PATTERN, '')
|
|
157
169
|
const rankPrefix = rest.match(/^(?: |^)(st|r|ab|f|var|ssp|subsp)\. /)
|
|
158
170
|
if (rankPrefix) {
|
|
@@ -194,7 +206,7 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
// Set verbatim identification after subsequent syntax is removed.
|
|
197
|
-
item.verbatimIdentification = name
|
|
209
|
+
item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ')
|
|
198
210
|
|
|
199
211
|
// Parent context is used for parsing and formatting binomial names.
|
|
200
212
|
// For formatting, it needs to match external databases (i.e. be correct).
|
|
@@ -218,7 +230,8 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
218
230
|
if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
|
|
219
231
|
const [, genus, subgenus, species] = name.match(BINAME_PATTERN) || []
|
|
220
232
|
if (genus) {
|
|
221
|
-
parentContext.
|
|
233
|
+
parentContext.incorrect.genus = genus
|
|
234
|
+
parentContext.genus = capitalizeGenericName(genus.replace(/(^| )x /, HYBRID_SIGN))
|
|
222
235
|
}
|
|
223
236
|
if (subgenus) {
|
|
224
237
|
parentContext.subgenus = capitalize(subgenus)
|
|
@@ -227,8 +240,9 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
227
240
|
// from the parent context.
|
|
228
241
|
delete parentContext.subgenus
|
|
229
242
|
}
|
|
230
|
-
if (species) {
|
|
231
|
-
parentContext.
|
|
243
|
+
if (species && compareRanks('species', rank) < 0) {
|
|
244
|
+
parentContext.incorrect.specificEpithet = species
|
|
245
|
+
parentContext.specificEpithet = species.replace(/(^| )x /, HYBRID_SIGN)
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
248
|
|
|
@@ -259,8 +273,12 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
259
273
|
}
|
|
260
274
|
|
|
261
275
|
// Hybrids
|
|
276
|
+
if (rank === 'genus' && name.startsWith('x ')) {
|
|
277
|
+
name = HYBRID_SIGN + name.slice(2)
|
|
278
|
+
}
|
|
279
|
+
|
|
262
280
|
if (rank === 'species' && /(^| )x /.test(name)) {
|
|
263
|
-
name = name.replace(/(^| )x /,
|
|
281
|
+
name = name.replace(/(^| )x /, HYBRID_SIGN)
|
|
264
282
|
}
|
|
265
283
|
|
|
266
284
|
// Divide the name into the main scientific name (only the epithet for taxa
|
|
@@ -271,7 +289,9 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
271
289
|
}
|
|
272
290
|
|
|
273
291
|
// To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
|
|
274
|
-
// underscores are used, which are replaced here.
|
|
292
|
+
// underscores are used, which are replaced here. This is also used for undescribed
|
|
293
|
+
// species (e.g. "Leiobunum species A") and intergeneric hybrids (e.g. "×Festulpia
|
|
294
|
+
// Festuca rubra × Vulpia bromoides")
|
|
275
295
|
if (nameParts[1].includes('_')) {
|
|
276
296
|
nameParts[1] = nameParts[1].replace(/_/g, ' ')
|
|
277
297
|
}
|
|
@@ -286,9 +306,14 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
// Validate names and recompose binomial and trinomial names
|
|
289
|
-
if (
|
|
309
|
+
if (rank === 'genus') {
|
|
310
|
+
item.scientificName = capitalizeGenericName(taxon)
|
|
311
|
+
if (taxon[0].toUpperCase() !== taxon[0] || (taxon[0] === HYBRID_SIGN && taxon[1].toUpperCase() !== taxon[1])) {
|
|
312
|
+
throw new Error(`Generic epithet should be capitalized: "${taxon}"`)
|
|
313
|
+
}
|
|
314
|
+
} else if (compareRanks('group', rank) > 0) {
|
|
290
315
|
item.scientificName = capitalize(taxon)
|
|
291
|
-
if (
|
|
316
|
+
if (taxon[0].toUpperCase() !== taxon[0]) {
|
|
292
317
|
throw new Error(`Taxon name (${rank}) should be capitalized: "${taxon}"`)
|
|
293
318
|
}
|
|
294
319
|
} else if (rank === 'group') {
|
|
@@ -312,12 +337,12 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
312
337
|
} else if (rank === 'species') {
|
|
313
338
|
item.genericName = parentContext.genus
|
|
314
339
|
item.infragenericEpithet = parentContext.subgenus
|
|
315
|
-
|
|
316
|
-
item.scientificName = `${item.genericName} ${item.specificEpithet}`
|
|
317
|
-
if (item.specificEpithet !== taxon) {
|
|
340
|
+
if (taxon.toLowerCase() !== taxon && !/^[A-Z][a-z]+ [a-z]+\xD7[A-Z][a-z]+ [a-z]+$/.test(taxon)) {
|
|
318
341
|
console.log(item, taxon)
|
|
319
342
|
throw new Error(`Specific epithet should be lowercase: "${taxon}"`)
|
|
320
343
|
}
|
|
344
|
+
item.specificEpithet = taxon
|
|
345
|
+
item.scientificName = `${item.genericName} ${item.specificEpithet}`
|
|
321
346
|
} else if (compareRanks('species', rank) < 0) {
|
|
322
347
|
item.genericName = parentContext.genus
|
|
323
348
|
item.infragenericEpithet = parentContext.subgenus
|
package/test/resources.js
CHANGED
|
@@ -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
|
+
Triticosecale 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
|
})
|