@larsgw/formica 0.8.3 → 0.8.5

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