@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 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
- /* 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:
@@ -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: ((?:x )?[a-z][^\s.]+)
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+?)\) )?)?((?:x )?[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.genus = parentContext.incorrect.genus = capitalize(genus);
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.specificEpithet = parentContext.incorrect.specificEpithet = species;
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 /, '\u00D7');
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 (compareRanks('group', rank) > 0) {
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 (item.scientificName[0] !== taxon[0]) {
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
- item.specificEpithet = taxon.toLowerCase();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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
  }
@@ -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: ((?:x )?[a-z][^\s.]+)
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+?)\) )?)?((?:x )?[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.genus = parentContext.incorrect.genus = capitalize(genus)
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.specificEpithet = parentContext.incorrect.specificEpithet = species
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 /, '\u00D7')
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 (compareRanks('group', rank) > 0) {
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 (item.scientificName[0] !== taxon[0]) {
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
- item.specificEpithet = taxon.toLowerCase()
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
  })