@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.
@@ -1,38 +1,7 @@
1
1
  import * as yaml from 'js-yaml'
2
2
  import { WorkResource } from './resource'
3
3
  import { createDiff, ResourceDiffType } from './diff-resource'
4
-
5
- const RANKS: Rank[] = [
6
- 'phylum',
7
- 'subphylum',
8
- 'class',
9
- 'infraclass',
10
- 'superorder',
11
- 'order',
12
- 'suborder',
13
- 'infraorder',
14
- 'superfamily',
15
- 'family',
16
- 'subfamily',
17
- 'tribe',
18
- 'subtribe',
19
- 'genus',
20
- 'subgenus',
21
- 'section', // not ICZN
22
- 'subsection', // not ICZN
23
- 'series', // not ICZN
24
- 'group',
25
- 'subgroup', // ...
26
- 'aggregate', // not ICZN
27
- 'complex', // not ICZN
28
- 'species',
29
- 'subspecies',
30
- 'variety',
31
- 'form',
32
- 'aberration', // not ICZN
33
- 'race', // not ICZN
34
- 'stirps' // not ICZN
35
- ]
4
+ import { parseName, RANKS } from './parse-name'
36
5
 
37
6
  const MAIN_RANKS: Rank[] = [
38
7
  'kingdom',
@@ -62,313 +31,15 @@ const FLAGS: ResourceFlag[] = [
62
31
  'MISSING_AUTHORSHIP'
63
32
  ]
64
33
 
65
- const TAXONOMIC_STATUS: Record<string, TaxonStatus> = {
66
- '>': 'incorrect',
67
- '+': 'heterotypic synonym',
68
- '=': 'synonym'
69
- }
70
-
71
- const RANK_LABELS: Record<Rank, string> = {
72
- 'subspecies': 'subsp.',
73
- 'variety': 'var.',
74
- 'form': 'f.',
75
- 'aberration': 'ab.',
76
- 'race': 'r.',
77
- 'stirps': 'st.'
78
- }
79
-
80
- const RANK_LABELS_REVERSE: Record<string, Rank> = {
81
- 'st': 'stirps',
82
- 'r': 'race',
83
- 'ab': 'aberration',
84
- 'f': 'form',
85
- 'var': 'variety',
86
- 'ssp': 'subspecies',
87
- 'subsp': 'subspecies'
88
- }
89
-
90
- const HYBRID_SIGN = '\u00D7'
91
-
92
- /**
93
- * 1. Any number of
94
- * - capitalized words
95
- * - "&"
96
- * - " in "
97
- * - " ex "
98
- * - lowercase name particles
99
- * 2. Followed by a capitalized word
100
- * 3. Optionally, followed by "et al."
101
- */
102
- const LOWERCASE_NAME_PARTICLES = ['y', 'der', 'den', 'de', 'van', 'von'].join('|')
103
- const SIMPLE_AUTHOR_PATTERN = '(?:(?:\\p{Lu}\\S*|&|in|ex|' + LOWERCASE_NAME_PARTICLES + ')\\s*)*\\p{Lu}\\S+(?:\\s+et\\s+al\\.)?'
104
-
105
- const NAME_PATTERN = new RegExp(
106
- '^' +
107
- // $1 main name part
108
- '(\\S+)' +
109
- // $2 optional author citation
110
- '(?: ' +
111
- // but not auct(t)., etc.
112
- '(?!auctt?\\.|(?:syn|comb|sp|spec|nom|gen|subgen)\\. n(?:ov)?\\.|s(?:ens[.u]|\\.)|in part|partim)' +
113
- '(' +
114
- // $2.1 anything in parentheses, followed by optional revising author(s)
115
- '\\(.+?\\)(?:\\s+' + SIMPLE_AUTHOR_PATTERN + ')?' +
116
- '|' +
117
- // $2.2 anything followed by a year
118
- '.+?\\d{4}\\)?' +
119
- '|' +
120
- // $2.3 author(s)
121
- SIMPLE_AUTHOR_PATTERN +
122
- '))?' +
123
- // $3 optional notes
124
- '(?:,? (.+))?' +
125
- '$',
126
- 'u'
127
- )
128
-
129
- /**
130
- * Structure
131
- * $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
132
- * $1.1 genus: ((?:x )?[A-Z]\S+)
133
- * $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
134
- * $2 species: (x [a-z-]+|[a-z-][^\s.]+(?: x [a-z-]+)?|[A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+)
135
- * $2a: x [a-z-]+
136
- * $2b hybrid: [a-z-][^\s.]+(?: x [a-z-]+)?
137
- * $2c intergeneric hybrid: [A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+
138
- */
139
- 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-]+)(?= |$)/
140
-
141
- function compareRanks (a: Rank, b: Rank): number {
142
- return RANKS.indexOf(a) - RANKS.indexOf(b)
143
- }
144
-
145
- function capitalize (name: string): string {
146
- return name[0].toUpperCase() + name.slice(1).toLowerCase()
147
- }
148
-
149
- function capitalizeGenericName (name: string): string {
150
- if (name[0] === HYBRID_SIGN) {
151
- return HYBRID_SIGN + capitalize(name.slice(1))
152
- }
34
+ const RESOURCE_DELIMITER = '\n\n===\n\n'
35
+ const INDENT = 2
153
36
 
154
- return capitalize(name)
37
+ function makeParseError (message: string, line: number, column: number = 1): SyntaxError {
38
+ return new SyntaxError(`[${line}:${column}] ${message}`)
155
39
  }
156
40
 
157
- function isUpperCase (name: string): boolean {
158
- return name === name.toUpperCase()
159
- }
160
-
161
- function getSynonymRank (name: string, rank: Rank): Rank {
162
- const rest = name.replace(BINAME_PATTERN, '')
163
- const rankPrefix = rest.match(/^(?: |^)(st|r|ab|f|var|ssp|subsp)\. /)
164
- if (rankPrefix) {
165
- return RANK_LABELS_REVERSE[rankPrefix[1]] as string
166
- } else if (!BINAME_PATTERN.test(name)) {
167
- return rank
168
- } else if (/^ (?!sensu)[a-z0-9-]+($| )/.test(rest)) {
169
- return 'subspecies'
170
- } else {
171
- return 'species'
172
- }
173
- }
174
-
175
- function capitalizeAuthors (authors: string): string {
176
- return authors
177
- .replace(
178
- /[^\x00-\x40\x5B-\x60\x7B-\x7F]+/g, // eslint-disable-line no-control-regex
179
- name => isUpperCase(name) ? capitalize(name) : name
180
- )
181
- .replace(/ Y /g, ' y ')
182
- }
183
-
184
- function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxon {
185
- const item = {} as WorkingTaxon
186
-
187
- // Synonyms have the accepted name usage as 'parent'.
188
- const isSynonym = /^[+=>] /.test(name)
189
- if (isSynonym) {
190
- item.taxonomicStatus = TAXONOMIC_STATUS[name[0]]
191
- name = name.replace(/^[+=>] (\? ?)?/, '')
192
- rank = getSynonymRank(name, parent.taxonRank as Rank)
193
- } else {
194
- item.taxonomicStatus = 'accepted'
195
- }
196
-
197
- // Clusters
198
- if (/^\[(_|\d+)\] /.test(name)) {
199
- name = name.replace(/^\[(_|\d+)\] /, '')
200
- }
201
-
202
- // Set verbatim identification after subsequent syntax is removed.
203
- item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ')
204
-
205
- // Parent context is used for parsing and formatting binomial names.
206
- // For formatting, it needs to match external databases (i.e. be correct).
207
- // For parsing, it needs to match the current file. If relevant parents
208
- // (i.e. genus, species) had mistakes that were corrected, the uncorrected
209
- // genus and species names need to be used.
210
- const parentContext = {
211
- genus: parent.genus,
212
- subgenus: parent.subgenus,
213
- specificEpithet: parent.specificEpithet,
214
- incorrect: {
215
- genus: parent.incorrect && parent.incorrect.genus,
216
- specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
217
- }
218
- }
219
-
220
- // Both contexts should be amended in the two cases where binomial names
221
- // are fully used: (1) synonyms and (2) multinomial taxa without parents to
222
- // provide parts of the name (e.g. bare species without a genus parent, or
223
- // even subspecies without a species or genus parent).
224
- if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
225
- const [, genus, subgenus, species] = name.match(BINAME_PATTERN) || []
226
- if (genus) {
227
- parentContext.incorrect.genus = genus
228
- parentContext.genus = capitalizeGenericName(genus.replace(/(^| )x /, HYBRID_SIGN))
229
- }
230
- if (subgenus) {
231
- parentContext.subgenus = capitalize(subgenus)
232
- } else if (genus) {
233
- // If a genus is given but no subgenus, remove any existing subgenus
234
- // from the parent context.
235
- delete parentContext.subgenus
236
- }
237
- if (species && compareRanks('species', rank) < 0) {
238
- parentContext.incorrect.specificEpithet = species
239
- parentContext.specificEpithet = species.replace(/(^| )x /, HYBRID_SIGN)
240
- }
241
- }
242
-
243
- // In taxa of group, species or lower, the name should just contain the
244
- // (infra)specific epithet and the author information & remarks when processing
245
- // further.
246
- if (compareRanks('group', rank) <= 0) {
247
- // Remove genus
248
- const genus = parentContext.incorrect.genus || parentContext.genus || ''
249
- if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
250
- name = name.slice(genus.length + 1)
251
- }
252
-
253
- // Remove subgenus
254
- name = name.replace(/^\(.*?\) /, '')
255
-
256
- // Infraspecific taxa
257
- if (compareRanks('species', rank) < 0) {
258
- // Remove specific epithet
259
- const species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || ''
260
- if (name.startsWith(species + ' ')) {
261
- name = name.slice(species.length + 1)
262
- }
263
-
264
- // Remove rank abbreviations
265
- name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '')
266
- }
267
- }
268
-
269
- // Hybrids
270
- if (rank === 'genus' && name.startsWith('x ')) {
271
- name = HYBRID_SIGN + name.slice(2)
272
- }
273
-
274
- if (rank === 'species' && /(^| )x /.test(name)) {
275
- name = name.replace(/(^| )x /, HYBRID_SIGN)
276
- }
277
-
278
- // Divide the name into the main scientific name (only the epithet for taxa
279
- // lower than genus), the authorship information, and optionally remarks
280
- const nameParts = name.match(NAME_PATTERN)
281
- if (!nameParts) {
282
- throw new Error(`Taxon "${name}" could not be parsed`)
283
- }
284
-
285
- // To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
286
- // underscores are used, which are replaced here. This is also used for undescribed
287
- // species (e.g. "Leiobunum species A") and intergeneric hybrids (e.g. "×Festulpia
288
- // Festuca rubra × Vulpia bromoides")
289
- if (nameParts[1].includes('_')) {
290
- nameParts[1] = nameParts[1].replace(/_/g, ' ')
291
- }
292
-
293
- const [_, taxon, citation = '', notes] = nameParts
294
- item.scientificNameAuthorship = capitalizeAuthors(citation)
295
- item.taxonRemarks = notes
296
- item.taxonRank = rank
297
-
298
- // @ts-expect-error TS1501: This regular expression flag is only available when targeting 'es6' or later.
299
- if (/[^\p{L}0-9\u{00D7}\- ]/u.test(taxon)) {
300
- throw new Error(`Taxon name contains unexpected characters: "${taxon}"`)
301
- }
302
-
303
- // Validate names and recompose binomial and trinomial names
304
- if (rank === 'genus') {
305
- item.scientificName = capitalizeGenericName(taxon)
306
- if (taxon[0].toUpperCase() !== taxon[0] || (taxon[0] === HYBRID_SIGN && taxon[1].toUpperCase() !== taxon[1])) {
307
- throw new Error(`Generic epithet should be capitalized: "${taxon}"`)
308
- }
309
- } else if (compareRanks('group', rank) > 0) {
310
- item.scientificName = capitalize(taxon)
311
- if (taxon[0].toUpperCase() !== taxon[0]) {
312
- throw new Error(`Taxon name (${rank}) should be capitalized: "${taxon}"`)
313
- }
314
- } else if (rank === 'group') {
315
- item.genericName = parentContext.genus
316
- item.infragenericEpithet = parentContext.subgenus
317
- const specificEpithet = taxon.toLowerCase().replace(/(-group)?$/, '')
318
- item.scientificName = `${item.genericName} ${specificEpithet}-group`
319
- if (taxon.toLowerCase() !== taxon) {
320
- console.log(item, taxon)
321
- throw new Error(`Group name should be lowercase: "${taxon}"`)
322
- }
323
- } else if (rank === 'subgroup') {
324
- item.genericName = parentContext.genus
325
- item.infragenericEpithet = parentContext.subgenus
326
- const specificEpithet = taxon.toLowerCase().replace(/(-subgroup)?$/, '')
327
- item.scientificName = `${item.genericName} ${specificEpithet}-subgroup`
328
- if (taxon.toLowerCase() !== taxon) {
329
- console.log(item, taxon)
330
- throw new Error(`Subgroup name should be lowercase: "${taxon}"`)
331
- }
332
- } else if (rank === 'species') {
333
- item.genericName = parentContext.genus
334
- item.infragenericEpithet = parentContext.subgenus
335
- if (taxon.toLowerCase() !== taxon && !/^[A-Z][a-z]+ [a-z]+\xD7[A-Z][a-z]+ [a-z]+$/.test(taxon)) {
336
- console.log(item, taxon)
337
- throw new Error(`Specific epithet should be lowercase: "${taxon}"`)
338
- }
339
- item.specificEpithet = taxon
340
- item.scientificName = `${item.genericName} ${item.specificEpithet}`
341
- } else if (compareRanks('species', rank) < 0) {
342
- item.genericName = parentContext.genus
343
- item.infragenericEpithet = parentContext.subgenus
344
- item.specificEpithet = parentContext.specificEpithet
345
- item.infraspecificEpithet = taxon.toLowerCase()
346
-
347
- // If possible, names below species should have abbreviations for ranks,
348
- // like "subsp."
349
- const nameParts = [
350
- item.genericName,
351
- item.specificEpithet,
352
- item.infraspecificEpithet
353
- ]
354
- if (item.taxonRank in RANK_LABELS) {
355
- nameParts.splice(2, 0, RANK_LABELS[item.taxonRank])
356
- }
357
- item.scientificName = nameParts.join(' ')
358
-
359
- if (item.infraspecificEpithet !== taxon) {
360
- console.log(item, taxon)
361
- throw new Error(`Infraspecific epithet should be lowercase: "${taxon}"`)
362
- }
363
- }
364
-
365
- // Re-add authorship information
366
- item.scientificNameOnly = item.scientificName
367
- if (item.scientificNameAuthorship) {
368
- item.scientificName += ` ${item.scientificNameAuthorship}`
369
- }
370
-
371
- return item
41
+ function mergeParserErrors (errors: SyntaxError[]): SyntaxError {
42
+ return new SyntaxError(errors.map(error => error.message).join('\n'))
372
43
  }
