@larsgw/formica 0.4.2 → 0.5.0

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,4 +1,5 @@
1
1
  import * as yaml from 'js-yaml'
2
+ import { Work } from '../catalog/tables/work'
2
3
  import { createDiff, ResourceDiffType } from './diff-resource'
3
4
 
4
5
  const RANKS: Rank[] = [
@@ -120,9 +121,9 @@ const NAME_PATTERN = new RegExp(
120
121
  * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
121
122
  * $1.1 genus: ([A-Z]\S+)
122
123
  * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
123
- * $2 species: ((?:x )?[a-z0-9-]+)
124
+ * $2 species: ((?:x )?[a-z][^\s.]+)
124
125
  */
125
- const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z0-9-]+)(?= |$)/
126
+ const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/
126
127
 
127
128
  function compareRanks (a: Rank, b: Rank): number {
128
129
  return RANKS.indexOf(a) - RANKS.indexOf(b)
@@ -178,49 +179,67 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
178
179
  name = name.replace(/^\[(_|\d+)\] /, '')
179
180
  }
180
181
 
182
+ // Set verbatim identification after subsequent syntax is removed.
183
+ item.verbatimIdentification = name
184
+
181
185
  // Parent context is used for parsing and formatting binomial names.
182
- const parentContext = { ...parent }
183
- if (parent.incorrect) { parentContext.incorrect = { ...parent.incorrect } }
186
+ // For formatting, it needs to match external databases (i.e. be correct).
187
+ // For parsing, it needs to match the current file. If relevant parents
188
+ // (i.e. genus, species) had mistakes that were corrected, the uncorrected
189
+ // genus and species names need to be used.
190
+ const parentContext = {
191
+ genus: parent.genus,
192
+ subgenus: parent.subgenus,
193
+ specificEpithet: parent.specificEpithet,
194
+ incorrect: {
195
+ genus: parent.incorrect && parent.incorrect.genus,
196
+ specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
197
+ }
198
+ }
184
199
 
185
- // The parent context should be amended in the two cases where binomial names
186
- // are truly accepted: synonyms and species (and below) without parents (resp.
187
- // genera and genera and species) to provide parts of the name.
200
+ // Both contexts should be amended in the two cases where binomial names
201
+ // are fully used: (1) synonyms and (2) multinomial taxa without parents to
202
+ // provide parts of the name (e.g. bare species without a genus parent, or
203
+ // even subspecies without a species or genus parent).
188
204
  if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
189
205
  const [, genus, subgenus, species] = name.match(BINAME_PATTERN) || []
190
206
  if (genus) {
191
- parentContext.genus = capitalize(genus)
192
- if (parentContext.incorrect) parentContext.incorrect.genus = capitalize(genus)
207
+ parentContext.genus = parentContext.incorrect.genus = capitalize(genus)
193
208
  }
194
209
  if (subgenus) {
195
210
  parentContext.subgenus = capitalize(subgenus)
196
- if (parentContext.incorrect) parentContext.incorrect.subgenus = capitalize(subgenus)
197
211
  } else if (genus) {
198
- // If a genus is given but no subgenus, remove it from the parent context
212
+ // If a genus is given but no subgenus, remove any existing subgenus
213
+ // from the parent context.
199
214
  delete parentContext.subgenus
200
- if (parentContext.incorrect) delete parentContext.incorrect.subgenus
201
215
  }
202
216
  if (species) {
203
- parentContext.specificEpithet = species
204
- if (parentContext.incorrect) parentContext.incorrect.specificEpithet = species
217
+ parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species
205
218
  }
206
219
  }
207
220
 
208
221
  // In taxa of group, species or lower, the name should just contain the
209
- // (inter)specific epithet and the author information & remarks when processing
222
+ // (infra)specific epithet and the author information & remarks when processing
210
223
  // further.
211
224
  if (compareRanks('group', rank) <= 0) {
212
- const parseContext = parentContext.incorrect || parentContext
213
- if (!parseContext.genus) { parseContext.genus = name.split(' ', 1)[0] }
214
- const genusPrefix = new RegExp(`^${parentContext.genus} (\\(.*?\\) )?`, 'i')
215
- if (name[0] === (parentContext.genus as string)[0]) {
216
- name = name.replace(genusPrefix, '')
225
+ // Remove genus
226
+ const genus = parentContext.incorrect.genus || parentContext.genus || ''
227
+ if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
228
+ name = name.slice(genus.length + 1)
217
229
  }
218
230
 
231
+ // Remove subgenus
232
+ name = name.replace(/^\(.*?\) /, '')
233
+
234
+ // Infraspecific taxa
219
235
  if (compareRanks('species', rank) < 0) {
220
- const speciesPrefix = parseContext.specificEpithet + ' '
221
- if (name.startsWith(speciesPrefix)) {
222
- name = name.slice(speciesPrefix.length)
236
+ // Remove specific epithet
237
+ const species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || ''
238
+ if (name.startsWith(species + ' ')) {
239
+ name = name.slice(species.length + 1)
223
240
  }
241
+
242
+ // Remove rank abbreviations
224
243
  name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '')
225
244
  }
226
245
  }
@@ -236,6 +255,13 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
236
255
  if (!nameParts) {
237
256
  throw new Error(`Taxon "${name}" could not be parsed`)
238
257
  }
