@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.
- package/.eslintrc.js +16 -0
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/lib/bin/process-resources-index.d.ts +1 -0
- package/lib/bin/process-resources-index.js +142 -0
- package/lib/bin/process-resources.d.ts +1 -0
- package/lib/bin/process-resources.js +594 -0
- package/lib/bin/util.d.ts +16 -0
- package/lib/bin/util.js +125 -0
- package/lib/bin/validate-catalog.d.ts +2 -0
- package/lib/bin/validate-catalog.js +92 -0
- package/lib/bin/validate-resources-text.d.ts +2 -0
- package/lib/bin/validate-resources-text.js +78 -0
- package/lib/catalog/entities.d.ts +12 -0
- package/lib/catalog/entities.js +111 -0
- package/lib/catalog/entity.d.ts +13 -0
- package/lib/catalog/entity.js +103 -0
- package/lib/catalog/index.d.ts +4 -0
- package/lib/catalog/index.js +33 -0
- package/lib/catalog/tables/author.d.ts +4 -0
- package/lib/catalog/tables/author.js +33 -0
- package/lib/catalog/tables/index.d.ts +2 -0
- package/lib/catalog/tables/index.js +13 -0
- package/lib/catalog/tables/place.d.ts +4 -0
- package/lib/catalog/tables/place.js +32 -0
- package/lib/catalog/tables/publisher.d.ts +4 -0
- package/lib/catalog/tables/publisher.js +33 -0
- package/lib/catalog/tables/work.d.ts +5 -0
- package/lib/catalog/tables/work.js +82 -0
- package/lib/catalog/value.d.ts +19 -0
- package/lib/catalog/value.js +49 -0
- package/lib/csv.d.ts +2 -0
- package/lib/csv.js +37 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +6 -0
- package/lib/resources/diff-resource.d.ts +7 -0
- package/lib/resources/diff-resource.js +152 -0
- package/lib/resources/index.d.ts +1 -0
- package/lib/resources/index.js +6 -0
- package/lib/resources/parse-text.d.ts +2 -0
- package/lib/resources/parse-text.js +499 -0
- package/lib/types.d.ts +62 -0
- package/lib/types.js +0 -0
- package/package.json +42 -0
- package/src/bin/process-resources-index.ts +73 -0
- package/src/bin/process-resources.ts +406 -0
- package/src/bin/util.ts +74 -0
- package/src/bin/validate-catalog.ts +37 -0
- package/src/bin/validate-resources-text.ts +25 -0
- package/src/catalog/entities.ts +62 -0
- package/src/catalog/entity.ts +113 -0
- package/src/catalog/index.ts +32 -0
- package/src/catalog/tables/author.ts +13 -0
- package/src/catalog/tables/index.ts +12 -0
- package/src/catalog/tables/place.ts +12 -0
- package/src/catalog/tables/publisher.ts +13 -0
- package/src/catalog/tables/work.ts +62 -0
- package/src/catalog/value.ts +48 -0
- package/src/csv.ts +33 -0
- package/src/index.ts +3 -0
- package/src/module.d.ts +105 -0
- package/src/resources/diff-resource.ts +155 -0
- package/src/resources/index.ts +4 -0
- package/src/resources/parse-text.ts +519 -0
- 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()
|
package/src/bin/util.ts
ADDED
|
@@ -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
|
+
}
|