@larsgw/formica 0.2.0 → 0.3.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.
@@ -43,6 +43,15 @@ var RANKS = [
43
43
  'race',
44
44
  'stirps' // not ICZN
45
45
  ];
46
+ var MAIN_RANKS = [
47
+ 'kingdom',
48
+ 'phylum',
49
+ 'class',
50
+ 'order',
51
+ 'family',
52
+ 'genus',
53
+ 'species'
54
+ ];
46
55
  var DWC_RANKS = [
47
56
  'kingdom',
48
57
  'phylum',
@@ -350,10 +359,7 @@ function parseHeader(header) {
350
359
  }
351
360
  return metadata;
352
361
  }
353
- function parseResource(resource) {
354
- var _a = resource.split(/(\n---\n+)/), header = _a[0], _ = _a[1], rest = _a.slice(2);
355
- var config = parseHeader(header);
356
- var content = rest.join('');
362
+ function validateResource(config, content) {
357
363
  // Check for too much indentation
358
364
  var longerIndent = new RegExp("^( ){".concat(config.levels.length - 1, "}(?! [+=>] ) "), 'm');
359
365
  var longerIndentMatch = content.match(longerIndent);
@@ -363,13 +369,22 @@ function parseResource(resource) {
363
369
  throw new SyntaxError("Too much indentation at ".concat(line, ":0\n").concat(content.slice(offset).split('\n', 1), "\n^"));
364
370
  }
365
371
  // Check for missing leaf taxa
366
- var missingLeafTaxa = new RegExp("^( ){0,".concat(config.levels.length - 2, "}(?![+=>] ).*\\n(?!\\1 )"), 'm');
367
- var missingLeafTaxaMatch = content.match(missingLeafTaxa);
368
- if (missingLeafTaxaMatch !== null) {
369
- var offset = missingLeafTaxaMatch.index;
370
- var line = (content.slice(0, offset).match(/\n/g) || []).length + 1;
371
- throw new SyntaxError("Missing leaf taxon at ".concat(line, ":0\n").concat(content.slice(offset).split('\n', 1), "\n^"));
372
+ var leafTaxonRank = config.levels.filter(function (rank) { return MAIN_RANKS.includes(rank); }).pop();
373
+ var leafTaxonParentIndent = config.levels.indexOf(leafTaxonRank) - 1;
374
+ if (leafTaxonRank && leafTaxonParentIndent >= 0) {
375
+ var missingLeafTaxa = new RegExp("^((?: ){0,".concat(leafTaxonParentIndent, "})(?![+=> ] ).*\\n(\\1( )+[+=>].*\\n)*(?!\\1 )"), 'm');
376
+ var missingLeafTaxaMatch = content.match(missingLeafTaxa);
377
+ if (missingLeafTaxaMatch !== null) {
378
+ var offset = missingLeafTaxaMatch.index;
379
+ var line = (content.slice(0, offset).match(/\n/g) || []).length + 1;
380
+ throw new SyntaxError("Missing leaf taxon at ".concat(line, ":0\n").concat(content.slice(offset).split('\n', 1), "\n^"));
381
+ }
372
382
  }
383
+ }
384
+ function parseResource(resource) {
385
+ var _a = resource.split(/(\n---\n+)/), header = _a[0], _ = _a[1], rest = _a.slice(2);
386
+ var config = parseHeader(header);
387
+ var content = rest.join('');
373
388
  return [config, content];
374
389
  }
375
390
  function parseResourceContent(content, resource, oldIds) {
@@ -476,6 +491,7 @@ function parseFile(file, id, old) {
476
491
  var oldResources = old ? splitResources(old.txt) : [];
477
492
  return splitResources(file).map(function (resource, index) {
478
493
  var _a = parseResource(resource), config = _a[0], content = _a[1];
494
+ validateResource(config, content);
479
495
  var template = {
480
496
  id: "".concat(id, ":").concat(index + 1),
481
497
  file: "".concat(id, "-").concat(index + 1),
@@ -0,0 +1,2 @@
1
+ export declare function groupNameMatches(results: Record<TaxonId, TaxonMatch[]>): GroupedNameMatches;
2
+ export declare function amendResource(resource: AmendedResource, source: string, matches: Record<TaxonId, TaxonMatch>): void;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.amendResource = exports.groupNameMatches = void 0;
4
+ var MINIMUM_PREFIX_LENGTH = 3;
5
+ var VALID_COMMON_PREFIXES = new Set([
6
+ 'Plantae|Tracheophyta',
7
+ 'Fungi',
8
+ 'Fungi|Ascomycota',
9
+ 'Fungi|Basidiomycota',
10
+ 'Fungi|Zygomycota'
11
+ ]);
12
+ function getCommonPrefix(a, b) {
13
+ for (var i = 0; i < Math.max(a.length, b.length); i++) {
14
+ if (a[i] !== b[i]) {
15
+ return a.slice(0, i);
16
+ }
17
+ }
18
+ return a.slice();
19
+ }
20
+ function isValidPrefix(a, b) {
21
+ var prefix = getCommonPrefix(a, b);
22
+ return VALID_COMMON_PREFIXES.has(prefix.join('|')) || prefix.length >= MINIMUM_PREFIX_LENGTH;
23
+ }
24
+ function groupNameMatches(results) {
25
+ var prefixes = {};
26
+ for (var scientificNameID in results) {
27
+ var _loop_1 = function (result) {
28
+ if (!prefixes[result.source]) {
29
+ prefixes[result.source] = [];
30
+ }
31
+ var prefix = prefixes[result.source].find(function (prefix) { return isValidPrefix(prefix[0], result.classificationPath); });
32
+ if (!prefix) {
33
+ prefix = [result.classificationPath, {}];
34
+ prefixes[result.source].push(prefix);
35
+ }
36
+ else {
37
+ prefix[0] = getCommonPrefix(prefix[0], result.classificationPath);
38
+ }
39
+ if (scientificNameID in prefix[1]) {
40
+ return "continue";
41
+ }
42
+ prefix[1][scientificNameID] = result;
43
+ };
44
+ for (var _i = 0, _a = results[scientificNameID]; _i < _a.length; _i++) {
45
+ var result = _a[_i];
46
+ _loop_1(result);
47
+ }
48
+ }
49
+ var groupedNameMatches = {};
50
+ for (var source in prefixes) {
51
+ groupedNameMatches[source] = prefixes[source]
52
+ .sort(function (a, b) { return Object.keys(b[1]).length - Object.keys(a[1]).length; })
53
+ .reduce(function (map, _a) {
54
+ var prefix = _a[0], taxa = _a[1];
55
+ map[prefix.join('|')] = taxa;
56
+ return map;
57
+ }, {});
58
+ }
59
+ return groupedNameMatches;
60
+ }
61
+ exports.groupNameMatches = groupNameMatches;
62
+ function amendResource(resource, source, matches) {
63
+ for (var id in matches) {
64
+ var match = matches[id];
65
+ if (source === '1') {
66
+ resource.taxa[id].colTaxonID = match.id;
67
+ if (match.currentId) {
68
+ resource.taxa[id].colAcceptedTaxonID = match.currentId;
69
+ }
70
+ }
71
+ else if (source === '11') {
72
+ resource.taxa[id].gbifTaxonID = match.id;
73
+ if (match.currentId) {
74
+ resource.taxa[id].gbifAcceptedTaxonID = match.currentId;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ exports.amendResource = amendResource;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "SDK and tools for data from the Library of Identification Resources",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -14,7 +14,9 @@
14
14
  "test": "node --test --test-reporter spec",
15
15
  "lint": "eslint src",
16
16
  "build": "tsc -d",
17
+ "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
17
18
  "preversion": "npm run lint",
19
+ "version": "npm run changelog && git add CHANGELOG.md",
18
20
  "prepublishOnly": "npm run build"
19
21
  },
20
22
  "repository": {
@@ -37,6 +39,7 @@
37
39
  "@types/node": "^18.14.1",
38
40
  "@typescript-eslint/eslint-plugin": "^5.54.0",
39
41
  "@typescript-eslint/parser": "^5.54.0",
42
+ "conventional-changelog-cli": "^3.0.0",
40
43
  "eslint": "^8.35.0",
41
44
  "typescript": "^4.9.5"
42
45
  }
@@ -21,6 +21,14 @@ function sortObject (object: Record<string, any>): Record<string, any> {
21
21
  }
22
22
  /* eslint-enable @typescript-eslint/no-explicit-any */
23
23
 
24
+ function addTaxon (gbifIndex: Record<string, TaxonId[]>, gbifId: string, taxon: string[]) {
25
+ if (!(gbifId in gbifIndex)) {
26
+ gbifIndex[gbifId] = []
27
+ }
28
+ gbifIndex[gbifId].push(taxon[0])
29
+ gbifIndex[gbifId].sort(numericSort)
30
+ }
31
+
24
32
  async function main (args: string[]): Promise<void> {
25
33
  const REPO_ROOT = path.resolve(args[0])
26
34
 
@@ -50,11 +58,10 @@ async function main (args: string[]): Promise<void> {
50
58
  for (const taxon of dwc) {
51
59
  const gbifId = taxon[25]
52
60
  if (gbifId) {
53
- if (!(gbifId in gbifIndex)) {
54
- gbifIndex[gbifId] = []
61
+ addTaxon(gbifIndex, gbifId, taxon)
62
+ if (taxon[27] !== taxon[25]) {
63
+ addTaxon(gbifIndex, taxon[27], taxon)
55
64
  }
56
- gbifIndex[gbifId].push(taxon[0])
57
- gbifIndex[gbifId].sort(numericSort)
58
65
  }
59
66
  amendedResource.taxonCount += 1
60
67
  }
@@ -8,17 +8,6 @@ import * as util from 'util'
8
8
  import { csv } from '../index'
9
9
  import { prompt, promptForAnswers, numericSort, runCommand } from './util'
10
10
 
11
- interface AmendedTaxon extends Taxon {
12
- colTaxonID?: string,
13
- gbifTaxonID?: string
14
- }
15
-
16
- interface AmendedResource extends Resource {
17
- taxa: Record<TaxonId, AmendedTaxon>
18
- }
19
-
20
- type Classifications = Record<string, Array<[AmendedTaxon, string]>>
21
-
22
11
  const DWC_FIELDS: string[] = [
23
12
  'scientificNameID',
24
13
  'scientificName',
@@ -49,7 +38,9 @@ const DWC_FIELDS: string[] = [
49
38
  'higherClassification',
50
39
 
51
40
  'colTaxonID',
52
- 'gbifTaxonID'
41
+ 'gbifTaxonID',
42
+ 'colAcceptedTaxonID',
43
+ 'gbifAcceptedTaxonID'
53
44
  ]
54
45
 
55
46
  const DISPLAY_FIELDS: string[] = [
@@ -74,17 +65,9 @@ const GBIF_RANKS: Rank[] = [
74
65
  'variety'
75
66
  ]
76
67
 
77
- const VALID_COMMON_PREFIXES = [
78
- 'Plantae|Tracheophyta',
79
- 'Fungi',
80
- 'Fungi|Ascomycota',
81
- 'Fungi|Basidiomycota',
82
- 'Fungi|Zygomycota'
83
- ]
84
-
85
68
  function runGnverifier (names: string): Promise<string> {
86
69
  return new Promise((resolve, reject) => {
87
- const proc = spawn('gnverifier', ['-s', '1,11', '-M'])
70
+ const proc = spawn('gnverifier', ['-s', '1,11', '-f', 'compact', '-M'])
88
71
  let stdout = ''
89
72
  proc.stdout.on('data', data => { stdout += data })
90
73
  proc.stderr.pipe(process.stdout)
@@ -153,6 +136,18 @@ class ResourceProcessor {
153
136
  }
154
137
  }
155
138
 
139
+ async runMappingsUpdate (): Promise<void> {
140
+ const input = await fs.readdir(this.DIR_TXT)
141
+
142
+ const ids = input
143
+ .map(file => path.basename(file, '.txt'))
144
+ .sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)))
145
+
146
+ for (const id of ids) {
147
+ await this.processWork(id, true)
148
+ }
149
+ }
150
+
156
151
  async processWork (id: WorkId, update?: boolean): Promise<void> {
157
152
  const resources = await this.processResources(id, update)
158
153
 
@@ -174,11 +169,7 @@ class ResourceProcessor {
174
169
 
175
170
  const amendedResources = []
176
171
  for (const resource of resources) {
177
- const [results, classifications] = await this.processResourceDwc(resource)
178
-
179
- for (const source in classifications) {
180
- await this.checkPrefix(resource, classifications, source)
181
- }
172
+ const results = await this.processResourceDwc(resource)
182
173
 
183
174
  const skip = await this.shouldBeSkipped(resource.id)
184
175
 
@@ -256,100 +247,153 @@ class ResourceProcessor {
256
247
  }
257
248
  }
258
249
 
259
- async processResourceDwc (resource: Resource): Promise<[AmendedResource, Classifications]> {
250
+ async processResourceDwc (resource: Resource): Promise<AmendedResource> {
260
251
  console.log(`${resource.workId}: matching ${resource.id}`)
261
- const taxa: Record<string, Record<TaxonId, AmendedTaxon>> = {}
262
- const names = []
252
+
253
+ const filteredResults: Record<TaxonId, TaxonMatch[]> = {}
254
+ const taxonNames: Record<string, TaxonId[]> = {}
255
+ const names = new Set()
263
256
  for (const id in resource.taxa) {
264
257
  const name = resource.taxa[id].scientificName
265
258
 
266
- if (!taxa[name]) { taxa[name] = {} }
267
- taxa[name][id] = { ...resource.taxa[id] }
259
+ if (!taxonNames[name]) { taxonNames[name] = [] }
260
+ taxonNames[name].push(id)
268
261
 
269
- names.push(name)
262
+ names.add(name)
263
+ filteredResults[id] = []
270
264
  }
271
265
 
272
- const result = await runGnverifier(names.join('\n'))
273
- const classifications: Classifications = { '1': [], '11': [] }
274
-
275
- const [header, ...matches] = csv.parseCsv(result)
276
- for (const match of matches) {
277
- const name = match[header.indexOf('ScientificName')]
278
- const source = match[header.indexOf('DataSourceId')]
279
- const id = match[header.indexOf('TaxonId')]
280
- const classification = match[header.indexOf('ClassificationPath')]
281
-
282
- for (const loirId in taxa[name]) {
283
- const taxon = taxa[name][loirId]
284
- if (source === '1' && !taxon.colTaxonID) {
285
- taxon.colTaxonID = id
286
- classifications[source].push([taxon, classification])
266
+ const result = await runGnverifier(Array.from(names).join('\n'))
267
+ for (const results of result.trim().split('\n')) {
268
+ const { name, results: matches } = JSON.parse(results)
269
+
270
+ if (!matches) {
271
+ continue
272
+ }
273
+
274
+ for (const match of matches) {
275
+ const source = match.dataSourceId
276
+ const currentRank = match.classificationRanks.split('|').pop()
277
+
278
+ if (match.scoreDetails.cardinalityScore === 0) {
279
+ // Rank mismatch
280
+ continue
281
+ } else if (source === 11 && currentRank === 'species' && match.classificationPath.endsWith(' spec')) {
282
+ // GBIF species like "Nomada spec"
283
+ continue
287
284
  }
288
- if (source === '11' && GBIF_RANKS.includes(taxon.taxonRank) && !taxon.gbifTaxonID) {
289
- taxon.gbifTaxonID = id
290
- classifications[source].push([taxon, classification])
285
+
286
+ for (const loirId of taxonNames[name]) {
287
+ const taxon = resource.taxa[loirId]
288
+
289
+ if (source === 11 && !GBIF_RANKS.includes(taxon.taxonRank)) {
290
+ // Exclude GBIF matches for ranks that are not in GBIF
291
+ continue
292
+ } else if (source === 11 && !match.isSynonym && currentRank !== taxon.taxonRank) {
293
+ // Exclude matches with rank mismatches (only possible
294
+ // for non-synonyms).
295
+ continue
296
+ }
297
+
298
+ if (!filteredResults[loirId]) {
299
+ filteredResults[loirId] = []
300
+ }
301
+
302
+ filteredResults[loirId].push({
303
+ source,
304
+ id: match.recordId,
305
+ currentId: match.currentRecordId,
306
+ classificationPath: match.classificationPath.split('|')
307
+ })
291
308
  }
292
309
  }
293
310
  }
294
311
 
295
- const results = {
296
- ...resource,
297
- taxa: Object.fromEntries(Object.values(resource.taxa).map(taxon => [
298
- taxon.scientificNameID,
299
- taxa[taxon.scientificName][taxon.scientificNameID]
300
- ]))
312
+ const { taxonNames: { amendResource, groupNameMatches } } = await import('../index')
313
+ const groupedNameMatches = groupNameMatches(filteredResults)
314
+
315
+ const amendedResource: AmendedResource = { ...resource, taxa: { ...resource.taxa } }
316
+ for (const source in groupedNameMatches) {
317
+ const matches = await this.selectPrefixes(resource, groupedNameMatches, source)
318
+ amendResource(amendedResource, source, matches)
301
319
  }
302
320
 
303
- return [results, classifications]
321
+ return amendedResource
304
322
  }
305
323
 
306
- async checkPrefix (resource: AmendedResource, classifications: Classifications, source: string): Promise<void> {
307
- const lists = classifications[source]
308
- if (!lists.length) { return }
309
- const prefix = lists[0][1].split('|')
310
-
311
- for (const [taxon, list] of lists.slice(1)) {
312
- const parts = list.split('|')
313
- for (let i = 0; i < parts.length; i++) {
314
- if (parts[i] !== prefix[i] && i < 3) {
315
- let choice
316
-
317
- if (source === '1' || taxon.taxonomicStatus !== 'accepted') {
318
- choice = 'd'
319
- } else if (VALID_COMMON_PREFIXES.includes(prefix.slice(0, i).join('|'))) {
320
- choice = 'k'
321
- } else {
322
- console.log(`${resource.workId}: source ${source} results in short prefix "${prefix.slice(0, i).join('|')}" (${i} taxa)`)
323
- console.log(` taxon: ${taxon.scientificNameID} "${taxon.scientificName}"`)
324
- console.log(` class: ${parts.join('|')}`)
325
- console.log(` prefx: ${prefix.join('|')}`)
326
-
327
- choice = await promptForAnswers(` Keep or delete (k/d)? `, ['k', 'K', 'd', 'D'])
328
- }
324
+ async selectPrefixes (resource: Resource, groupedNameMatches: GroupedNameMatches, source: string): Promise<Record<TaxonId, TaxonMatch>> {
325
+ const prefixes = Object.keys(groupedNameMatches[source])
326
+ if (prefixes.length === 0) {
327
+ return {}
328
+ } else if (prefixes.length === 1) {
329
+ return groupedNameMatches[source][prefixes[0]]
330
+ }
329
331
 
330
- switch (choice) {
331
- case 'k':
332
- case 'K': {
333
- console.log(` keeping...`)
334
- break
335
- }
332
+ // Count total mapped taxa
333
+ const mappedTaxa: Record<TaxonId, boolean> = {}
334
+ for (const prefix of prefixes) {
335
+ for (const taxon in groupedNameMatches[source][prefix]) {
336
+ mappedTaxa[taxon] = true
337
+ }
338
+ }
339
+ const missedTaxonCount = Object.keys(mappedTaxa).length - Object.keys(groupedNameMatches[source][prefixes[0]]).length
336
340
 
337
- case 'd':
338
- case 'D': {
339
- console.log(` deleting...`)
340
- if (source === '1') {
341
- delete taxon.colTaxonID
342
- } else if (source === '11') {
343
- delete taxon.gbifTaxonID
344
- }
345
- break
346
- }
347
- }
341
+ if (missedTaxonCount === 0) {
342
+ // Multiple prefixes but the first one maps all taxa (not counting that are unmapped in all prefixes)
343
+ return groupedNameMatches[source][prefixes[0]]
344
+ }
345
+
346
+ console.error(`${resource.workId}: source ${source} results in multiple prefixes`)
347
+
348
+ let choice
349
+ if (missedTaxonCount <= 5) {
350
+ console.error(` Most common prefix misses ${missedTaxonCount} taxa: automatically selecting most common prefix...`)
351
+ choice = '1'
352
+ } else if (source === '1') {
353
+ console.error(` Catalogue of Life: automatically selecting most common prefix...`)
354
+ choice = '1'
355
+ } else {
356
+ for (let i = 0; i < prefixes.length; i++) {
357
+ const prefix = prefixes[i]
358
+ const taxa = groupedNameMatches[source][prefix]
359
+ const taxonIds = Object.keys(taxa)
360
+
361
+ console.error(` [${i + 1}] ${prefix} (${taxonIds.length} taxa)`)
362
+ for (let j = 0; j < Math.min(9, taxonIds.length); j++) {
363
+ const taxonId = taxonIds[j]
364
+ const taxon = resource.taxa[taxonId]
365
+ const match = taxa[taxonId]
366
+ console.error(` taxon: ${taxonId} "${taxon.scientificName}" - ${match.classificationPath.join('|')}`)
367
+ }
368
+ if (taxonIds.length > 9) {
369
+ console.error(` ...`)
370
+ }
371
+ }
372
+
373
+ do {
374
+ choice = await prompt(` Select prefixes (1-${prefixes.length})? `)
375
+ } while (!/^(|\d+(,\d+)*)$/.test(choice))
376
+ }
377
+
378
+ console.error(` Applying selection...`)
379
+
380
+ if (choice === '') {
381
+ return {}
382
+ }
348
383
 
349
- break
384
+ const matches: Record<TaxonId, TaxonMatch> = {}
385
+ for (const i of choice.split(',')) {
386
+ const prefix = prefixes[parseInt(i) - 1]
387
+ const taxa = groupedNameMatches[source][prefix]
388
+ for (const id in taxa) {
389
+ if (id in matches) {
390
+ continue
350
391
  }
392
+ matches[id] = taxa[id]
351
393
  }
352
394
  }
395
+
396
+ return matches
353
397
  }
354
398
 
355
399
  checkResults (resource: AmendedResource): boolean {
@@ -388,6 +432,9 @@ function main (): void {
388
432
  update: {
389
433
  type: 'boolean',
390
434
  short: 'u'
435
+ },
436
+ 'update-mappings': {
437
+ type: 'boolean'
391
438
  }
392
439
  },
393
440
  allowPositionals: true
@@ -398,7 +445,15 @@ function main (): void {
398
445
  process.stdout.write('\n')
399
446
  })
400
447
 
401
- const task = args.values.update ? processor.runUpdate() : processor.run()
448
+ let task
449
+ if (args.values.update) {
450
+ task = processor.runUpdate()
451
+ } else if (args.values['update-mappings']) {
452
+ task = processor.runMappingsUpdate()
453
+ } else {
454
+ task = processor.run()
455
+ }
456
+
402
457
  task.catch(error => {
403
458
  console.error(error)
404
459
  process.exit(1)
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * as catalog from './catalog/index'
2
2
  export * as resources from './resources/index'
3
+ export * as taxonNames from './taxon-names/index'
3
4
  export * as csv from './csv'