258
+
259
+ // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
260
+ // underscores are used, which are replaced here.
261
+ if (nameParts[1].includes('_')) {
262
+ nameParts[1] = nameParts[1].replace(/_/g, ' ')
263
+ }
264
+
239
265
  const [_, taxon, citation = '', notes] = nameParts
240
266
  item.scientificNameAuthorship = capitalizeAuthors(citation)
241
267
  item.taxonRemarks = notes
@@ -282,23 +308,23 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
282
308
  item.genericName = parentContext.genus
283
309
  item.infragenericEpithet = parentContext.subgenus
284
310
  item.specificEpithet = parentContext.specificEpithet
285
- item.intraspecificEpithet = taxon.toLowerCase()
311
+ item.infraspecificEpithet = taxon.toLowerCase()
286
312
 
287
313
  // If possible, names below species should have abbreviations for ranks,
288
314
  // like "subsp."
289
315
  const nameParts = [
290
316
  item.genericName,
291
317
  item.specificEpithet,
292
- item.intraspecificEpithet
318
+ item.infraspecificEpithet
293
319
  ]
294
320
  if (item.taxonRank in RANK_LABELS) {
295
321
  nameParts.splice(2, 0, RANK_LABELS[item.taxonRank])
296
322
  }
297
323
  item.scientificName = nameParts.join(' ')
298
324
 
299
- if (item.intraspecificEpithet !== taxon) {
325
+ if (item.infraspecificEpithet !== taxon) {
300
326
  console.log(item, taxon)
301
- throw new Error(`Intraspecific epithet should be lowercase: "${taxon}"`)
327
+ throw new Error(`Infraspecific epithet should be lowercase: "${taxon}"`)
302
328
  }
303
329
  }
304
330
 
@@ -341,13 +367,8 @@ function parseHeader (header: string): ResourceMetadata {
341
367
  levels = config.levels
342
368
  }
343
369
 
344
- let scope
345
- if (!('scope' in config)) {
346
- scope = []
347
- } else if (!Array.isArray(config.scope)) {
348
- throw new SyntaxError('"scope" should be an array')
349
- } else {
350
- scope = config.scope
370
+ if ('scope' in config) {
371
+ throw new SyntaxError('"scope" data should go in "catalog"')
351
372
  }
352
373
 
353
374
  // No taxon ranks
@@ -361,10 +382,29 @@ function parseHeader (header: string): ResourceMetadata {
361
382
  throw new SyntaxError(`"levels" contains invalid values: ${invalidTaxonRanks.join(', ')}`)
362
383
  }
363
384
 
364
- const metadata: ResourceMetadata = { levels, scope }
385
+ const metadata: ResourceMetadata = { levels }
365
386
 
366
387
  if ('catalog' in config && typeof config.catalog === 'object' && config.catalog !== null) {
367
- metadata.catalog = config.catalog
388
+ const catalog: Record<string, string> = {}
389
+ for (const key in config.catalog) {
390
+ const value = config.catalog[key as keyof object]
391
+ if (typeof value === 'number') {
392
+ catalog[key] = (value as number).toString()
393
+ } else if (typeof value === 'string') {
394
+ catalog[key] = value
395
+ } else {
396
+ throw new SyntaxError(`"catalog" should contain only strings ("${key}")`)
397
+ }
398
+ }
399
+ const work = new Work(catalog)
400
+ const errors = work.validate().filter(({ error }) => error !== 'Value(s) required but missing')
401
+ if (errors.length > 0) {
402
+ throw new SyntaxError(`"catalog" contains errors: ${errors.map(({ field, error }) => `[${field}] ${error}`).join('; ')}`)
403
+ }
404
+ metadata.catalog = {}
405
+ for (const key in work.fields) {
406
+ metadata.catalog[key] = work.get(key) as Value
407
+ }
368
408
  }
369
409
 
370
410
  return metadata
package/test/resources.js CHANGED
@@ -90,4 +90,38 @@ Drymus
90
90
  `, 'T1')
91
91
  assert.strictEqual(resource.taxa['T1:1:1'].scientificName, 'Drymus')
92
92
  })
93
+
94
+ await t.test('parses names containing non-ASCII characters', (t) => {
95
+ const [resource] = resources.parseTextFile(`---
96
+ levels: [species]
97
+ ---
98
+
99
+ Nematus fåhraei Thomson
100
+ `, 'T1')
101
+ assert.strictEqual(resource.taxa['T1:1:1'].scientificName, 'Nematus fåhraei Thomson')
102
+ })
103
+
104
+ await t.test('parses synonyms in different genera', (t) => {
105
+ const [resource] = resources.parseTextFile(`---
106
+ levels: [species]
107
+ ---
108
+
109
+ Katamenes arbustorum subsp. burlinii
110
+ > arbustorum subsp. burlinii
111
+ = Eumenes arbustorum var. burlinii
112
+ `, 'T1')
113
+ assert.strictEqual(resource.taxa['T1:1:2'].scientificName, 'Eumenes arbustorum var. burlinii')
114
+ })
115
+
116
+ await t.test('parses species without generic names', (t) => {
117
+ const [resource] = resources.parseTextFile(`---
118
+ levels: [genus, subgenus, species]
119
+ ---
120
+
121
+ Microdynerus Thomson, 1874
122
+ Alastorynerus Blüthgen, 1938
123
+ microdynerus (Dalla Torre, 1889)
124
+ `, 'T1')
125
+ assert.strictEqual(resource.taxa['T1:1:3'].scientificName, 'Microdynerus microdynerus (Dalla Torre, 1889)')
126
+ })
93
127
  })