@larsgw/formica 0.8.4 → 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.
@@ -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,
@@ -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
+ }