@larsgw/formica 0.8.4 → 0.8.6

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.
@@ -1,30 +1,12 @@
1
1
  "use strict";
2
- var __extends = (this && this.__extends) || (function () {
3
- var extendStatics = function (d, b) {
4
- extendStatics = Object.setPrototypeOf ||
5
- ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
6
- function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
7
- return extendStatics(d, b);
8
- };
9
- return function (d, b) {
10
- if (typeof b !== "function" && b !== null)
11
- throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
12
- extendStatics(d, b);
13
- function __() { this.constructor = d; }
14
- d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
15
- };
16
- })();
17
2
  Object.defineProperty(exports, "__esModule", { value: true });
18
3
  exports.WorkResource = void 0;
19
- var work_1 = require("../catalog/tables/work");
20
- var WorkResource = /** @class */ (function (_super) {
21
- __extends(WorkResource, _super);
22
- function WorkResource(values) {
23
- var _this = _super.call(this, values) || this;
24
- _this.schema.version_of.format = /^B[1-9]\d*:[1-9]\d*$/;
25
- _this.schema.duplicate_of.format = /^B[1-9]\d*:[1-9]\d*$/;
26
- return _this;
4
+ const work_1 = require("../catalog/tables/work");
5
+ class WorkResource extends work_1.Work {
6
+ constructor(values) {
7
+ super(values);
8
+ this.schema.version_of.format = /^B[1-9]\d*:[1-9]\d*$/;
9
+ this.schema.duplicate_of.format = /^B[1-9]\d*:[1-9]\d*$/;
27
10
  }
28
- return WorkResource;
29
- }(work_1.Work));
11
+ }
30
12
  exports.WorkResource = WorkResource;
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.groupNameMatches = groupNameMatches;
4
4
  exports.amendResource = amendResource;
5
- var MINIMUM_PREFIX_LENGTH = 3;
6
- var VALID_COMMON_PREFIXES = new Set([
5
+ const MINIMUM_PREFIX_LENGTH = 3;
6
+ const VALID_COMMON_PREFIXES = new Set([
7
7
  'Plantae|Tracheophyta',
8
8
  'Fungi',
9
9
  'Fungi|Ascomycota',
@@ -11,7 +11,7 @@ var VALID_COMMON_PREFIXES = new Set([
11
11
  'Fungi|Zygomycota'
12
12
  ]);
13
13
  function getCommonPrefix(a, b) {
14
- for (var i = 0; i < Math.max(a.length, b.length); i++) {
14
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
15
15
  if (a[i] !== b[i]) {
16
16
  return a.slice(0, i);
17
17
  }
@@ -19,17 +19,17 @@ function getCommonPrefix(a, b) {
19
19
  return a.slice();
20
20
  }
21
21
  function isValidPrefix(a, b) {
22
- var prefix = getCommonPrefix(a, b);
22
+ const prefix = getCommonPrefix(a, b);
23
23
  return VALID_COMMON_PREFIXES.has(prefix.join('|')) || prefix.length >= MINIMUM_PREFIX_LENGTH;
24
24
  }
25
25
  function groupNameMatches(results) {
26
- var prefixes = {};
27
- for (var scientificNameID in results) {
28
- var _loop_1 = function (result) {
26
+ const prefixes = {};
27
+ for (const scientificNameID in results) {
28
+ for (const result of results[scientificNameID]) {
29
29
  if (!prefixes[result.source]) {
30
30
  prefixes[result.source] = [];
31
31
  }
32
- var prefix = prefixes[result.source].find(function (prefix) { return isValidPrefix(prefix[0], result.classificationPath); });
32
+ let prefix = prefixes[result.source].find(prefix => isValidPrefix(prefix[0], result.classificationPath));
33
33
  if (!prefix) {
34
34
  prefix = [result.classificationPath, {}];
35
35
  prefixes[result.source].push(prefix);
@@ -38,21 +38,16 @@ function groupNameMatches(results) {
38
38
  prefix[0] = getCommonPrefix(prefix[0], result.classificationPath);
39
39
  }
40
40
  if (scientificNameID in prefix[1]) {
41
- return "continue";
41
+ continue;
42
42
  }
43
43
  prefix[1][scientificNameID] = result;
44
- };
45
- for (var _i = 0, _a = results[scientificNameID]; _i < _a.length; _i++) {
46
- var result = _a[_i];
47
- _loop_1(result);
48
44
  }
49
45
  }
50
- var groupedNameMatches = {};
51
- for (var source in prefixes) {
46
+ const groupedNameMatches = {};
47
+ for (const source in prefixes) {
52
48
  groupedNameMatches[source] = prefixes[source]
53
- .sort(function (a, b) { return Object.keys(b[1]).length - Object.keys(a[1]).length; })
54
- .reduce(function (map, _a) {
55
- var prefix = _a[0], taxa = _a[1];
49
+ .sort((a, b) => Object.keys(b[1]).length - Object.keys(a[1]).length)
50
+ .reduce((map, [prefix, taxa]) => {
56
51
  map[prefix.join('|')] = taxa;
57
52
  return map;
58
53
  }, {});
@@ -60,8 +55,8 @@ function groupNameMatches(results) {
60
55
  return groupedNameMatches;
61
56
  }
62
57
  function amendResource(resource, source, matches) {
63
- for (var id in matches) {
64
- var match = matches[id];
58
+ for (const id in matches) {
59
+ const match = matches[id];
65
60
  if (source === '1') {
66
61
  resource.taxa[id].colTaxonID = match.id;
67
62
  if (match.currentId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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",
@@ -9,6 +9,7 @@
9
9
  "loir-validate-resources": "./lib/bin/validate-resources-text.js",
10
10
  "loir-resources-process": "./lib/bin/process-resources.js",
11
11
  "loir-resources-index": "./lib/bin/process-resources-index.js",
12
+ "loir-generate-linked-data": "./lib/bin/generate-linked-data.js",
12
13
  "loir-catalog-clean-links": "./lib/bin/clean-links.js"
13
14
  },
14
15
  "scripts": {
@@ -58,6 +58,7 @@ const SCOPES: Record<string, [string, string]> = {
58
58
  // sex
59
59
  'females': ['dwciri:sex', 'http://rs.gbif.org/vocabulary/gbif/sex/female'],
60
60
  'males': ['dwciri:sex', 'http://rs.gbif.org/vocabulary/gbif/sex/male'],
61
+ 'male': ['dwciri:sex', 'http://rs.gbif.org/vocabulary/gbif/sex/male'],
61
62
 
62
63
  // caste
63
64
  'queens': ['dwc:caste', 'queen'],
@@ -231,7 +232,7 @@ function makeLinkedDataForTaxon (taxon: catalog.Entity): NodeObject {
231
232
  const node: NodeObject = {
232
233
  '@id': `${PREFIX}taxon/${taxon.get('id')}`,
233
234
  '@type': 'dwc:Taxon',
234
- 'dwc:scientificName': taxon.get('display_name'),
235
+ 'dwc:scientificName': taxon.get('name'),
235
236
  }
236
237
 
237
238
  if (taxon.has('rank')) {
@@ -362,12 +363,14 @@ function makeLinkedDataForResource (work: catalog.Entity, files: Catalog, resour
362
363
 
363
364
  const types = resource.get('key_type') ?? work.get('key_type') ?? []
364
365
 
365
- if (types.includes('matrix')) {
366
+ if (types.includes('matrix') || types.includes('algorithm')) {
366
367
  node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Software' }
367
368
  } else if (types.includes('key') || types.includes('reference') || types.includes('supplement')) {
368
369
  node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Text' }
369
370
  } else if (types.includes('gallery') || types.includes('collection')) {
370
371
  node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Collection' }
372
+ } else if (types.includes('checklist')) {
373
+ node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Dataset' }
371
374
  }
372
375
 
373
376
  if (types.includes('key') || types.includes('matrix')) {
@@ -660,10 +663,10 @@ function makeLinkedDataForWorks (files: Catalog): NodeObject[] {
660
663
  }
661
664
  (node['dcterms:hasPart'] as NodeObject[]).push(resource)
662
665
 
663
- if (!Array.isArray(resource['dcterms:isPartof'])) {
664
- resource['dcterms:isPartof'] = []
666
+ if (!Array.isArray(resource['dcterms:isPartOf'])) {
667
+ resource['dcterms:isPartOf'] = []
665
668
  }
666
- (resource['dcterms:isPartof'] as NodeObject[]).push({ '@id': node['@id'] })
669
+ (resource['dcterms:isPartOf'] as NodeObject[]).push({ '@id': node['@id'] })
667
670
  }
668
671
  }
669
672
 
@@ -232,7 +232,7 @@ class ResourceProcessor {
232
232
  const { resources } = await import('../index')
233
233
  return resources.parseTextFile(file, id, old)
234
234
  } catch (error) {
235
- console.log(error)
235
+ console.log(error.message)
236
236
  await prompt(`${id}: generating Darwin Core failed, retry? `)
237
237
 
238
238
  // Clear cache to re-import
@@ -14,7 +14,7 @@ async function main (args: string[]): Promise<void> {
14
14
  try {
15
15
  resources.parseTextFile(file, id)
16
16
  } catch (error) {
17
- console.error(filePath + '\n ' + error.message + '\n')
17
+ console.error(filePath + '\n' + error.message.replace(/^/gm, ' ') + '\n')
18
18
  exitStatus = 1
19
19
  }
20
20
  }
package/src/module.d.ts CHANGED
@@ -27,7 +27,7 @@ type TaxonId = string
27
27
  type ResourceId = string
28
28
  type WorkId = string
29
29
 
30
- interface WorkingTaxon {
30
+ interface TaxonBase {
31
31
  scientificNameID?: TaxonId,
32
32
  scientificName?: string,
33
33
  scientificNameAuthorship?: string,
@@ -56,13 +56,15 @@ interface WorkingTaxon {
56
56
  subgenus?: string,
57
57
  higherClassification?: string,
58
58
  verbatimIdentification?: string,
59
+ }
59
60
 
61
+ interface WorkingTaxon extends TaxonBase {
60
62
  // Non-standard
61
63
  scientificNameOnly?: string,
62
64
  incorrect?: WorkingTaxon
63
65
  }
64
66
 
65
- interface Taxon extends WorkingTaxon {
67
+ interface Taxon extends TaxonBase {
66
68
  scientificNameID: TaxonId,
67
69
  scientificName: string,
68
70
  taxonRank: Rank,
@@ -189,34 +189,38 @@ function getWordTokensFromLines (lines: ResourceDiff): string[] {
189
189
  return lines.flatMap(change => tokenizeWords(change.text as string).concat('\n'))
190
190
  }
191
191
 
192
+ type MultilineDiffPart = { added: ResourceDiff, deleted: ResourceDiff }
193
+
194
+ function mergeDiffPart (diffPart: MultilineDiffPart): ResourceDiff {
195
+ if (diffPart.added.length && diffPart.deleted.length) {
196
+ return convertWordDiff(diffTokens(getWordTokensFromLines(diffPart.added), getWordTokensFromLines(diffPart.deleted)))
197
+ } else if (diffPart.added.length) {
198
+ return diffPart.added
199
+ } else if (diffPart.deleted.length) {
200
+ return diffPart.deleted.map(change => ({ text: undefined, original: change.text, type: change.type }))
201
+ } else {
202
+ return []
203
+ }
204
+ }
205
+
192
206
  export function createDiff (a: string, b: string): ResourceDiff {
193
207
  const lines: ResourceDiff = diffTokens(tokenizeLines(a.trimEnd()), tokenizeLines(b.trimEnd()))
194
208
 
195
209
  const changes: ResourceDiff = []
196
- const diffPart: Record<string, ResourceDiff> = { added: [], deleted: [] }
210
+ const diffPart: MultilineDiffPart = { added: [], deleted: [] }
197
211
  for (let i = 0; i < lines.length; i++) {
198
212
  if (lines[i].type === ResourceDiffType.Added) {
199
213
  diffPart.added.push(lines[i])
200
- continue
201
214
  } else if (lines[i].type === ResourceDiffType.Deleted) {
202
215
  diffPart.deleted.push(lines[i])
203
- continue
204
- }
205
-
206
- if (diffPart.added.length && diffPart.deleted.length) {
207
- changes.push(...convertWordDiff(diffTokens(getWordTokensFromLines(diffPart.added), getWordTokensFromLines(diffPart.deleted))))
208
- diffPart.added.length = 0
209
- diffPart.deleted.length = 0
210
- } else if (diffPart.added.length) {
211
- changes.push(...diffPart.added)
216
+ } else {
217
+ changes.push(...mergeDiffPart(diffPart), lines[i])
212
218
  diffPart.added.length = 0
213
- } else if (diffPart.deleted.length) {
214
- changes.push(...diffPart.deleted.map(change => ({ text: undefined, original: change.text, type: change.type })))
215
219
  diffPart.deleted.length = 0
216
220
  }
217
-
218
- changes.push(lines[i])
219
221
  }
220
222
 
223
+ changes.push(...mergeDiffPart(diffPart))
224
+
221
225
  return changes
222
226
  }
@@ -0,0 +1,379 @@
1
+ export class RecoverableSyntaxError<Result> extends SyntaxError {
2
+ result: Result
3
+
4
+ constructor (message: string, result: Result) {
5
+ super(message)
6
+ this.result = result
7
+ }
8
+ }
9
+
10
+ export const RANKS: Rank[] = [
11
+ 'phylum',
12
+ 'subphylum',
13
+ 'class',
14
+ 'infraclass',
15
+ 'superorder',
16
+ 'order',
17
+ 'suborder',
18
+ 'infraorder',
19
+ 'superfamily',
20
+ 'family',
21
+ 'subfamily',
22
+ 'tribe',
23
+ 'subtribe',
24
+ 'genus',
25
+ 'subgenus',
26
+ 'section', // not ICZN
27
+ 'subsection', // not ICZN
28
+ 'series', // not ICZN
29
+ 'group',
30
+ 'subgroup', // ...
31
+ 'aggregate', // not ICZN
32
+ 'complex', // not ICZN
33
+ 'species',
34
+ 'subspecies',
35
+ 'variety',
36
+ 'form',
37
+ 'aberration', // not ICZN
38
+ 'race', // not ICZN
39
+ 'stirps' // not ICZN
40
+ ]
41
+
42
+ const TAXONOMIC_STATUS: Record<string, TaxonStatus> = {
43
+ '>': 'incorrect',
44
+ '+': 'heterotypic synonym',
45
+ '=': 'synonym'
46
+ }
47
+
48
+ const RANK_LABELS: Record<Rank, string> = {
49
+ 'subspecies': 'subsp.',
50
+ 'variety': 'var.',
51
+ 'form': 'f.',
52
+ 'aberration': 'ab.',
53
+ 'race': 'r.',
54
+ 'stirps': 'st.'
55
+ }
56
+
57
+ const RANK_LABELS_REVERSE: Record<string, Rank> = {
58
+ 'st': 'stirps',
59
+ 'r': 'race',
60
+ 'ab': 'aberration',
61
+ 'f': 'form',
62
+ 'var': 'variety',
63
+ 'ssp': 'subspecies',
64
+ 'subsp': 'subspecies'
65
+ }
66
+
67
+ const HYBRID_SIGN = '\u00D7'
68
+
69
+ /**
70
+ * 1. Any number of
71
+ * - capitalized words
72
+ * - "&"
73
+ * - " in "
74
+ * - " ex "
75
+ * - lowercase name particles
76
+ * 2. Followed by a capitalized word
77
+ * 3. Optionally, followed by "et al."
78
+ */
79
+ const LOWERCASE_NAME_PARTICLES = ['y', 'der', 'den', 'de', 'van', 'von'].join('|')
80
+ const SIMPLE_AUTHOR_PATTERN = '(?:(?:\\p{Lu}\\S*|&|in|ex|' + LOWERCASE_NAME_PARTICLES + ')\\s*)*\\p{Lu}\\S+(?:\\s+et\\s+al\\.)?'
81
+
82
+ const NAME_PATTERN = new RegExp(
83
+ '^' +
84
+ // $1 main name part
85
+ '(\\S+)' +
86
+ // $2 optional author citation
87
+ '(?: ' +
88
+ // but not auct(t)., etc.
89
+ '(?!auctt?\\.|(?:syn|comb|sp|spec|nom|gen|subgen)\\. n(?:ov)?\\.|s(?:ens[.u]|\\.)|in part|partim)' +
90
+ '(' +
91
+ // $2.1 anything in parentheses, followed by optional revising author(s)
92
+ '\\(.+?\\)(?:\\s+' + SIMPLE_AUTHOR_PATTERN + ')?' +
93
+ '|' +
94
+ // $2.2 anything followed by a year
95
+ '.+?\\d{4}\\)?' +
96
+ '|' +
97
+ // $2.3 author(s)
98
+ SIMPLE_AUTHOR_PATTERN +
99
+ '))?' +
100
+ // $3 optional notes
101
+ '(?:,? (.+))?' +
102
+ '$',
103
+ 'u'
104
+ )
105
+
106
+ /**
107
+ * Structure
108
+ * $1 genus: ((?:x )?[A-Z]\S+)
109
+ * $2 subgenus: (?:\(([A-Z]\S+?)\) )?
110
+ */
111
+ const SUBGENUS_PATTERN = /^([A-Z]\S+) (?:\(([A-Z]\S+?)\))(?= |$)/
112
+
113
+ /**
114
+ * Structure
115
+ * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
116
+ * $1.1 genus: ((?:x )?[A-Z]\S+)
117
+ * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
118
+ * $2 species: (x [a-z-]+|[a-z-][^\s.]+(?: x [a-z-]+)?|[A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+)
119
+ * $2a: x [a-z-]+
120
+ * $2b hybrid: [a-z-][^\s.]+(?: x [a-z-]+)?
121
+ * $2c intergeneric hybrid: [A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+
122
+ */
123
+ 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-]+)(?= |$)/
124
+
125
+ function compareRanks (a: Rank, b: Rank): number {
126
+ return RANKS.indexOf(a) - RANKS.indexOf(b)
127
+ }
128
+
129
+ function capitalize (name: string): string {
130
+ return name[0].toUpperCase() + name.slice(1).toLowerCase()
131
+ }
132
+
133
+ function capitalizeGenericName (name: string): string {
134
+ if (name[0] === HYBRID_SIGN) {
135
+ return HYBRID_SIGN + capitalize(name.slice(1))
136
+ }
137
+
138
+ return capitalize(name)
139
+ }
140
+
141
+ function isUpperCase (name: string): boolean {
142
+ return name === name.toUpperCase()
143
+ }
144
+
145
+ function getSynonymRank (name: string, rank: Rank): Rank {
146
+ const rest = name.replace(BINAME_PATTERN, '')
147
+ const rankPrefix = rest.match(/^(?: |^)(st|r|ab|f|var|ssp|subsp)\. /)
148
+ if (rankPrefix) {
149
+ return RANK_LABELS_REVERSE[rankPrefix[1]] as string
150
+ } else if (!BINAME_PATTERN.test(name)) {
151
+ return SUBGENUS_PATTERN.test(name) ? 'subgenus' : rank
152
+ } else if (/^ (?!sensu)[a-z0-9-]+($| )/.test(rest)) {
153
+ return 'subspecies'
154
+ } else {
155
+ return 'species'
156
+ }
157
+ }
158
+
159
+ function capitalizeAuthors (authors: string): string {
160
+ return authors
161
+ .replace(
162
+ /[^\x00-\x40\x5B-\x60\x7B-\x7F]+/g, // eslint-disable-line no-control-regex
163
+ name => isUpperCase(name) ? capitalize(name) : name
164
+ )
165
+ .replace(/ Y /g, ' y ')
166
+ }
167
+
168
+ export function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxon {
169
+ const item = {} as WorkingTaxon
170
+
171
+ // Synonyms have the accepted name usage as 'parent'.
172
+ const isSynonym = /^[+=>] /.test(name)
173
+ if (isSynonym) {
174
+ item.taxonomicStatus = TAXONOMIC_STATUS[name[0]]
175
+ name = name.replace(/^[+=>] (\? ?)?/, '')
176
+ rank = getSynonymRank(name, parent.taxonRank as Rank)
177
+ } else {
178
+ item.taxonomicStatus = 'accepted'
179
+ }
180
+
181
+ // Clusters
182
+ if (/^\[(_|\d+)\] /.test(name)) {
183
+ name = name.replace(/^\[(_|\d+)\] /, '')
184
+ }
185
+
186
+ // Set verbatim identification after subsequent syntax is removed.
187
+ item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ')
188
+
189
+ // Parent context is used for parsing and formatting binomial names.
190
+ // For formatting, it needs to match external databases (i.e. be correct).
191
+ // For parsing, it needs to match the current file. If relevant parents
192
+ // (i.e. genus, species) had mistakes that were corrected, the uncorrected
193
+ // genus and species names need to be used.
194
+ const parentContext = {
195
+ genus: parent.genus,
196
+ subgenus: parent.subgenus,
197
+ specificEpithet: parent.specificEpithet,
198
+ incorrect: {
199
+ genus: parent.incorrect && parent.incorrect.genus,
200
+ specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
201
+ }
202
+ }
203
+
204
+ // Both contexts should be amended in the two cases where binomial names
205
+ // are fully used: (1) synonyms and (2) multinomial taxa without parents to
206
+ // provide parts of the name (e.g. bare species without a genus parent, or
207
+ // even subspecies without a species or genus parent).
208
+ if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
209
+ const [, genus, subgenus, species] = name.match(BINAME_PATTERN) ?? name.match(SUBGENUS_PATTERN) ?? []
210
+ if (genus) {
211
+ parentContext.incorrect.genus = genus
212
+ parentContext.genus = capitalizeGenericName(genus.replace(/(^| )x /, HYBRID_SIGN))
213
+ }
214
+ if (subgenus) {
215
+ parentContext.subgenus = capitalize(subgenus)
216
+ } else if (genus) {
217
+ // If a genus is given but no subgenus, remove any existing subgenus
218
+ // from the parent context.
219
+ delete parentContext.subgenus
220
+ }
221
+ if (species && compareRanks('species', rank) < 0) {
222
+ parentContext.incorrect.specificEpithet = species
223
+ parentContext.specificEpithet = species.replace(/(^| )x /, HYBRID_SIGN)
224
+ }
225
+ }
226
+
227
+ // In taxa of group, species or lower, the name should just contain the
228
+ // (infra)specific epithet and the author information & remarks when processing
229
+ // further.
230
+ if (compareRanks('group', rank) <= 0) {
231
+ // Remove genus
232
+ const genus = parentContext.incorrect.genus || parentContext.genus || ''
233
+ if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
234
+ name = name.slice(genus.length + 1)
235
+ }
236
+
237
+ // Remove subgenus
238
+ name = name.replace(/^\(.*?\) /, '')
239
+
240
+ // Infraspecific taxa
241
+ if (compareRanks('species', rank) < 0) {
242
+ // Remove specific epithet
243
+ const species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || ''
244
+ if (name.startsWith(species + ' ')) {
245
+ name = name.slice(species.length + 1)
246
+ }
247
+
248
+ // Remove rank abbreviations
249
+ name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '')
250
+ }
251
+ } else if (compareRanks('genus', rank) <= 0) {
252
+ // Remove genus
253
+ const genus = parentContext.incorrect.genus || parentContext.genus || ''
254
+ if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' (')) {
255
+ name = name.slice(genus.length + 1)
256
+ }
257
+
258
+ // Remove subgenus parentheses
259
+ name = name.replace(/^\((.*?)\)/, '$1')
260
+ }
261
+
262
+ // Hybrids
263
+ if (rank === 'genus' && name.startsWith('x ')) {
264
+ name = HYBRID_SIGN + name.slice(2)
265
+ }
266
+
267
+ if (rank === 'species' && /(^| )x /.test(name)) {
268
+ name = name.replace(/(^| )x /, HYBRID_SIGN)
269
+ }
270
+
271
+ // Divide the name into the main scientific name (only the epithet for taxa
272
+ // lower than genus), the authorship information, and optionally remarks
273
+ const nameParts = name.match(NAME_PATTERN)
274
+ if (!nameParts) {
275
+ throw new Error(`Taxon "${name}" could not be parsed`)
276
+ }
277
+
278
+ // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
279
+ // underscores are used, which are replaced here. This is also used for undescribed
280
+ // species (e.g. "Leiobunum species A") and intergeneric hybrids (e.g. "×Festulpia
281
+ // Festuca rubra × Vulpia bromoides")
282
+ if (nameParts[1].includes('_')) {
283
+ nameParts[1] = nameParts[1].replace(/_/g, ' ')
284
+ }
285
+
286
+ const [_, taxon, citation = '', notes] = nameParts
287
+ item.scientificNameAuthorship = capitalizeAuthors(citation)
288
+ item.taxonRemarks = notes
289
+ item.taxonRank = rank
290
+ item.genericName = undefined
291
+ item.infragenericEpithet = undefined
292
+ item.specificEpithet = undefined
293
+ item.infraspecificEpithet = undefined
294
+
295
+ if (/[^\p{L}0-9\u{00D7}\- ]/u.test(taxon)) {
296
+ throw new RecoverableSyntaxError(`Taxon name contains unexpected characters: "${taxon}"`, item)
297
+ }
298
+
299
+ // Validate names and recompose binomial and trinomial names
300
+ if (compareRanks('genus', rank) > 0) {
301
+ item.scientificName = capitalize(taxon)
302
+ if (taxon[0].toUpperCase() !== taxon[0]) {
303
+ throw new RecoverableSyntaxError(`Taxon name (${rank}) should be capitalized: "${taxon}"`, item)
304
+ }
305
+ } else if (rank === 'genus') {
306
+ item.scientificName = capitalizeGenericName(taxon)
307
+ if (taxon[0].toUpperCase() !== taxon[0] || (taxon[0] === HYBRID_SIGN && taxon[1].toUpperCase() !== taxon[1])) {
308
+ throw new RecoverableSyntaxError(`Generic epithet should be capitalized: "${taxon}"`, item)
309
+ }
310
+ } else if (compareRanks('group', rank) > 0) {
311
+ item.genericName = parentContext.genus
312
+ item.infragenericEpithet = parentContext.subgenus
313
+ item.scientificName = capitalize(taxon)
314
+ if (taxon[0].toUpperCase() !== taxon[0]) {
315
+ throw new RecoverableSyntaxError(`Infrageneric epithet should be capitalized: "${taxon}"`, item)
316
+ }
317
+ } else if (rank === 'group') {
318
+ item.genericName = parentContext.genus
319
+ item.infragenericEpithet = parentContext.subgenus
320
+ const specificEpithet = taxon.toLowerCase().replace(/(-group)?$/, '')
321
+ item.scientificName = `${item.genericName} ${specificEpithet}-group`
322
+ if (taxon.toLowerCase() !== taxon) {
323
+ throw new RecoverableSyntaxError(`Group name should be lowercase: "${taxon}"`, item)
324
+ }
325
+ } else if (rank === 'subgroup') {
326
+ item.genericName = parentContext.genus
327
+ item.infragenericEpithet = parentContext.subgenus
328
+ const specificEpithet = taxon.toLowerCase().replace(/(-subgroup)?$/, '')
329
+ item.scientificName = `${item.genericName} ${specificEpithet}-subgroup`
330
+ if (taxon.toLowerCase() !== taxon) {
331
+ throw new RecoverableSyntaxError(`Subgroup name should be lowercase: "${taxon}"`, item)
332
+ }
333
+ } else if (compareRanks('species', rank) > 0) {
334
+ item.genericName = parentContext.genus
335
+ item.infragenericEpithet = parentContext.subgenus
336
+ const specificEpithet = taxon.toLowerCase()
337
+ item.scientificName = `${item.genericName} ${specificEpithet}`
338
+ if (specificEpithet !== taxon) {
339
+ throw new RecoverableSyntaxError(`Taxon name should be lowercase: "${taxon}"`, item)
340
+ }
341
+ } else if (rank === 'species') {
342
+ item.genericName = parentContext.genus
343
+ item.infragenericEpithet = parentContext.subgenus
344
+ if (taxon.toLowerCase() !== taxon && !/^[A-Z][a-z]+ [a-z]+\xD7[A-Z][a-z]+ [a-z]+$/.test(taxon)) {
345
+ throw new RecoverableSyntaxError(`Specific epithet should be lowercase: "${taxon}"`, item)
346
+ }
347
+ item.specificEpithet = taxon
348
+ item.scientificName = `${item.genericName} ${item.specificEpithet}`
349
+ } else if (compareRanks('species', rank) < 0) {
350
+ item.genericName = parentContext.genus
351
+ item.infragenericEpithet = parentContext.subgenus
352
+ item.specificEpithet = parentContext.specificEpithet
353
+ item.infraspecificEpithet = taxon.toLowerCase()
354
+
355
+ // If possible, names below species should have abbreviations for ranks,
356
+ // like "subsp."
357
+ const nameParts = [
358
+ item.genericName,
359
+ item.specificEpithet,
360
+ item.infraspecificEpithet
361
+ ]
362
+ if (item.taxonRank in RANK_LABELS) {
363
+ nameParts.splice(2, 0, RANK_LABELS[item.taxonRank])
364
+ }
365
+ item.scientificName = nameParts.join(' ')
366
+
367
+ if (item.infraspecificEpithet !== taxon) {
368
+ throw new RecoverableSyntaxError(`Infraspecific epithet should be lowercase: "${taxon}"`, item)
369
+ }
370
+ }
371
+
372
+ // Re-add authorship information
373
+ item.scientificNameOnly = item.scientificName
374
+ if (item.scientificNameAuthorship) {
375
+ item.scientificName += ` ${item.scientificNameAuthorship}`
376
+ }
377
+
378
+ return item
379
+ }