@larsgw/formica 0.1.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.
Files changed (65) hide show
  1. package/.eslintrc.js +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -0
  4. package/lib/bin/process-resources-index.d.ts +1 -0
  5. package/lib/bin/process-resources-index.js +142 -0
  6. package/lib/bin/process-resources.d.ts +1 -0
  7. package/lib/bin/process-resources.js +594 -0
  8. package/lib/bin/util.d.ts +16 -0
  9. package/lib/bin/util.js +125 -0
  10. package/lib/bin/validate-catalog.d.ts +2 -0
  11. package/lib/bin/validate-catalog.js +92 -0
  12. package/lib/bin/validate-resources-text.d.ts +2 -0
  13. package/lib/bin/validate-resources-text.js +78 -0
  14. package/lib/catalog/entities.d.ts +12 -0
  15. package/lib/catalog/entities.js +111 -0
  16. package/lib/catalog/entity.d.ts +13 -0
  17. package/lib/catalog/entity.js +103 -0
  18. package/lib/catalog/index.d.ts +4 -0
  19. package/lib/catalog/index.js +33 -0
  20. package/lib/catalog/tables/author.d.ts +4 -0
  21. package/lib/catalog/tables/author.js +33 -0
  22. package/lib/catalog/tables/index.d.ts +2 -0
  23. package/lib/catalog/tables/index.js +13 -0
  24. package/lib/catalog/tables/place.d.ts +4 -0
  25. package/lib/catalog/tables/place.js +32 -0
  26. package/lib/catalog/tables/publisher.d.ts +4 -0
  27. package/lib/catalog/tables/publisher.js +33 -0
  28. package/lib/catalog/tables/work.d.ts +5 -0
  29. package/lib/catalog/tables/work.js +82 -0
  30. package/lib/catalog/value.d.ts +19 -0
  31. package/lib/catalog/value.js +49 -0
  32. package/lib/csv.d.ts +2 -0
  33. package/lib/csv.js +37 -0
  34. package/lib/index.d.ts +3 -0
  35. package/lib/index.js +6 -0
  36. package/lib/resources/diff-resource.d.ts +7 -0
  37. package/lib/resources/diff-resource.js +152 -0
  38. package/lib/resources/index.d.ts +1 -0
  39. package/lib/resources/index.js +6 -0
  40. package/lib/resources/parse-text.d.ts +2 -0
  41. package/lib/resources/parse-text.js +499 -0
  42. package/lib/types.d.ts +62 -0
  43. package/lib/types.js +0 -0
  44. package/package.json +42 -0
  45. package/src/bin/process-resources-index.ts +73 -0
  46. package/src/bin/process-resources.ts +406 -0
  47. package/src/bin/util.ts +74 -0
  48. package/src/bin/validate-catalog.ts +37 -0
  49. package/src/bin/validate-resources-text.ts +25 -0
  50. package/src/catalog/entities.ts +62 -0
  51. package/src/catalog/entity.ts +113 -0
  52. package/src/catalog/index.ts +32 -0
  53. package/src/catalog/tables/author.ts +13 -0
  54. package/src/catalog/tables/index.ts +12 -0
  55. package/src/catalog/tables/place.ts +12 -0
  56. package/src/catalog/tables/publisher.ts +13 -0
  57. package/src/catalog/tables/work.ts +62 -0
  58. package/src/catalog/value.ts +48 -0
  59. package/src/csv.ts +33 -0
  60. package/src/index.ts +3 -0
  61. package/src/module.d.ts +105 -0
  62. package/src/resources/diff-resource.ts +155 -0
  63. package/src/resources/index.ts +4 -0
  64. package/src/resources/parse-text.ts +519 -0
  65. package/tsconfig.json +11 -0