373
44
 
374
45
  function parseHeader (header: string): ResourceMetadata {
@@ -447,113 +118,101 @@ function parseHeader (header: string): ResourceMetadata {
447
118
  return metadata
448
119
  }
449
120
 
450
- function validateResource (config: ResourceMetadata, content: string) {
451
- // Check for too much indentation
452
- const longerIndent = new RegExp(`^( ){${config.levels.length - 1}}(?! [+=>] | > ) `, 'm')
453
- const longerIndentMatch = content.match(longerIndent)
454
- if (longerIndentMatch !== null) {
455
- const offset = longerIndentMatch.index
456
- const line = (content.slice(0, offset).match(/\n/g) || []).length + 1
457
- throw new SyntaxError(`Too much indentation at ${line}:0
458
- ${content.slice(offset).split('\n', 1)}
459
- ^`)
460
- }
121
+ function parseResource (resource: FilePart): [ResourceMetadata, FilePart] {
122
+ const [header, _, ...rest] = resource.content.split(/(\n---\n+)/)
123
+ let config
461
124
 
462
- // Check for missing leaf taxa
463
- const leafTaxonRank = config.levels.filter(rank => MAIN_RANKS.includes(rank)).pop() as string
464
- const leafTaxonParentIndent = config.levels.indexOf(leafTaxonRank) - 1
465
- if (leafTaxonRank && leafTaxonParentIndent >= 0) {
466
- const missingLeafTaxa = new RegExp(`^((?: ){0,${leafTaxonParentIndent}})(?![+=> ] ).*\\n(\\1( )+[+=>].*\\n)*(?!\\1 )`, 'm')
467
- const missingLeafTaxaMatch = content.match(missingLeafTaxa)
468
- if (missingLeafTaxaMatch !== null) {
469
- const offset = missingLeafTaxaMatch.index
470
- const line = (content.slice(0, offset).match(/\n/g) || []).length + 1
471
- throw new SyntaxError(`Missing leaf taxon at ${line}:0
472
- ${content.slice(offset).split('\n', 1)}
473
- ^`)
474
- }
125
+ try {
126
+ config = parseHeader(header)
127
+ } catch (error) {
128
+ throw makeParseError(error.message, resource.offsetLine + 1)
475
129
  }
476
- }
477
130
 
478
- function parseResource (resource: string): [ResourceMetadata, string] {
479
- const [header, _, ...rest] = resource.split(/(\n---\n+)/)
480
- const config = parseHeader(header)
481
131
  const content = rest.join('')
132
+ const offsetLine = resource.offsetLine + (header + _).split('\n').length - 1
482
133
 
483
- return [config, content]
134
+ return [config, { content, offsetLine }]
484
135
  }
485
136
 
486
- function getIndentation (line: string): number {
487
- return (line.match(/^ */) as string[])[0].length
488
- }
137
+ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds: number[], offsetLine: number): Resource {
138
+ const leafTaxonIndex = resource.metadata.levels.reduce((last, rank, i) => MAIN_RANKS.includes(rank) ? i : last, 0)
139
+ const data = resource.taxa as Record<TaxonId, WorkingTaxon>
140
+ const errors = []
489
141
 
490
- function isIndetLine (line: string, indent?: number): boolean {
491
- if (indent === undefined) {
492
- indent = getIndentation(line)
493
- }
142
+ let id = 0
143
+ let newId = Math.max(...oldIds)
144
+ let lineNumber = offsetLine
494
145
 
495
- line = line.slice(indent)
146
+ const parents: Array<TaxonId | null> = []
147
+ const previous = { id: '', indent: 0, group: { isLeaf: false, indent: 0 } }
496
148
 
497
- return line.startsWith('[indet]')
498
- }
149
+ for (const line of content) {
150
+ const hasOriginalId = line.type !== ResourceDiffType.Added && !/^\s*(\[indet\]|> )/.test(line.original ?? line.text as string)
151
+ if (hasOriginalId) {
152
+ id++
153
+ }
499
154
 
500
- function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds: number[]): Resource {
501
- const idBase = `${resource.id}:`
155
+ if (line.type === ResourceDiffType.Deleted) {
156
+ continue
157
+ } else {
158
+ lineNumber++
159
+ }
502
160
 
503
- const data = resource.taxa as Record<TaxonId, WorkingTaxon>
504
- let id = 0
505
- let parents: Array<TaxonId | null> = []
506
- let groupIndent = 0
507
- let previousId = ''
508
- let newIdOffset = Math.max(...oldIds)
161
+ const [indentation, name] = (line.text as string).match(/^(\s*)(.*)/)!.slice(1)
162
+ const lineIndent = indentation.length
509
163
 
510
- for (const line of content) {
511
- if (line.type === ResourceDiffType.Deleted) {
512
- // Increase id counter for removed line unless it was an "indet line"
513
- if (!isIndetLine(line.original as string)) {
514
- id++
515
- }
164
+ // Validate line
165
+ if (lineIndent % INDENT === 1) {
166
+ errors.push(makeParseError('Too much or little indentation', lineNumber))
167
+ continue
168
+ } else if (lineIndent / INDENT >= resource.metadata.levels.length && !/^[+=>] /.test(name)) {
169
+ errors.push(makeParseError('Too much indentation', lineNumber))
170
+ continue
171
+ } else if (lineIndent <= previous.group.indent && (data[previous.id] && !previous.group.isLeaf)) {
172
+ errors.push(makeParseError('Missing leaf taxon', lineNumber - 1))
516
173
  continue
517
174
  }
518
175
 
519
- const lineIndent = getIndentation(line.text as string)
520
- if (lineIndent > groupIndent) {
176
+ // Update parentage
177
+ if (lineIndent > previous.indent) {
521
178
  // Do not count synonyms as parents (unless this is correcting a typo in the synonym)
522
- if (data[previousId] && data[previousId].taxonomicStatus === 'accepted' || /^( {2})+> /.test(line.text as string)) {
523
- parents.push(previousId)
179
+ if (data[previous.id] && data[previous.id].taxonomicStatus === 'accepted' || name.startsWith('> ')) {
180
+ parents.push(previous.id)
524
181
  } else {
525
182
  parents.push(null)
526
183
  }
184
+
527
185
  // Handle skips in indentation levels,
528
186
  // e.g. if a certain genus has only species
529
187
  // whereas other genera in the same key also
530
188
  // have subgenera
531
- if ((lineIndent - groupIndent) > 2) {
532
- const gap = (lineIndent - groupIndent - 2) / 2
533
- for (let i = 0; i < gap; i++) { parents.push(null) }
189
+ for (let i = previous.indent + INDENT; i < lineIndent; i += INDENT) {
190
+ parents.push(null)
534
191
  }
535
- groupIndent = lineIndent
536
- } else if (lineIndent < groupIndent) {
537
- parents = parents.slice(0, lineIndent / 2)
538
- groupIndent = lineIndent
192
+ } else if (lineIndent < previous.indent) {
193
+ parents.splice(lineIndent / INDENT)
539
194
  }
540
195
 
196
+ previous.indent = lineIndent
197
+
541
198
  // Do not process "indet" lines further, as they only serve to indicate
542
199
  // that subtaxa are explicitely omitted
543
- if (isIndetLine(line.text as string, lineIndent)) {
544
- // If the line was previously not and ndet line, increase the id counter
545
- if (line.type === ResourceDiffType.Modified && !isIndetLine(line.original as string)) {
546
- id++
547
- }
200
+ if (name.startsWith('[indet]')) {
201
+ previous.group.isLeaf = lineIndent / INDENT >= leafTaxonIndex
548
202
  continue
549
203
  }
550
204
 
551
- const parentId = parents.reduce((grandparent, parent) => parent || grandparent, null)
205
+ const parentId = parents.reduce((grandparent, parent) => parent ?? grandparent, null)
552
206
  const parent = parentId === null ? {} as WorkingTaxon : data[parentId]
553
- const name = (line.text as string).slice(groupIndent)
554
- const rank = resource.metadata.levels[groupIndent / 2]
555
- const item = parseName(name, rank, parent)
556
- const isSynonym = item.taxonomicStatus !== 'accepted'
207
+ const rank = resource.metadata.levels[parents.length]
208
+ let item
209
+
210
+ try {
211
+ item = parseName(name, rank, parent)
212
+ } catch (error) {
213
+ errors.push(makeParseError(error.message, lineNumber))
214
+ continue
215
+ }
557
216
 
558
217
  // Add higher classification info
559
218
  const itemAsObject = item as { [index: string]: unknown }
@@ -575,15 +234,7 @@ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds
575
234
  item.subgenus = item.infragenericEpithet
576
235
  }
577
236
 
578
- if (isSynonym) {
579
- item.higherClassification = parent.higherClassification
580
- } else if (parent.higherClassification) {
581
- item.higherClassification = parent.higherClassification + ` | ${parent.scientificNameOnly}`
582
- } else if (parentId) {
583
- item.higherClassification = parent.scientificNameOnly
584
- }
585
-
586
- // Amend "parent" with corrections
237
+ // Amend "parent" with corrections, exit
587
238
  if (item.taxonomicStatus === 'incorrect') {
588
239
  parent.incorrect = { ...parent }
589
240
  for (const key in item) {
@@ -594,40 +245,65 @@ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds
594
245
  continue
595
246
  }
596
247
 
597
- // Set identifiers
598
- if (line.type === ResourceDiffType.Added) {
599
- newIdOffset++
600
- item.scientificNameID = idBase + newIdOffset.toString()
601
- } else if (line.type === ResourceDiffType.Modified && isIndetLine(line.original as string)) {
602
- newIdOffset++
603
- item.scientificNameID = idBase + newIdOffset.toString()
604
- } else {
605
- id++
606
- item.scientificNameID = idBase + (oldIds[id - 1] || id).toString()
248
+ // Add more classification info
249
+ const isSynonym = item.taxonomicStatus !== 'accepted'
250
+ if (isSynonym) {
251
+ item.higherClassification = parent.higherClassification
252
+ } else if (parent.higherClassification) {
253
+ item.higherClassification = parent.higherClassification + ` | ${parent.scientificNameOnly}`
254
+ } else if (parentId) {
255
+ item.higherClassification = parent.scientificNameOnly
607
256
  }
608
- previousId = item.scientificNameID
257
+
258
+ // Set identifiers
259
+ item.scientificNameID = `${resource.id}:${hasOriginalId ? (oldIds[id - 1] ?? id) : ++newId}`
609
260
 
610
261
  item.parentNameUsageID = isSynonym ? undefined : parent.scientificNameID
611
262
  item.parentNameUsage = isSynonym ? undefined : parent.scientificName
612
263
  item.acceptedNameUsageID = isSynonym ? parent.scientificNameID : undefined
613
264
  item.acceptedNameUsage = isSynonym ? parent.scientificName : undefined
614
- item.collectionCode = idBase.slice(0, -1)
265
+ item.collectionCode = resource.id
615
266
 
616
267
  data[item.scientificNameID] = item
268
+
269
+ // Update loop state
270
+ previous.id = item.scientificNameID
271
+ if (item.taxonomicStatus === 'accepted') {
272
+ previous.group.indent = previous.indent
273
+ previous.group.isLeaf = lineIndent / INDENT >= leafTaxonIndex
274
+ }
275
+ }
276
+
277
+ if (errors.length) {
278
+ throw mergeParserErrors(errors)
617
279
  }
618
280
 
619
281
  return resource
620
282
  }
621
283
 
622
- function splitResources (file: string): string[] {
623
- return file.split('\n\n===\n\n')
284
+ interface FilePart {
285
+ content: string
286
+ offsetLine: number
287
+ }
288
+
289
+ function splitResources (file: string): FilePart[] {
290
+ const resources: FilePart[] = []
291
+ let offsetLine = 0
292
+ for (const content of file.split(RESOURCE_DELIMITER)) {
293
+ resources.push({ content, offsetLine })
294
+ offsetLine += (content + RESOURCE_DELIMITER).split('\n').length - 1
295
+ }
296
+ return resources
624
297
  }
625
298
 
626
299
  export function parseFile (file: string, id: WorkId, old?: ResourceHistory): Resource[] {
627
300
  const oldResources = old ? splitResources(old.txt) : []
628
- return splitResources(file).map((resource, index) => {
629
- const [config, content] = parseResource(resource)
630
- validateResource(config, content)
301
+ const newResources = splitResources(file)
302
+ const resources: Resource[] = []
303
+ const errors = []
304
+
305
+ for (let index = 0; index < newResources.length; index++) {
306
+ const [config, content] = parseResource(newResources[index])
631
307
  const template: Resource = {
632
308
  id: `${id}:${index + 1}`,
633
309
  file: `${id}-${index + 1}`,
@@ -638,11 +314,11 @@ export function parseFile (file: string, id: WorkId, old?: ResourceHistory): Res
638
314
 
639
315
  let diff
640
316
  if (oldResources[index]) {
641
- diff = createDiff(content, parseResource(oldResources[index])[1])
317
+ diff = createDiff(content.content, parseResource(oldResources[index])[1].content)
642
318
  // Ignore empty lines
643
319
  diff = diff.filter(line => line.text !== '')
644
320
  } else {
645
- diff = createDiff(content, content)
321
+ diff = createDiff(content.content, content.content)
646
322
  }
647
323
 
648
324
  const oldIds = []
@@ -652,8 +328,18 @@ export function parseFile (file: string, id: WorkId, old?: ResourceHistory): Res
652
328
  }
653
329
  }
654
330
 
655
- return parseResourceContent(diff, template, oldIds)
656
- })
331
+ try {
332
+ resources.push(parseResourceContent(diff, template, oldIds, content.offsetLine))
333
+ } catch (error) {
334
+ errors.push(error)
335
+ }
336
+ }
337
+
338
+ if (errors.length) {
339
+ throw mergeParserErrors(errors)
340
+ }
341
+
342
+ return resources
657
343
  }
658
344
 
659
345
  export function parseFileHeader (file: string): ResourceMetadata[] {