@larsgw/formica 0.7.2 → 0.8.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 (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/eslint.config.js +34 -0
  3. package/lib/bin/SHEETS.js +0 -0
  4. package/lib/bin/generate-linked-data.d.ts +2 -0
  5. package/lib/bin/generate-linked-data.js +740 -0
  6. package/lib/bin/process-resources-index.js +36 -3
  7. package/lib/bin/process-resources.js +40 -7
  8. package/lib/bin/util.js +40 -8
  9. package/lib/bin/validate-catalog.js +36 -3
  10. package/lib/bin/validate-resources-text.js +36 -3
  11. package/lib/catalog/entities.js +2 -2
  12. package/lib/catalog/entity.js +0 -2
  13. package/lib/catalog/index.js +3 -3
  14. package/lib/catalog/tables/author.js +5 -3
  15. package/lib/catalog/tables/index.js +4 -4
  16. package/lib/catalog/tables/place.js +3 -1
  17. package/lib/catalog/tables/publisher.js +5 -3
  18. package/lib/catalog/tables/taxon.js +3 -1
  19. package/lib/catalog/tables/work.js +5 -5
  20. package/lib/catalog/value.d.ts +6 -2
  21. package/lib/catalog/value.js +14 -9
  22. package/lib/csv.js +2 -3
  23. package/lib/index.js +37 -4
  24. package/lib/resources/diff-resource.js +5 -4
  25. package/lib/resources/parse-text.js +45 -12
  26. package/lib/taxon-names/index.js +2 -3
  27. package/package.json +13 -7
  28. package/src/bin/generate-linked-data.ts +709 -0
  29. package/src/bin/process-resources-index.ts +1 -1
  30. package/src/catalog/entity.ts +0 -2
  31. package/src/catalog/tables/author.ts +5 -3
  32. package/src/catalog/tables/index.ts +4 -4
  33. package/src/catalog/tables/place.ts +3 -1
  34. package/src/catalog/tables/publisher.ts +5 -3
  35. package/src/catalog/tables/taxon.ts +3 -1
  36. package/src/catalog/tables/work.ts +5 -5
  37. package/src/catalog/value.ts +7 -5
  38. package/src/module.d.ts +5 -0
  39. package/src/resources/diff-resource.ts +2 -1
  40. package/src/resources/parse-text.ts +1 -0
  41. package/tsconfig.json +2 -1
  42. package/.eslintrc.js +0 -16
  43. package/src/bin/clean-links.ts +0 -96
@@ -0,0 +1,709 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync as doesFileExist, promises as fs } from 'fs'
4
+ import * as path from 'path'
5
+ import * as util from 'util'
6
+
7
+ import type { JsonLdDocument, NodeObject } from 'jsonld'
8
+ import * as jsonld from 'jsonld'
9
+ import * as N3 from 'n3'
10
+
11
+ import { catalog } from '../index'
12
+ import { WorkResource } from '../resources/resource'
13
+ import { parseCsv } from '../csv'
14
+
15
+ const SHEETS = ['catalog', 'authors', 'places', 'publishers', 'taxa'] as const
16
+
17
+ interface Resource {
18
+ metadata: ResourceMetadata,
19
+ taxa: AmendedTaxon[],
20
+ }
21
+
22
+ interface Catalog {
23
+ catalog: catalog.Entities,
24
+ authors: catalog.Entities,
25
+ places: catalog.Entities,
26
+ publishers: catalog.Entities,
27
+ taxa: catalog.Entities,
28
+
29
+ resources: Record<ResourceId, Resource>,
30
+ }
31
+
32
+ const PREFIX = 'https://purl.org/identification-resources/'
33
+ const HANDLE_PREFIX = 'https://hdl.handle.net/'
34
+ const SCOPES: Record<string, [string, string]> = {
35
+ // animal life stage
36
+ 'adults': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/adult'],
37
+ 'pupae': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/pupa'],
38
+ 'juveniles': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/juvenile'],
39
+ 'subimagos': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/juvenile'],
40
+ 'larvae': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
41
+ 'larvae (instar V)': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
42
+ 'larvae (instar IV)': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
43
+ 'larvae (instar III)': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
44
+ 'larvae (instar I)': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
45
+ 'nymphs': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
46
+ 'nypmhs': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
47
+ 'nymphs (instar V)': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/larva'],
48
+ 'eggs': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/embryo'],
49
+
50
+ // plant life stage
51
+ 'flowering plants': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/adult'],
52
+ 'fruiting plants': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/adult'],
53
+ 'without sporangia': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/juvenile'],
54
+ 'with sporangia': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/adult'],
55
+ 'teleomorphs': ['dwciri:lifeStage', 'http://rs.gbif.org/vocabulary/gbif/life_stage/adult'],
56
+
57
+ // sex
58
+ 'females': ['dwciri:sex', 'http://rs.gbif.org/vocabulary/gbif/sex/female'],
59
+ 'males': ['dwciri:sex', 'http://rs.gbif.org/vocabulary/gbif/sex/male'],
60
+
61
+ // caste
62
+ 'queens': ['dwc:caste', 'queen'],
63
+ 'workers': ['dwc:caste', 'worker'],
64
+ 'soldiers': ['dwc:caste', 'soldier'],
65
+ 'alates': ['dwc:caste', 'alate'],
66
+ 'alatae': ['dwc:caste', 'alate'],
67
+ 'apterae': ['dwc:caste', 'aptera'],
68
+ 'viviparae': ['dwc:caste', 'vivipara'],
69
+
70
+ // evidence
71
+ 'nests': ['ac:subjectPartLiteral', 'nest'],
72
+ 'galls': ['ac:subjectPartLiteral', 'gall'],
73
+ 'puparia': ['ac:subjectPartLiteral', 'puparium'],
74
+ 'eggcases': ['ac:subjectPart', 'http://rs.tdwg.org/acpart/values/p0031'],
75
+ 'bones': ['ac:subjectPartLiteral', 'skeleton'],
76
+ 'bones (skulls)': ['ac:subjectPart', 'http://rs.tdwg.org/acpart/values/p0027'],
77
+ 'bones (upper jaws)': ['ac:subjectPart', 'http://rs.tdwg.org/acpart/values/p0028'],
78
+ 'bones (lower jaws)': ['ac:subjectPart', 'http://rs.tdwg.org/acpart/values/p0029'],
79
+ }
80
+ const PREFIXES = {
81
+ 'ac': 'http://rs.tdwg.org/ac/terms/',
82
+ 'bibo': 'http://purl.org/ontology/bibo/',
83
+ 'dcterms': 'http://purl.org/dc/terms/',
84
+ 'dwc': 'http://rs.tdwg.org/dwc/terms/',
85
+ 'dwciri': 'http://rs.tdwg.org/dwc/iri/',
86
+ 'foaf': 'http://xmlns.com/foaf/0.1/',
87
+ 'owl': 'http://www.w3.org/2002/07/owl#',
88
+ 'schema': 'https://schema.org/',
89
+ }
90
+ const DWC_FIELDS: Record<string, string> = {
91
+ scientificName: 'dwc:scientificName',
92
+ scientificNameAuthorship: 'dwc:scientificNameAuthorship',
93
+ genericName: 'dwc:genericName',
94
+ infragenericEpithet: 'dwc:infragenericEpithet',
95
+ specificEpithet: 'dwc:specificEpithet',
96
+ infraspecificEpithet: 'dwc:infraspecificEpithet',
97
+ taxonRank: 'dwc:taxonRank',
98
+ taxonRemarks: 'dwc:taxonRemarks',
99
+ taxonomicStatus: 'dwc:taxonomicStatus',
100
+ verbatimIdentification: 'dwc:verbatimIdentification',
101
+ }
102
+ const GBIF_RANKS = ['kingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'subspecies']
103
+
104
+ function getCoveringTaxon (taxa: catalog.Entity[]): string|null {
105
+ if (taxa.length === 1 && !taxa[0].has('gbif')) {
106
+ return `${PREFIX}taxon/${taxa[0].get('id')}`
107
+ }
108
+
109
+ function getAncestors (taxon: catalog.Entity): string[] {
110
+ const [...parents] = (taxon.get('parent_taxa') ?? []) as string[]
111
+ if (taxon.has('gbif')) {
112
+ parents.push(taxon.get('gbif') as string)
113
+ }
114
+ return parents
115
+ }
116
+
117
+ const ancestors = getAncestors(taxa[0])
118
+ for (const taxon of taxa.slice(1)) {
119
+ const otherAncestors = getAncestors(taxon)
120
+
121
+ for (let i = 0; i < ancestors.length; i++) {
122
+ if (ancestors[i] !== otherAncestors[i]) {
123
+ ancestors.splice(i)
124
+ }
125
+ }
126
+ }
127
+
128
+ return ancestors.length ? makeGbifUri(ancestors[ancestors.length - 1])['@id'] as string : null
129
+ }
130
+
131
+ function mapEntities (names: string[], entities: catalog.Entity[]): catalog.Entity[] {
132
+ const result = []
133
+
134
+ for (const name of names) {
135
+ const entity = entities.find(entity => {
136
+ const values = entity.get('name')
137
+ return Array.isArray(values) ? values.includes(name) : values === name
138
+ })
139
+
140
+ if (!entity) {
141
+ console.error('Unmapped entity:', name)
142
+ continue
143
+ }
144
+
145
+ result.push(entity as catalog.Entity)
146
+ }
147
+
148
+ return result
149
+ }
150
+
151
+ function makeWikidataUri (qid: string): NodeObject {
152
+ return { '@id': `http://www.wikidata.org/entity/${qid}` }
153
+ }
154
+
155
+ function makeGbifUri (id: string): NodeObject {
156
+ return { '@id': `https://gbif.org/species/${id}` }
157
+ }
158
+
159
+ function makeWorkUri (id: string): NodeObject {
160
+ return { '@id': `${PREFIX}catalog/${id}` }
161
+ }
162
+
163
+ function makeScientificNameUri (id: string): NodeObject {
164
+ const resource = id.split(':').slice(0, -1).join(':')
165
+ return { '@id': `${PREFIX}resource/${resource}#${id}` }
166
+ }
167
+
168
+ function makeLinkedDataForAuthor (author: catalog.Entity): NodeObject {
169
+ const node: NodeObject = {
170
+ '@id': `${PREFIX}author/${author.get('id')}`,
171
+ '@type': 'foaf:Person',
172
+ 'foaf:name': author.get('display_name')
173
+ }
174
+
175
+ if (author.has('qid')) {
176
+ node['owl:sameAs'] = makeWikidataUri(author.get('qid') as string)
177
+ }
178
+
179
+ return node
180
+ }
181
+
182
+ function makeLinkedDataForPlace (place: catalog.Entity): NodeObject {
183
+ const node: NodeObject = {
184
+ '@id': `${PREFIX}place/${place.get('id')}`,
185
+ '@type': 'dcterms:Location',
186
+ 'dcterms:title': place.get('display_name'),
187
+ }
188
+
189
+ if (place.has('qid')) {
190
+ node['owl:sameAs'] = makeWikidataUri(place.get('qid') as string)
191
+ }
192
+
193
+ return node
194
+ }
195
+
196
+ function makeLinkedDataForPublisher (publisher: catalog.Entity): NodeObject {
197
+ const node: NodeObject = {
198
+ '@id': `${PREFIX}author/${publisher.get('id')}`,
199
+ '@type': 'foaf:Organization',
200
+ 'foaf:name': publisher.get('display_name')
201
+ }
202
+
203
+ if (publisher.has('qid')) {
204
+ node['owl:sameAs'] = makeWikidataUri(publisher.get('qid') as string)
205
+ }
206
+
207
+ return node
208
+ }
209
+
210
+ function makeLinkedDataForTaxon (taxon: catalog.Entity): NodeObject {
211
+ const node: NodeObject = {
212
+ '@id': `${PREFIX}taxon/${taxon.get('id')}`,
213
+ '@type': 'dwc:Taxon',
214
+ 'dwc:scientificName': taxon.get('display_name'),
215
+ }
216
+
217
+ if (taxon.has('rank')) {
218
+ node['dwc:taxonRank'] = taxon.get('rank')
219
+ }
220
+
221
+ const ids = []
222
+ if (taxon.has('qid')) {
223
+ ids.push(makeWikidataUri(taxon.get('qid') as string))
224
+ }
225
+ if (taxon.has('gbif')) {
226
+ ids.push(makeGbifUri(taxon.get('gbif') as string))
227
+ }
228
+ if (ids.length) {
229
+ node['owl:sameAs'] = ids
230
+ }
231
+
232
+ return node
233
+ }
234
+
235
+ function makeLinkedDataForTaxa (files: Catalog): NodeObject[] {
236
+ const nodes = []
237
+ const gbifTaxa: Record<string, NodeObject> = {}
238
+
239
+ for (const taxon of files.taxa.entities) {
240
+ const node = makeLinkedDataForTaxon(taxon)
241
+
242
+ const ancestors = taxon.get('ancestors_gbif') ?? []
243
+ if (ancestors.length) {
244
+ node['dwc:parentNameUsageID'] = makeGbifUri(ancestors[ancestors.length - 1])
245
+ } else if (taxon.get('id') !== 'T141') {
246
+ node['dwc:parentNameUsageID'] = { '@id': `${PREFIX}taxon/T141` }
247
+ }
248
+
249
+ for (let i = 0; i < ancestors.length; i++) {
250
+ gbifTaxa[ancestors[i]] = {
251
+ ...makeGbifUri(ancestors[i]),
252
+ 'dwc:taxonRank': GBIF_RANKS[i],
253
+ 'dwc:parentNameUsageID': i ? makeGbifUri(ancestors[i - 1]) : { '@id': `${PREFIX}taxon/T141` }
254
+ }
255
+ }
256
+
257
+ if (taxon.has('children_gbif')) {
258
+ const children = taxon.get('children_gbif') as string[]
259
+ node['@reverse'] = { 'dwc:parentNameUsageID': children.map(makeGbifUri) as unknown as string }
260
+
261
+ const childRank = GBIF_RANKS[ancestors.length]
262
+ for (const child of children) {
263
+ gbifTaxa[child] = {
264
+ ...makeGbifUri(child),
265
+ 'dwc:taxonRank': childRank,
266
+ }
267
+ }
268
+ }
269
+
270
+ nodes.push(node)
271
+ }
272
+
273
+ nodes.push(...Object.values(gbifTaxa))
274
+
275
+ return nodes
276
+ }
277
+
278
+ function makeLinkedDataForScientificName (name: AmendedTaxon): NodeObject {
279
+ const node: NodeObject = {
280
+ ...makeScientificNameUri(name.scientificNameID),
281
+ '@type': 'dwc:Taxon',
282
+ }
283
+
284
+ for (const field in DWC_FIELDS) {
285
+ const value = name[field as keyof AmendedTaxon]
286
+ if (value) {
287
+ node[DWC_FIELDS[field]] = value as string
288
+ }
289
+ }
290
+
291
+ if (name.acceptedNameUsageID) {
292
+ node['dwc:acceptedNameUsageID'] = makeScientificNameUri(name.acceptedNameUsageID)
293
+ }
294
+
295
+ if (name.parentNameUsageID) {
296
+ node['dwc:parentNameUsageID'] = makeScientificNameUri(name.parentNameUsageID)
297
+ }
298
+
299
+ const identifiers = []
300
+ if (name.gbifTaxonID) {
301
+ identifiers.push(makeGbifUri(name.gbifTaxonID))
302
+ }
303
+ if (name.colTaxonID) {
304
+ identifiers.push({ '@id': `https://www.checklistbank.org/dataset/309120/taxon/${name.colTaxonID}` })
305
+ }
306
+
307
+ if (identifiers.length) {
308
+ node['dwc:taxonID'] = identifiers
309
+ }
310
+
311
+ return node
312
+ }
313
+
314
+ function makeLinkedDataForResource (work: catalog.Entity, files: Catalog, resourceId?: ResourceId): NodeObject {
315
+ const resource = new WorkResource({})
316
+
317
+ if (!resourceId) {
318
+ resourceId = `${work.get('id')}:0`
319
+ }
320
+
321
+ if (files.resources[resourceId] && files.resources[resourceId].metadata.catalog) {
322
+ resource.fields = files.resources[resourceId].metadata.catalog as Record<string, Value>
323
+ }
324
+
325
+ if (!resource.has('language')) {
326
+ resource.fields.language = work.get('language') as Value
327
+ }
328
+
329
+ const node: NodeObject = {
330
+ ...makeLinkedDataForWork(resource, files),
331
+ '@id': `${PREFIX}resource/${resourceId}`,
332
+ '@type': 'bibo:DocumentPart',
333
+ }
334
+
335
+ const types = resource.get('key_type') ?? work.get('key_type') ?? []
336
+
337
+ if (types.includes('matrix')) {
338
+ node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Software' }
339
+ } else if (types.includes('key') || types.includes('reference') || types.includes('supplement')) {
340
+ node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Text' }
341
+ } else if (types.includes('gallery') || types.includes('collection')) {
342
+ node['dcterms:type'] = { '@id': 'http://purl.org/dc/dcmitype/Collection' }
343
+ }
344
+
345
+ if (types.includes('key') || types.includes('matrix')) {
346
+ node['ac:subtype'] = { '@id': 'http://rs.tdwg.org/acsubtype/values/IdentificationKey' }
347
+ }
348
+
349
+ const taxonNames = resource.get('taxon') ?? work.get('taxon')
350
+ if (taxonNames) {
351
+ const taxa = mapEntities(taxonNames as string[], files.taxa.entities)
352
+ const coveringTaxon = taxa.length ? getCoveringTaxon(taxa) : null
353
+ if (coveringTaxon !== null) {
354
+ node['ac:taxonCoverage'] = { '@id': coveringTaxon }
355
+ }
356
+
357
+ node['dwc:taxonID'] = taxa.map(taxon => ({ '@id': `${PREFIX}taxon/${taxon.get('id')}` }))
358
+ }
359
+
360
+ const scopes = resource.get('scope') ?? work.get('scope')
361
+ if (scopes) {
362
+ for (const scope of scopes as string[]) {
363
+ if (!SCOPES[scope]) {
364
+ console.error('Unmapped scope:', scope)
365
+ continue
366
+ }
367
+
368
+ const [property, ...values] = SCOPES[scope]
369
+
370
+ if (!Array.isArray(node[property])) {
371
+ node[property] = []
372
+ }
373
+
374
+ for (const value of values) {
375
+ if (value.startsWith('http')) {
376
+ (node[property] as unknown[]).push({ '@id': value })
377
+ } else {
378
+ (node[property] as unknown[]).push(value)
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ const region = resource.get('region') ?? work.get('region')
385
+ if (region) {
386
+ const places = mapEntities(region as string[], files.places.entities).map(place => ({ '@id': `${PREFIX}place/${place.get('id')}` }))
387
+ node['dcterms:spatial'] = places
388
+ }
389
+
390
+ const tags: string[] = []
391
+
392
+ const taxonScopes = resource.get('taxon_scope') ?? work.get('taxon_scope')
393
+ if (taxonScopes) {
394
+ tags.push(...taxonScopes as string[])
395
+ } else if ((resource.get('complete') ?? work.get('complete')) === 'FALSE') {
396
+ tags.push('not intended to be complete')
397
+ }
398
+
399
+ const targetTaxa = resource.get('target_taxa') ?? work.get('target_taxa')
400
+ if (targetTaxa) {
401
+ const first = (targetTaxa as string[]).slice(0, -1).map(rank => rank + ',')
402
+ const last = targetTaxa[targetTaxa.length - 1]
403
+ const list = first.length ? first.join('') + ' or ' + last : last
404
+ tags.push(`for identification to ${list}`)
405
+ }
406
+
407
+ if (tags.length) {
408
+ node['ac:tag'] = tags
409
+ }
410
+
411
+ if (files.resources[resourceId].taxa) {
412
+ const leafs = new Set(files.resources[resourceId].taxa.map(taxon => taxon.scientificNameID))
413
+ const taxa = []
414
+ for (const taxon of files.resources[resourceId].taxa) {
415
+ taxa.push(makeLinkedDataForScientificName(taxon))
416
+
417
+ if (taxon.parentNameUsageID) {
418
+ leafs.delete(taxon.parentNameUsageID)
419
+ }
420
+ }
421
+
422
+ node['dcterms:subject'] = taxa
423
+ node['ac:taxonCount'] = leafs.size
424
+ }
425
+
426
+ return node
427
+ }
428
+
429
+ function makeLinkedDataForWork (work: catalog.Entity, files: Catalog): NodeObject {
430
+ const id = work.get('id') as string
431
+ const node: NodeObject = makeWorkUri(id)
432
+
433
+ const languages = work.get('language') as string[]
434
+ node['dcterms:language'] = languages.map(language => ({ '@id': `http://id.loc.gov/vocabulary/iso639-1/${language}` }))
435
+
436
+ if (work.has('title')) {
437
+ const title = work.get('title') as string[]
438
+ node['dcterms:title'] = title.map((value, i) => ({ '@value': value, '@lang': languages[i] }))
439
+ }
440
+
441
+ if (work.has('pages') && (work.get('pages') as string).includes('-')) {
442
+ const containers = work.has('part_of') ? (work.get('part_of') as string[]).map(id => files.catalog.get(id)) : []
443
+ if (containers.find(container => container.get('entry_type') === 'online')) {
444
+ node['@type'] = 'bibo:BookSection'
445
+ } else {
446
+ node['@type'] = 'bibo:AcademicArticle'
447
+ }
448
+ } else if (work.get('entry_type') === 'online') {
449
+ node['@type'] = 'bibo:Website'
450
+ } else {
451
+ node['@type'] = 'bibo:Book'
452
+ }
453
+
454
+ if (work.has('author')) {
455
+ const authors = mapEntities(work.get('author') as string[], files.authors.entities).map(author => ({ '@id': `${PREFIX}author/${author.get('id')}` }))
456
+ node['bibo:authorList'] = { '@list': authors }
457
+ node['dcterms:creator'] = authors
458
+ }
459
+
460
+ if (work.has('url')) {
461
+ const urls = work.get('url') as string[]
462
+
463
+ const handle = urls.find(url => url.startsWith(HANDLE_PREFIX))
464
+ if (handle) {
465
+ node['bibo:handle'] = handle.slice(HANDLE_PREFIX.length)
466
+ }
467
+
468
+ node['schema:url'] = urls.map(url => ({ '@id': url }))
469
+ }
470
+
471
+ if (work.has('fulltext_url')) {
472
+ const urls = work.get('fulltext_url') as string[]
473
+
474
+ node['schema:encoding'] = urls.map(url => ({ '@type': 'schema:MediaObject', 'schema:contentUrl': { '@id': url } }))
475
+ }
476
+
477
+ if (work.has('archive_url')) {
478
+ node['schema:archivedAt'] = (work.get('archive_url') as string[]).map(url => ({ '@id': url }))
479
+ }
480
+
481
+ if (work.has('date')) {
482
+ node['dcterms:issued'] = work.get('date')
483
+ }
484
+
485
+ if (work.has('publisher')) {
486
+ const publishers = mapEntities(work.get('publisher') as string[], files.publishers.entities).map(publisher => ({ '@id': `${PREFIX}publisher/${publisher.get('id')}` }))
487
+ node['dcterms:publisher'] = publishers
488
+ }
489
+
490
+ if (work.has('ISSN')) {
491
+ node['bibo:issn'] = work.get('ISSN')
492
+ }
493
+
494
+ if (work.has('ISBN')) {
495
+ const isbns = work.get('ISBN') as string[]
496
+
497
+ for (const isbn of isbns) {
498
+ if (isbn.length === 13) {
499
+ node['bibo:isbn13'] = isbn
500
+ } else if (isbn.length === 10) {
501
+ node['bibo:isbn10'] = isbn
502
+ } else {
503
+ node['bibo:isbn'] = isbn
504
+ }
505
+ }
506
+ }
507
+
508
+ if (work.has('QID')) {
509
+ node['bibo:uri'] = makeWikidataUri(work.get('QID') as string)
510
+ }
511
+
512
+ if (work.has('DOI')) {
513
+ node['bibo:doi'] = work.get('DOI')
514
+ }
515
+
516
+ if (work.has('volume')) {
517
+ node['bibo:volume'] = work.get('volume')
518
+ }
519
+
520
+ if (work.has('issue')) {
521
+ node['bibo:issue'] = work.get('issue')
522
+ }
523
+
524
+ if (work.has('pages')) {
525
+ const pages = work.get('pages') as string
526
+ const range = pages.split('-')
527
+
528
+ if (pages.match(/^\d+$/)) {
529
+ node['bibo:numPages'] = parseInt(pages)
530
+ } else if (range.length === 2) {
531
+ node['bibo:pages'] = pages
532
+
533
+ const [start, end] = range.map(part => parseInt(part))
534
+ node['bibo:pageStart'] = start
535
+ node['bibo:pageEnd'] = end
536
+ node['bibo:numPages'] = end - start + 1
537
+ } else {
538
+ node['bibo:pages'] = pages
539
+ }
540
+ }
541
+
542
+ if (work.has('edition')) {
543
+ node['bibo:edition'] = work.get('edition')
544
+ }
545
+
546
+ if (work.has('license')) {
547
+ const licenses = work.get('license') as string[]
548
+ node['dcterms:rights'] = licenses.map(license => {
549
+ if (license === '<public domain>' || license.match(/^<.+\?>$/)) {
550
+ return license.slice(1, -1)
551
+ } else {
552
+ return { '@id': `https://spdx.org/licenses/${license}.html` }
553
+ }
554
+ })
555
+ }
556
+
557
+ return node
558
+ }
559
+
560
+ function makeLinkedDataForWorks (files: Catalog): NodeObject[] {
561
+ const nodes: Record<string, NodeObject> = {}
562
+ const works = files.catalog
563
+
564
+ for (const work of works.entities) {
565
+ nodes[work.get('id') as string] = makeLinkedDataForWork(work, files)
566
+ }
567
+
568
+ for (const work of works.entities) {
569
+ const id = work.get('id') as string
570
+ const node = nodes[id]
571
+ const language = work.get('language') as string[]
572
+
573
+ if (work.has('part_of')) {
574
+ const containers = work.get('part_of') as string[]
575
+
576
+ if ((work.get('key_type') as string[]).includes('supplement')) {
577
+ node['bibo:annotates'] = containers.map(makeWorkUri)
578
+ } else {
579
+ node['dcterms:isPartOf'] = containers.map(makeWorkUri)
580
+
581
+ for (const container of containers) {
582
+ if (!Array.isArray(nodes[container]['dcterms:hasPart'])) {
583
+ nodes[container]['dcterms:hasPart'] = []
584
+ }
585
+ (nodes[container]['dcterms:hasPart'] as NodeObject[]).push(makeWorkUri(id))
586
+ }
587
+ }
588
+ }
589
+
590
+ if (work.has('listed_in')) {
591
+ const referers = work.get('listed_in') as string[]
592
+ node['bibo:citedBy'] = node['dcterms:isReferencedBy'] = referers.map(makeWorkUri)
593
+
594
+ for (const referer of referers) {
595
+ nodes[referer]['bibo:cites'] = nodes[referer]['dcterms:references'] = makeWorkUri(id)
596
+ }
597
+ }
598
+
599
+ if (work.has('version_of')) {
600
+ const originals = work.get('version_of') as string[]
601
+ const originalLanguages = originals.map(id => ((works.get(id) as catalog.Entity).get('language') as string[]).join())
602
+ node['bibo:translationOf'] = originals.filter((_, i) => originalLanguages[i] !== language.join()).map(makeWorkUri)
603
+ node['dcterms:isVersionOf'] = originals.map(makeWorkUri)
604
+ }
605
+
606
+ let resourceIndex = 1
607
+ let resourceId
608
+ while ((resourceId = `${id}:${resourceIndex++}`) in files.resources) {
609
+ const resource = makeLinkedDataForResource(work, files, resourceId)
610
+
611
+ if (!Array.isArray(node['dcterms:hasPart'])) {
612
+ node['dcterms:hasPart'] = []
613
+ }
614
+ (node['dcterms:hasPart'] as NodeObject[]).push(resource)
615
+
616
+ if (!Array.isArray(resource['dcterms:isPartof'])) {
617
+ resource['dcterms:isPartof'] = []
618
+ }
619
+ (resource['dcterms:isPartof'] as NodeObject[]).push({ '@id': node['@id'] })
620
+ }
621
+ }
622
+
623
+ return Object.values(nodes)
624
+ }
625
+
626
+ async function writeOutput (document: JsonLdDocument, format = 'jsonld'): Promise<void> {
627
+ if (format === 'jsonld') {
628
+ process.stdout.write(JSON.stringify(document, null, 2))
629
+ return
630
+ }
631
+
632
+ const nquads = await jsonld.toRDF(document, { format: 'application/n-quads' }) as string
633
+ if (format === 'nquads') {
634
+ process.stdout.write(nquads)
635
+ return
636
+ }
637
+
638
+ const parser = new N3.StreamParser()
639
+ const writer = new N3.StreamWriter({ prefixes: PREFIXES })
640
+ parser.write(nquads)
641
+ parser.pipe(writer)
642
+ writer.pipe(process.stdout)
643
+ }
644
+
645
+ async function main (): Promise<void> {
646
+ const args = util.parseArgs({
647
+ allowPositionals: true,
648
+ options: {
649
+ format: {
650
+ type: 'string',
651
+ short: 'f',
652
+ default: 'jsonld',
653
+ }
654
+ },
655
+ })
656
+ const directory = path.resolve(args.positionals[0])
657
+
658
+ const files = {} as Catalog
659
+ for (const sheet of SHEETS) {
660
+ const filePath = path.join(directory, `${sheet}.csv`)
661
+ if (!doesFileExist(filePath)) {
662
+ throw new Error(`File "${sheet}.csv" must be provided`)
663
+ }
664
+
665
+ const file = await fs.readFile(filePath, 'utf8')
666
+ files[sheet] = catalog.loadData(file, sheet)
667
+ }
668
+
669
+ files.resources = {}
670
+ const resources = JSON.parse(await fs.readFile(path.join(directory, 'resources', 'index.json'), 'utf8'))
671
+ for (const id in resources) {
672
+ const filePath = path.join(directory, 'resources', 'dwc', id.split(':').join('-') + '.csv')
673
+ const file = await fs.readFile(filePath, 'utf8')
674
+ const [header, ...rows] = parseCsv(file)
675
+ const taxa = rows.map(row => row.reduce((object, value, index) => {
676
+ object[header[index]] = value
677
+ return object
678
+ }, {} as Record<string, string>) as unknown as AmendedTaxon)
679
+
680
+ files.resources[id] = { metadata: resources[id], taxa }
681
+ }
682
+
683
+ const graph: NodeObject[] = [
684
+ ...makeLinkedDataForWorks(files),
685
+ ...makeLinkedDataForTaxa(files),
686
+ ]
687
+
688
+ for (const entity of files.authors.entities) {
689
+ graph.push(makeLinkedDataForAuthor(entity))
690
+ }
691
+ for (const entity of files.places.entities) {
692
+ graph.push(makeLinkedDataForPlace(entity))
693
+ }
694
+ for (const entity of files.publishers.entities) {
695
+ graph.push(makeLinkedDataForPublisher(entity))
696
+ }
697
+
698
+ const document: JsonLdDocument = {
699
+ '@context': PREFIXES,
700
+ '@graph': graph
701
+ }
702
+
703
+ writeOutput(document, args.values.format)
704
+ }
705
+
706
+ main().catch((error: Error) => {
707
+ console.error(error)
708
+ process.exit(1)
709
+ })