@@ -0,0 +1,406 @@
1
+ import { promises as fs } from 'fs'
2
+ import * as path from 'path'
3
+ import { spawn } from 'child_process'
4
+ import * as util from 'util'
5
+
6
+ import { csv } from '../index'
7
+ import { prompt, promptForAnswers, numericSort, runCommand } from './util'
8
+
9
+ interface AmendedTaxon extends Taxon {
10
+ colTaxonID?: string,
11
+ gbifTaxonID?: string
12
+ }
13
+
14
+ interface AmendedResource extends Resource {
15
+ taxa: Record<TaxonId, AmendedTaxon>
16
+ }
17
+
18
+ type Classifications = Record<string, Array<[AmendedTaxon, string]>>
19
+
20
+ const DWC_FIELDS: string[] = [
21
+ 'scientificNameID',
22
+ 'scientificName',
23
+ 'scientificNameAuthorship',
24
+ 'genericName',
25
+ 'intragenericEpithet',
26
+ 'specificEpithet',
27
+ 'intraspecificEpithet',
28
+
29
+ 'taxonRank',
30
+ 'taxonRemarks',
31
+ 'collectionCode',
32
+
33
+ 'taxonomicStatus',
34
+ 'acceptedNameUsageID',
35
+ 'acceptedNameUsage',
36
+
37
+ 'parentNameUsageID',
38
+ 'parentNameUsage',
39
+ 'kingdom',
40
+ 'phylum',
41
+ 'class',
42
+ 'order',
43
+ 'family',
44
+ 'subfamily',
45
+ 'genus',
46
+ 'subgenus',
47
+ 'higherClassification',
48
+
49
+ 'colTaxonID',
50
+ 'gbifTaxonID'
51
+ ]
52
+
53
+ const DISPLAY_FIELDS: string[] = [
54
+ 'scientificNameID',
55
+ 'taxonRank',
56
+ 'scientificName',
57
+ 'taxonomicStatus',
58
+ 'taxonRemarks',
59
+ 'colTaxonID',
60
+ 'gbifTaxonID'
61
+ ]
62
+
63
+ const GBIF_RANKS: Rank[] = [
64
+ 'kingdom',
65
+ 'phyllum',
66
+ 'class',
67
+ 'order',
68
+ 'family',
69
+ 'genus',
70
+ 'species',
71
+ 'subspecies',
72
+ 'variety'
73
+ ]
74
+
75
+ const VALID_COMMON_PREFIXES = [
76
+ 'Plantae|Tracheophyta',
77
+ 'Fungi',
78
+ 'Fungi|Ascomycota',
79
+ 'Fungi|Basidiomycota',
80
+ 'Fungi|Zygomycota'
81
+ ]
82
+
83
+ function runGnverifier (names: string): Promise<string> {
84
+ return new Promise((resolve, reject) => {
85
+ const proc = spawn('gnverifier', ['-s', '1,11', '-M'])
86
+ let stdout = ''
87
+ proc.stdout.on('data', data => { stdout += data })
88
+ proc.stderr.pipe(process.stdout)
89
+ proc.on('close', code => {
90
+ if (code === 0) {
91
+ resolve(stdout)
92
+ } else {
93
+ reject()
94
+ }
95
+ })
96
+ proc.stdin.write(names)
97
+ proc.stdin.end()
98
+ })
99
+ }
100
+
101
+ async function listChangedFiles (directory: string): Promise<string[]> {
102
+ const output = await runCommand('git', ['diff', '--name-only', 'HEAD', '--', directory], {
103
+ cwd: directory
104
+ })
105
+ return output.trimEnd().split('\n').sort(numericSort)
106
+ }
107
+
108
+ async function getOldFile (file: string): Promise<string> {
109
+ const options = {
110
+ cwd: path.dirname(file)
111
+ }
112
+ const gitRoot = (await runCommand('git', ['rev-parse', '--show-toplevel'], options)).trim()
113
+ return await runCommand('git', ['show', 'HEAD:' + path.relative(gitRoot, file)], options)
114
+ }
115
+
116
+ class ResourceProcessor {
117
+ DIR_ROOT: string;
118
+ DIR_TXT: string;
119
+ DIR_DWC: string;
120
+ FILE_PROBLEMS: string;
121
+
122
+ constructor (collectionPath: string) {
123
+ this.DIR_ROOT = path.resolve(collectionPath)
124
+ this.DIR_TXT = path.join(this.DIR_ROOT, 'txt')
125
+ this.DIR_DWC = path.join(this.DIR_ROOT, 'dwc')
126
+ this.FILE_PROBLEMS = path.join(this.DIR_ROOT, 'problems.csv')
127
+ }
128
+
129
+ async run (): Promise<void> {
130
+ const input = await fs.readdir(this.DIR_TXT)
131
+ const output = await fs.readdir(this.DIR_DWC)
132
+
133
+ const ids = input
134
+ .map(file => path.basename(file, '.txt'))
135
+ .sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)))
136
+
137
+ for (const id of ids) {
138
+ // Skip existing files
139
+ if (output.some(file => file.startsWith(id + '-'))) {
140
+ continue
141
+ }
142
+
143
+ await this.processWork(id)
144
+ }
145
+ }
146
+
147
+ async runUpdate (): Promise<void> {
148
+ for (const file of await listChangedFiles(this.DIR_TXT)) {
149
+ const id = path.basename(file, '.txt')
150
+ await this.processWork(id, true)
151
+ }
152
+ }
153
+
154
+ async processWork (id: WorkId, update?: boolean): Promise<void> {
155
+ const resources = await this.processResources(id, update)
156
+
157
+ await Promise.all(resources.map(resource => {
158
+ const header = DWC_FIELDS
159
+ const table = [header]
160
+
161
+ for (const id in resource.taxa) {
162
+ const taxon = resource.taxa[id] as unknown as Record<string, string | undefined>
163
+ table.push(header.map(column => taxon[column] || ''))
164
+ }
165
+
166
+ return fs.writeFile(path.join(this.DIR_DWC, `${resource.file}.csv`), csv.formatCsv(table, ',').trim())
167
+ }))
168
+ }
169
+
170
+ async processResources (id: WorkId, update?: boolean): Promise<AmendedResource[]> {
171
+ const resources = await this.processResourceText(id, update)
172
+
173
+ const amendedResources = []
174
+ for (const resource of resources) {
175
+ const [results, classifications] = await this.processResourceDwc(resource)
176
+
177
+ for (const source in classifications) {
178
+ await this.checkPrefix(resource, classifications, source)
179
+ }
180
+
181
+ const skip = await this.shouldBeSkipped(resource.id)
182
+
183
+ if (!skip) {
184
+ // TODO const correct = checkResults(results, classifications)
185
+ const correct = this.checkResults(results)
186
+ if (!correct) {
187
+ const choice = await promptForAnswers(
188
+ `${resource.workId}: problems found in ${resource.id}. Skip or retry (s/r)? `,
189
+ ['s', 'S', 'r', 'R']
190
+ )
191
+
192
+ switch (choice) {
193
+ case 's':
194
+ case 'S': {
195
+ const reason = await prompt('Reason for skipping? ')
196
+ fs.appendFile(this.FILE_PROBLEMS, csv.formatCsv([[
197
+ resource.workId,
198
+ resource.id,
199
+ reason
200
+ ]]))
201
+ console.log(`${resource.workId}: skipping ${resource.id}`)
202
+ break
203
+ }
204
+
205
+ case 'r':
206
+ case 'R': {
207
+ console.log(`${resource.workId}: retrying ${resource.id}`)
208
+ return this.processResources(id, update)
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ amendedResources.push(results)
215
+ }
216
+
217
+ return amendedResources
218
+ }
219
+
220
+ async processResourceText (id: WorkId, update?: boolean): Promise<Resource[]> {
221
+ try {
222
+ console.log(`${id}: generating Darwin Core`)
223
+ const filePath = path.join(this.DIR_TXT, id + '.txt')
224
+ const file = await fs.readFile(filePath, 'utf-8')
225
+
226
+ let old = undefined
227
+ if (update) {
228
+ const dwc = []
229
+ for (const file of await fs.readdir(this.DIR_DWC)) {
230
+ if (file.startsWith(id + '-')) {
231
+ const filePath = path.join(this.DIR_DWC, file)
232
+ dwc.push(csv.parseCsv(await getOldFile(filePath)))
233
+ }
234
+ }
235
+
236
+ old = { txt: await getOldFile(filePath), dwc }
237
+ }
238
+
239
+ const { resources } = await import('../index')
240
+ return resources.parseTextFile(file, id, old)
241
+ } catch (error) {
242
+ console.log(error)
243
+ await prompt(`${id}: generating Darwin Core failed, retry? `)
244
+
245
+ // Clear cache to re-import
246
+ const prefix = path.dirname(require.resolve('../index'))
247
+ for (const file in require.cache) {
248
+ if (file.startsWith(prefix)) {
249
+ delete require.cache[file]
250
+ }
251
+ }
252
+
253
+ return this.processResourceText(id, update)
254
+ }
255
+ }
256
+
257
+ async processResourceDwc (resource: Resource): Promise<[AmendedResource, Classifications]> {
258
+ console.log(`${resource.workId}: matching ${resource.id}`)
259
+ const taxa: Record<string, Record<TaxonId, AmendedTaxon>> = {}
260
+ const names = []
261
+ for (const id in resource.taxa) {
262
+ const name = resource.taxa[id].scientificName
263
+
264
+ if (!taxa[name]) { taxa[name] = {} }
265
+ taxa[name][id] = { ...resource.taxa[id] }
266
+
267
+ names.push(name)
268
+ }
269
+
270
+ const result = await runGnverifier(names.join('\n'))
271
+ const classifications: Classifications = { '1': [], '11': [] }
272
+
273
+ const [header, ...matches] = csv.parseCsv(result)
274
+ for (const match of matches) {
275
+ const name = match[header.indexOf('ScientificName')]
276
+ const source = match[header.indexOf('DataSourceId')]
277
+ const id = match[header.indexOf('TaxonId')]
278
+ const classification = match[header.indexOf('ClassificationPath')]
279
+
280
+ for (const loirId in taxa[name]) {
281
+ const taxon = taxa[name][loirId]
282
+ if (source === '1' && !taxon.colTaxonID) {
283
+ taxon.colTaxonID = id
284
+ classifications[source].push([taxon, classification])
285
+ }
286
+ if (source === '11' && GBIF_RANKS.includes(taxon.taxonRank) && !taxon.gbifTaxonID) {
287
+ taxon.gbifTaxonID = id
288
+ classifications[source].push([taxon, classification])
289
+ }
290
+ }
291
+ }
292
+
293
+ const results = {
294
+ ...resource,
295
+ taxa: Object.fromEntries(Object.values(resource.taxa).map(taxon => [
296
+ taxon.scientificNameID,
297
+ taxa[taxon.scientificName][taxon.scientificNameID]
298
+ ]))
299
+ }
300
+
301
+ return [results, classifications]
302
+ }
303
+
304
+ async checkPrefix (resource: AmendedResource, classifications: Classifications, source: string): Promise<void> {
305
+ const lists = classifications[source]
306
+ if (!lists.length) { return }
307
+ const prefix = lists[0][1].split('|')
308
+
309
+ for (const [taxon, list] of lists.slice(1)) {
310
+ const parts = list.split('|')
311
+ for (let i = 0; i < parts.length; i++) {
312
+ if (parts[i] !== prefix[i] && i < 3) {
313
+ let choice
314
+
315
+ if (source === '1' || taxon.taxonomicStatus !== 'accepted') {
316
+ choice = 'd'
317
+ } else if (VALID_COMMON_PREFIXES.includes(prefix.slice(0, i).join('|'))) {
318
+ choice = 'k'
319
+ } else {
320
+ console.log(`${resource.workId}: source ${source} results in short prefix "${prefix.slice(0, i).join('|')}" (${i} taxa)`)
321
+ console.log(` taxon: ${taxon.scientificNameID} "${taxon.scientificName}"`)
322
+ console.log(` class: ${parts.join('|')}`)
323
+ console.log(` prefx: ${prefix.join('|')}`)
324
+
325
+ choice = await promptForAnswers(` Keep or delete (k/d)? `, ['k', 'K', 'd', 'D'])
326
+ }
327
+
328
+ switch (choice) {
329
+ case 'k':
330
+ case 'K': {
331
+ console.log(` keeping...`)
332
+ break
333
+ }
334
+
335
+ case 'd':
336
+ case 'D': {
337
+ console.log(` deleting...`)
338
+ if (source === '1') {
339
+ delete taxon.colTaxonID
340
+ } else if (source === '11') {
341
+ delete taxon.gbifTaxonID
342
+ }
343
+ break
344
+ }
345
+ }
346
+
347
+ break
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ checkResults (resource: AmendedResource): boolean {
354
+ let correct = true
355
+ const missing = []
356
+
357
+ for (const id in resource.taxa) {
358
+ const taxon = resource.taxa[id]
359
+ if (taxon.taxonomicStatus !== 'accepted') { continue }
360
+
361
+ const missingCol = false // !taxon.colTaxonID
362
+ const missingGbif = GBIF_RANKS.includes(taxon.taxonRank) && !taxon.gbifTaxonID
363
+
364
+ if (missingCol || missingGbif) {
365
+ correct = false
366
+ missing.push(taxon)
367
+ }
368
+ }
369
+
370
+ if (missing.length) {
371
+ console.table(missing, DISPLAY_FIELDS)
372
+ }
373
+
374
+ return correct
375
+ }
376
+
377
+ async shouldBeSkipped (id: ResourceId): Promise<boolean> {
378
+ const problems = csv.parseCsv(await fs.readFile(this.FILE_PROBLEMS, 'utf8'))
379
+ return problems.some(([_work, resource, _problem]) => resource === id)
380
+ }
381
+ }
382
+
383
+ function main (): void {
384
+ const args = util.parseArgs({
385
+ options: {
386
+ update: {
387
+ type: 'boolean',
388
+ short: 'u'
389
+ }
390
+ },
391
+ allowPositionals: true
392
+ })
393
+
394
+ const processor = new ResourceProcessor(args.positionals[0])
395
+ process.on('exit', () => {
396
+ process.stdout.write('\n')
397
+ })
398
+
399
+ const task = args.values.update ? processor.runUpdate() : processor.run()
400
+ task.catch(error => {
401
+ console.error(error)
402
+ process.exit(1)
403
+ })
404
+ }
405
+
406
+ main()
@@ -0,0 +1,74 @@
1
+ import * as readline from 'readline'
2
+ import { spawn } from 'child_process'
3
+
4
+ /**
5
+ * Comparison function to sort strings with numerical components.
6
+ */
7
+ export function numericSort (a: string, b: string): number {
8
+ const as = a.split(/(\d+)/)
9
+ const bs = b.split(/(\d+)/)
10
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
11
+ const ai = as[i]
12
+ const bi = bs[i]
13
+
14
+ if (ai === bi) {
15
+ continue
16
+ } else if (!ai) {
17
+ return 1
18
+ } else if (!bi) {
19
+ return -1
20
+ } else if (i % 2) {
21
+ return parseInt(ai) - parseInt(bi)
22
+ } else {
23
+ return ai > bi ? -1 : 1
24
+ }
25
+ }
26
+ return 0
27
+ }
28
+
29
+ /**
30
+ * Create a CLI prompt and return any answer.
31
+ */
32
+ export function prompt (question: string): Promise<string> {
33
+ const rl = readline.createInterface({
34
+ input: process.stdin,
35
+ output: process.stdout
36
+ })
37
+ return new Promise(resolve => {
38
+ rl.question(question, (answer: string) => {
39
+ rl.close()
40
+ resolve(answer)
41
+ })
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Create a CLI prompt and only accept certain answers.
47
+ */
48
+ export async function promptForAnswers (question: string, answers: string[]) {
49
+ let answer
50
+
51
+ do {
52
+ answer = (await prompt(question))[0]
53
+ } while (!answers.includes(answer))
54
+
55
+ return answer
56
+ }
57
+
58
+ /**
59
+ * Run a command and return stdout.
60
+ */
61
+ export function runCommand (command: string, args: string[], options?: Record<string, unknown>): Promise<string> {
62
+ return new Promise((resolve, reject) => {
63
+ const proc = spawn(command, args, options)
64
+ let stdout = ''
65
+ proc.stdout.on('data', data => { stdout += data })
66
+ proc.on('close', code => {
67
+ if (code === 0) {
68
+ resolve(stdout)
69
+ } else {
70
+ reject()
71
+ }
72
+ })
73
+ })
74
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { promises as fs } from 'fs'
4
+ import * as path from 'path'
5
+ import { catalog } from '../index'
6
+
7
+ async function validateFile (arg: string): Promise<{ filePath: string, errors: WorkError[]}> {
8
+ const filePath = path.resolve(arg)
9
+ const file = await fs.readFile(filePath, 'utf8')
10
+ const sheet = path.basename(filePath, '.csv')
11
+ return {
12
+ filePath,
13
+ errors: catalog.loadData(file, sheet).validate()
14
+ }
15
+ }
16
+
17
+ async function main (args: string[]): Promise<void> {
18
+ let exitStatus = 0
19
+
20
+ const results = await Promise.allSettled(args.map(validateFile))
21
+ for (const result of results) {
22
+ if (result.status === 'rejected') {
23
+ console.error(result.reason)
24
+ console.error()
25
+ exitStatus = 1
26
+ } else if (result.value.errors.length > 0) {
27
+ console.error(`${result.value.filePath}:`)
28
+ console.table(result.value.errors)
29
+ console.error()
30
+ exitStatus = 1
31
+ }
32
+ }
33
+
34
+ process.exit(exitStatus)
35
+ }
36
+
37
+ main(process.argv.slice(2))
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { promises as fs } from 'fs'
4
+ import * as path from 'path'
5
+ import { resources } from '../index'
6
+
7
+ async function main (args: string[]): Promise<void> {
8
+ let exitStatus = 0
9
+
10
+ for (const arg of args) {
11
+ const filePath = path.resolve(arg)
12
+ const file = await fs.readFile(filePath, 'utf8')
13
+ const id = path.basename(filePath, '.txt')
14
+ try {
15
+ resources.parseTextFile(file, id)
16
+ } catch (error) {
17
+ console.error(filePath + '\n ' + error.message + '\n')
18
+ exitStatus = 1
19
+ }
20
+ }
21
+
22
+ process.exit(exitStatus)
23
+ }
24
+
25
+ main(process.argv.slice(2))
@@ -0,0 +1,62 @@
1
+ import { Entity } from './entity'
2
+
3
+ export class Entities {
4
+ entities: Entity[];
5
+ indexField: string;
6
+ index: Record<string, Entity>;
7
+
8
+ constructor (entities: Entity[], indexField: string) {
9
+ this.entities = entities
10
+ this.indexField = indexField
11
+ this.index = {}
12
+ for (const entity of this.entities) {
13
+ const key = entity.get(indexField)
14
+ if (typeof key !== 'string') {
15
+ throw new TypeError('Entity missing index field')
16
+ }
17
+ this.index[key] = entity
18
+ }
19
+ }
20
+
21
+ *[Symbol.iterator] () {
22
+ for (const entity of this.entities) {
23
+ yield entity
24
+ }
25
+ }
26
+
27
+ get (key: string): Entity {
28
+ return this.index[key]
29
+ }
30
+
31
+ has (key: string): boolean {
32
+ return key in this.index
33
+ }
34
+
35
+ validate (): WorkError[] {
36
+ const errors: WorkError[] = []
37
+ for (const entity of this.entities) {
38
+ for (const { field, error } of entity.validate()) {
39
+ errors.push({
40
+ entity: (entity.get(this.indexField) || '[missing]') as string,
41
+ field,
42
+ error
43
+ })
44
+ }
45
+ }
46
+ return errors
47
+ }
48
+
49
+ toTable (): string[][] {
50
+ if (this.entities.length === 0) {
51
+ return [[]]
52
+ }
53
+ const header = Object.keys(this.entities[0].fields)
54
+ const table = this.entities.map((entity: Entity) => {
55
+ return header.map((field: string): string => {
56
+ const value = entity.get(field) || ''
57
+ return Array.isArray(value) ? value.join('; ') : value
58
+ })
59
+ })
60
+ return [header, ...table]
61
+ }
62
+ }