@larsgw/formica 0.7.3 → 0.8.1

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