@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.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +34 -0
- package/lib/bin/generate-linked-data.js +10 -5
- package/lib/bin/process-resources.js +1 -1
- package/lib/bin/validate-catalog.js +8 -10
- package/lib/bin/validate-resources-text.js +1 -1
- package/lib/resources/diff-resource.d.ts +1 -1
- package/lib/resources/diff-resource.js +61 -16
- package/lib/resources/parse-name.d.ts +2 -0
- package/lib/resources/parse-name.js +352 -0
- package/lib/resources/parse-text.js +118 -409
- package/package.json +2 -1
- package/src/bin/generate-linked-data.ts +9 -5
- package/src/bin/process-resources.ts +1 -1
- package/src/bin/validate-catalog.ts +9 -9
- package/src/bin/validate-resources-text.ts +1 -1
- package/src/module.d.ts +4 -4
- package/src/resources/diff-resource.ts +70 -14
- package/src/resources/parse-name.ts +374 -0
- package/src/resources/parse-text.ts +126 -440
- package/test/resources.js +99 -17
|
@@ -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('
|
|
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:
|
|
663
|
-
resource['dcterms:
|
|
666
|
+
if (!Array.isArray(resource['dcterms:isPartOf'])) {
|
|
667
|
+
resource['dcterms:isPartOf'] = []
|
|
664
668
|
}
|
|
665
|
-
(resource['dcterms:
|
|
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<
|
|
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 (
|
|
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.
|
|
27
|
-
console.error(`${
|
|
28
|
-
console.table(result.value
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
42
|
+
C.setValue(i + 1, j + 1, C.getValue(i, j) + 1)
|
|
23
43
|
} else {
|
|
24
|
-
C
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
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
|
+
}
|