fhirsmith 0.18.1 → 1.0.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/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/ips/ips.js
CHANGED
|
@@ -43,6 +43,7 @@ const express = require('express');
|
|
|
43
43
|
const path = require('path');
|
|
44
44
|
const fs = require('fs');
|
|
45
45
|
const crypto = require('crypto');
|
|
46
|
+
const scripts = require('./scripts');
|
|
46
47
|
const rateLimit = require('express-rate-limit');
|
|
47
48
|
const escape = require('escape-html');
|
|
48
49
|
const session = require('express-session');
|
|
@@ -60,6 +61,9 @@ const { parseSearch, listQueryToSearch, capabilitySearchParams, compositionOf, p
|
|
|
60
61
|
const render = require('./render');
|
|
61
62
|
const { TxClient } = require('./tx-client');
|
|
62
63
|
const { STEPS, stripChecks } = require('./pipeline');
|
|
64
|
+
|
|
65
|
+
// the extension definitions remembered from the validator (the analysis step)
|
|
66
|
+
const MAX_CACHED_EXTENSIONS = 5000;
|
|
63
67
|
const { IpsValueSets } = require('./ips-valuesets');
|
|
64
68
|
const aiClient = require('./ai-client');
|
|
65
69
|
const { ShlClient } = require('./share');
|
|
@@ -79,7 +83,35 @@ const TX_STARTUP_GRACE_MS = 5 * 60 * 1000;
|
|
|
79
83
|
const FHIR_JSON = 'application/fhir+json; charset=utf-8';
|
|
80
84
|
const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
|
81
85
|
// paths at the root that can't be project codes
|
|
82
|
-
const
|
|
86
|
+
const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
|
|
87
|
+
const { SNOMED_LINKABLE_EDITIONS } = require('../tx/sct/editions');
|
|
88
|
+
const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What's wrong with a national profile's details, or null: a jurisdiction (a country's ISO 3166
|
|
92
|
+
* two-letter code, or a region's UN M49 three-digit code - see jurisdictions.js), a FHIR package id (lower case, at
|
|
93
|
+
* least two parts separated by dots), a version (as package versions are written), the
|
|
94
|
+
* canonical URL (http or https) of the IG's profile on Bundle, and - optionally - the SNOMED CT
|
|
95
|
+
* edition the IG takes for granted, by its module id (one of SNOMED_LINKABLE_EDITIONS).
|
|
96
|
+
*/
|
|
97
|
+
function nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition) {
|
|
98
|
+
if (!jurisdictionOf(jurisdiction)) {
|
|
99
|
+
return 'The jurisdiction must be a country, by its ISO 3166 two-letter code (e.g. AU), or a region, by its UN M49 three-digit code (e.g. 150 for Europe)';
|
|
100
|
+
}
|
|
101
|
+
if (packageId.length > 128 || !/^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$/.test(packageId)) {
|
|
102
|
+
return 'The package id must be a FHIR package id: lower case letters, digits, - and _, in two or more parts separated by dots (e.g. hl7.fhir.au.ips)';
|
|
103
|
+
}
|
|
104
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version)) {
|
|
105
|
+
return 'The version must be a package version (e.g. 1.0.0, or 1.1.0-ballot)';
|
|
106
|
+
}
|
|
107
|
+
if (url.length > 1024 || !/^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/.test(url)) {
|
|
108
|
+
return 'The profile must be the canonical URL (http or https) of the IG\'s profile on Bundle';
|
|
109
|
+
}
|
|
110
|
+
if (snomedEdition && !SNOMED_LINKABLE_EDITIONS.has(snomedEdition)) {
|
|
111
|
+
return 'The SNOMED CT edition must be one of the editions offered (or none)';
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
83
115
|
|
|
84
116
|
/**
|
|
85
117
|
* What's wrong with a submitted document, if anything.
|
|
@@ -165,25 +197,6 @@ function preferValue(req, name) {
|
|
|
165
197
|
return null;
|
|
166
198
|
}
|
|
167
199
|
|
|
168
|
-
// the browser scripts: the module's own files, read once and served from memory (so no
|
|
169
|
-
// request reaches the file system), with an ETag so a browser can keep its copy
|
|
170
|
-
const SCRIPTS = new Map();
|
|
171
|
-
function script(name) {
|
|
172
|
-
if (!SCRIPTS.has(name)) {
|
|
173
|
-
const body = fs.readFileSync(path.join(__dirname, name), 'utf8');
|
|
174
|
-
SCRIPTS.set(name, { body, etag: `"${crypto.createHash('sha256').update(body).digest('base64url').substring(0, 27)}"` });
|
|
175
|
-
}
|
|
176
|
-
const s = SCRIPTS.get(name);
|
|
177
|
-
return (req, res) => {
|
|
178
|
-
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
179
|
-
res.setHeader('ETag', s.etag);
|
|
180
|
-
if (req.get('If-None-Match') === s.etag) {
|
|
181
|
-
return res.status(304).end();
|
|
182
|
-
}
|
|
183
|
-
res.type('application/javascript').send(s.body);
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
200
|
function wantsHtml(req) {
|
|
188
201
|
const f = req.query._format;
|
|
189
202
|
if (typeof f === 'string' && f !== '') {
|
|
@@ -308,6 +321,7 @@ class IpsModule {
|
|
|
308
321
|
this.setupSession();
|
|
309
322
|
this.setupRootRoutes();
|
|
310
323
|
this.setupProfileRoutes();
|
|
324
|
+
this.setupNationalProfileRoutes();
|
|
311
325
|
this.setupProjectRoutes();
|
|
312
326
|
this.setupValidator();
|
|
313
327
|
this.setupValueSets();
|
|
@@ -451,7 +465,7 @@ class IpsModule {
|
|
|
451
465
|
next();
|
|
452
466
|
};
|
|
453
467
|
|
|
454
|
-
r.get('/profile-editor.js',
|
|
468
|
+
r.get('/profile-editor.js', scripts.handler('profile-editor.js'));
|
|
455
469
|
r.get('/lookup', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.systemLookup(req, res)));
|
|
456
470
|
r.get('/lookup-vs', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.valueSetLookup(req, res)));
|
|
457
471
|
r.get('/StructureDefinition', ...this.web, h('profiles', (req, res) => this.profileList(req, res)));
|
|
@@ -460,12 +474,37 @@ class IpsModule {
|
|
|
460
474
|
r.get('/StructureDefinition/:id', profile, ...this.web, h('profile', (req, res) => this.profileRead(req, res)));
|
|
461
475
|
r.get('/StructureDefinition/:id/edit', profile, this.session, this.csrf, admin, h('profile', (req, res) => this.profileEditor(req, res, req.profile)));
|
|
462
476
|
r.post('/StructureDefinition/:id', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.profileSave(req, res, req.profile)));
|
|
477
|
+
// one constraint at a time, from the profile's page: add, edit (keeping its key), delete
|
|
478
|
+
r.post('/StructureDefinition/:id/constraint', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, null)));
|
|
479
|
+
r.post('/StructureDefinition/:id/constraint/:key', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, req.params.key)));
|
|
480
|
+
r.post('/StructureDefinition/:id/constraint/:key/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => this.constraintDelete(req, res)));
|
|
463
481
|
r.post('/StructureDefinition/:id/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => {
|
|
464
482
|
this.store.deleteProfile(req.profileId);
|
|
465
483
|
res.redirect(req.baseUrl + '/StructureDefinition');
|
|
466
484
|
}));
|
|
467
485
|
}
|
|
468
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The national profiles, across all projects: everyone can see them, only the administrator
|
|
489
|
+
* adds, edits and deletes them. Before the project routes, so /national-profiles isn't taken
|
|
490
|
+
* for a project code.
|
|
491
|
+
*/
|
|
492
|
+
setupNationalProfileRoutes() {
|
|
493
|
+
const r = this.router;
|
|
494
|
+
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
495
|
+
const admin = this.adminOnly;
|
|
496
|
+
r.get('/national-profiles', ...this.web, h('national-profiles', (req, res) => this.sendNationalProfiles(req, res)));
|
|
497
|
+
r.post('/national-profiles', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, null)));
|
|
498
|
+
r.post('/national-profiles/:id', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, req.params.id)));
|
|
499
|
+
r.post('/national-profiles/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => {
|
|
500
|
+
const np = /^\d{1,15}$/.test(req.params.id) ? this.store.nationalProfile(parseInt(req.params.id, 10)) : null;
|
|
501
|
+
if (np && this.store.deleteNationalProfile(np.id)) {
|
|
502
|
+
this.log.info(`IPS module: national profile ${np.packageId}#${np.version} deleted`);
|
|
503
|
+
}
|
|
504
|
+
res.redirect(req.baseUrl + '/national-profiles');
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
|
|
469
508
|
setupProjectRoutes() {
|
|
470
509
|
const r = this.router;
|
|
471
510
|
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
@@ -509,7 +548,7 @@ class IpsModule {
|
|
|
509
548
|
next();
|
|
510
549
|
};
|
|
511
550
|
|
|
512
|
-
r.get('/editor.js',
|
|
551
|
+
r.get('/editor.js', scripts.handler('editor.js'));
|
|
513
552
|
r.get('/:code', project, ...this.web, h('list', (req, res) => this.htmlList(req, res)));
|
|
514
553
|
r.get('/:code/metadata', project, h('metadata', (req, res) => this.metadata(req, res)));
|
|
515
554
|
// The submission form has no CSRF token and starts no session: submitting is open to
|
|
@@ -595,25 +634,39 @@ class IpsModule {
|
|
|
595
634
|
* wait in the queue.
|
|
596
635
|
*/
|
|
597
636
|
setupValidator() {
|
|
637
|
+
// the national profiles' packages, as they are loaded into the validator: id#version -> Promise
|
|
638
|
+
this.nationalPackages = new Map();
|
|
598
639
|
const cfg = this.config.validator;
|
|
599
640
|
if (!cfg || cfg.enabled === false) {
|
|
600
641
|
this.log.warn('IPS module: no validator configured (modules.ips.validator) - documents will not be validated');
|
|
601
642
|
return;
|
|
602
643
|
}
|
|
603
644
|
const FhirValidator = require('fhir-validator-wrapper');
|
|
604
|
-
|
|
645
|
+
// a jar of its own (a validator built locally, say): used as it is, never downloaded or
|
|
646
|
+
// updated; otherwise the released one in the data folder's bin directory, kept up to date
|
|
647
|
+
const localJar = typeof cfg.jar === 'string' && cfg.jar.trim() ? (path.isAbsolute(cfg.jar) ? cfg.jar : folders.filePath(cfg.jar)) : null;
|
|
648
|
+
const jar = localJar || folders.filePath('bin', 'validator_cli.jar');
|
|
605
649
|
this.validator = new FhirValidator(jar, this.log);
|
|
606
650
|
const settings = {
|
|
651
|
+
autoDownload: !localJar,
|
|
652
|
+
skipUpdateCheck: !!localJar,
|
|
607
653
|
version: cfg.version || '4.0.1',
|
|
608
654
|
txServer: cfg.txServer || 'http://tx.fhir.org/r4',
|
|
609
655
|
txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
|
|
610
656
|
port: cfg.port || 8082,
|
|
611
657
|
igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
|
|
612
|
-
timeout: cfg.timeout || 120000
|
|
658
|
+
timeout: cfg.timeout || 120000,
|
|
659
|
+
// lookupExtension(): an extension the validator doesn't have is looked for the way the
|
|
660
|
+
// validator does while validating - the package that defines it is found from the url,
|
|
661
|
+
// downloaded and loaded (validator 7.0.5 and later; fhir-validator-wrapper 1.7.0 and later)
|
|
662
|
+
autoLoadPackages: cfg.autoLoadPackages !== false
|
|
613
663
|
};
|
|
614
|
-
this.log.info(`IPS module: starting the FHIR validator on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
|
|
664
|
+
this.log.info(`IPS module: starting the FHIR validator${localJar ? ` (${localJar})` : ''} on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
|
|
615
665
|
this.validatorStarting = this.validator.start(settings).then(() => {
|
|
616
666
|
this.log.info('IPS module: the FHIR validator is running');
|
|
667
|
+
// documents wait for this too, so the analysis step - which looks up extensions before
|
|
668
|
+
// the national step would load the packages - finds what the national IGs define
|
|
669
|
+
return this.loadNationalPackages();
|
|
617
670
|
}).catch(e => {
|
|
618
671
|
this.log.error('IPS module: the FHIR validator failed to start: ' + e.message);
|
|
619
672
|
this.validatorError = e.message;
|
|
@@ -622,6 +675,60 @@ class IpsModule {
|
|
|
622
675
|
});
|
|
623
676
|
}
|
|
624
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Load every national profile's package into the validator (as the national step would), so
|
|
680
|
+
* that what they define is there from the start. A package that won't load is logged (and
|
|
681
|
+
* remembered) by loadNationalPackage(), and doesn't stop the others.
|
|
682
|
+
* @returns {Promise<void>}
|
|
683
|
+
*/
|
|
684
|
+
async loadNationalPackages() {
|
|
685
|
+
if (!this.validator || !this.store) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const list = this.store.nationalProfiles();
|
|
689
|
+
if (list.length === 0) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const started = Date.now();
|
|
693
|
+
this.log.info(`IPS module: loading the packages of ${list.length} national profiles into the validator`);
|
|
694
|
+
// one at a time: the validator loads one package per request anyway
|
|
695
|
+
for (const np of list) {
|
|
696
|
+
try {
|
|
697
|
+
await this.loadNationalPackage(np.packageId, np.version);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
// already logged
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
this.log.info(`IPS module: national profile packages loaded in ${Math.round((Date.now() - started) / 1000)}s`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Load a national profile's package into the validator - once: a package that loaded, or
|
|
707
|
+
* failed to (with why), is remembered until the national profiles are changed, so each
|
|
708
|
+
* document doesn't ask again. The validator's packages (modules.ips.validator.packages) are
|
|
709
|
+
* loaded when it starts; these are added to them.
|
|
710
|
+
* @returns {Promise<void>} rejects with the reason the package could not be loaded
|
|
711
|
+
*/
|
|
712
|
+
loadNationalPackage(packageId, version) {
|
|
713
|
+
const key = `${packageId}#${version}`;
|
|
714
|
+
let p = this.nationalPackages.get(key);
|
|
715
|
+
if (!p) {
|
|
716
|
+
p = this.validator.loadIG(packageId, version).then(outcome => {
|
|
717
|
+
const bad = (Array.isArray(outcome?.issue) ? outcome.issue : []).filter(i => i && (i.severity === 'error' || i.severity === 'fatal'));
|
|
718
|
+
if (bad.length > 0 || outcome?.resourceType !== 'OperationOutcome') {
|
|
719
|
+
const why = bad.map(i => i.details?.text || i.diagnostics || i.code).filter(Boolean).join('; ') || 'the validator did not return an OperationOutcome';
|
|
720
|
+
throw new Error(`The validator could not load ${key}: ${why}`);
|
|
721
|
+
}
|
|
722
|
+
this.log.info(`IPS module: national profile package ${key} loaded into the validator`);
|
|
723
|
+
// the validator knows more now: extensions it couldn't find before may be there
|
|
724
|
+
this.forgetMissingExtensions();
|
|
725
|
+
});
|
|
726
|
+
p.catch(e => this.log.warn(`IPS module: ${e.message}`));
|
|
727
|
+
this.nationalPackages.set(key, p);
|
|
728
|
+
}
|
|
729
|
+
return p;
|
|
730
|
+
}
|
|
731
|
+
|
|
625
732
|
/** Read the value sets the terminology check holds documents to: the module's own, in ips/valuesets/. */
|
|
626
733
|
setupValueSets() {
|
|
627
734
|
try {
|
|
@@ -654,6 +761,58 @@ class IpsModule {
|
|
|
654
761
|
return null;
|
|
655
762
|
}
|
|
656
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Forget the extensions the validator couldn't find: it has loaded a package since, and may
|
|
766
|
+
* know them now. The ones it found are kept.
|
|
767
|
+
*/
|
|
768
|
+
forgetMissingExtensions() {
|
|
769
|
+
if (this.extensionCache) {
|
|
770
|
+
for (const [url, answer] of this.extensionCache) {
|
|
771
|
+
if (answer === null) {
|
|
772
|
+
this.extensionCache.delete(url);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The definition of an extension, for the analysis step: {url, title, name, description},
|
|
780
|
+
* or null when it can't be found. It is asked of the validator (the wrapper's
|
|
781
|
+
* lookupCanonical(), the service's /canonical, which looks in the validator's context - and,
|
|
782
|
+
* with modules.ips.validator.autoLoadPackages (on by default), finds and loads the package that
|
|
783
|
+
* defines one it doesn't have).
|
|
784
|
+
* Answers are remembered (a bounded cache); a validator that isn't there, or that fails, or
|
|
785
|
+
* that predates /canonical, isn't an answer, and is asked again next time.
|
|
786
|
+
* @param {string} url
|
|
787
|
+
* @returns {Promise<Object|null>}
|
|
788
|
+
*/
|
|
789
|
+
async lookupExtension(url) {
|
|
790
|
+
const v = this.validator;
|
|
791
|
+
if (!v || typeof v.lookupCanonical !== 'function' || typeof v.isRunning !== 'function' || !v.isRunning()) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
const cache = this.extensionCache || (this.extensionCache = new Map());
|
|
795
|
+
if (cache.has(url)) {
|
|
796
|
+
return cache.get(url);
|
|
797
|
+
}
|
|
798
|
+
let answer = null;
|
|
799
|
+
try {
|
|
800
|
+
const r = await v.lookupCanonical(url);
|
|
801
|
+
if (r) {
|
|
802
|
+
const str = (x) => (typeof x === 'string' ? x : '');
|
|
803
|
+
answer = { url: str(r.url), title: str(r.title), name: str(r.name), description: str(r.description).substring(0, 1000) };
|
|
804
|
+
}
|
|
805
|
+
} catch (e) {
|
|
806
|
+
this.log.warn(`IPS module: looking up extension ${url} failed: ${e.message}`);
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
if (cache.size >= MAX_CACHED_EXTENSIONS) {
|
|
810
|
+
cache.delete(cache.keys().next().value);
|
|
811
|
+
}
|
|
812
|
+
cache.set(url, answer);
|
|
813
|
+
return answer;
|
|
814
|
+
}
|
|
815
|
+
|
|
657
816
|
/**
|
|
658
817
|
* Whether the pipeline can run now: not while the validator is still starting, nor -
|
|
659
818
|
* for a while after start-up - while a terminology server on this server isn't there
|
|
@@ -829,20 +988,29 @@ class IpsModule {
|
|
|
829
988
|
}
|
|
830
989
|
|
|
831
990
|
/**
|
|
832
|
-
* The paragraph for the server's daily report: pages served
|
|
833
|
-
*
|
|
991
|
+
* The paragraph for the server's daily report: pages served (and how many were about documents - a
|
|
992
|
+
* document's page, in any of its views), new documents and how many are stored, and the
|
|
993
|
+
* processing pipeline - how many times it was started, and how many of those didn't finish
|
|
994
|
+
* (it failed, or the server stopped while it ran).
|
|
834
995
|
*/
|
|
835
996
|
dailyReport({since, now}) {
|
|
836
997
|
const {plural} = require('../library/daily-report');
|
|
998
|
+
const was = (n) => n === 1 ? 'was' : 'were';
|
|
837
999
|
const sentences = [];
|
|
838
1000
|
if (this.stats) {
|
|
839
1001
|
const counts = this.stats.countsSince(since, now);
|
|
840
|
-
|
|
1002
|
+
const docs = counts.operations.read || 0;
|
|
1003
|
+
sentences.push(`${plural(counts.total, 'page')} ${was(counts.total)} served${counts.total ? `, ${docs} of them about documents` : ''}.`);
|
|
841
1004
|
}
|
|
842
1005
|
if (this.store) {
|
|
843
1006
|
const received = this.store.countReceived(since, now);
|
|
844
1007
|
const stored = this.store.count();
|
|
845
|
-
sentences.push(`${plural(received, '
|
|
1008
|
+
sentences.push(`${plural(received, 'new document')} ${was(received)} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
|
|
1009
|
+
const runs = this.store.countRuns(since, now);
|
|
1010
|
+
const unfinished = runs.failed + runs.interrupted;
|
|
1011
|
+
const why = [runs.failed ? `${runs.failed} failed` : '', runs.interrupted ? `${runs.interrupted} interrupted by a shutdown` : ''].filter(Boolean).join(', ');
|
|
1012
|
+
sentences.push(`The processing pipeline was started ${plural(runs.started, 'time')}; ${unfinished} didn't finish${why ? ` (${why})` : ''}` +
|
|
1013
|
+
`${runs.running ? `, and ${runs.running} ${runs.running === 1 ? 'is' : 'are'} still running` : ''}.`);
|
|
846
1014
|
} else {
|
|
847
1015
|
sentences.push('The document store is not open.');
|
|
848
1016
|
}
|
|
@@ -1295,6 +1463,59 @@ class IpsModule {
|
|
|
1295
1463
|
render.renderSubmit(req.project, this.ctx(req, res), { needsToken: tokenConfigured(req.project.token), aiEnabled: this.store.projectAi(req.project).enabled === true, ...options }), Date.now(), req);
|
|
1296
1464
|
}
|
|
1297
1465
|
|
|
1466
|
+
// ---- national profiles ----
|
|
1467
|
+
|
|
1468
|
+
/** The national profiles: a page (with the administrator's forms), or JSON. */
|
|
1469
|
+
sendNationalProfiles(req, res, message, status = 200) {
|
|
1470
|
+
const list = this.store.nationalProfiles();
|
|
1471
|
+
res.status(status);
|
|
1472
|
+
if (!wantsHtml(req) && !message) {
|
|
1473
|
+
return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url,
|
|
1474
|
+
...(np.snomedEdition ? { snomedEdition: np.snomedEdition } : {}) })));
|
|
1475
|
+
}
|
|
1476
|
+
this.sendHtml(res, 'National Profiles', render.renderNationalProfiles(list, this.ctx(req, res), message), Date.now(), req);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Add (id null) or change a national profile, from the form: jurisdiction, packageId, version, url, snomedEdition (optional). */
|
|
1480
|
+
saveNationalProfile(req, res, id) {
|
|
1481
|
+
const field = (name) => (typeof req.body[name] === 'string' ? req.body[name].trim() : '');
|
|
1482
|
+
const packageId = field('packageId');
|
|
1483
|
+
const version = field('version');
|
|
1484
|
+
const url = field('url');
|
|
1485
|
+
// a country code is upper case; a region's is digits
|
|
1486
|
+
const jurisdiction = field('jurisdiction').toUpperCase();
|
|
1487
|
+
// the edition's module id; empty for none
|
|
1488
|
+
const snomedEdition = field('snomedEdition') || null;
|
|
1489
|
+
const problem = nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition);
|
|
1490
|
+
if (problem) {
|
|
1491
|
+
return this.sendNationalProfiles(req, res, problem, 400);
|
|
1492
|
+
}
|
|
1493
|
+
const existing = this.store.nationalProfileByPackage(packageId, version);
|
|
1494
|
+
if (id === null) {
|
|
1495
|
+
if (existing) {
|
|
1496
|
+
return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
|
|
1497
|
+
}
|
|
1498
|
+
this.store.createNationalProfile(packageId, version, url, jurisdiction, snomedEdition);
|
|
1499
|
+
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) added (${url})`);
|
|
1500
|
+
} else {
|
|
1501
|
+
const n = /^\d{1,15}$/.test(id) ? parseInt(id, 10) : -1;
|
|
1502
|
+
if (existing && existing.id !== n) {
|
|
1503
|
+
return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
|
|
1504
|
+
}
|
|
1505
|
+
if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction, snomedEdition)) {
|
|
1506
|
+
return this.sendNationalProfiles(req, res, 'That national profile no longer exists', 404);
|
|
1507
|
+
}
|
|
1508
|
+
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) saved (${url})`);
|
|
1509
|
+
}
|
|
1510
|
+
// a package that failed to load is tried again; the packages are loaded now (in the
|
|
1511
|
+
// background), so the analysis step finds what they define
|
|
1512
|
+
this.nationalPackages = new Map();
|
|
1513
|
+
if (this.validator && this.validator.isRunning()) {
|
|
1514
|
+
this.loadNationalPackages().catch(e => this.log.warn(`IPS module: loading the national profile packages failed: ${e.message}`));
|
|
1515
|
+
}
|
|
1516
|
+
res.redirect(req.baseUrl + '/national-profiles');
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1298
1519
|
// ---- clinical profiles ----
|
|
1299
1520
|
|
|
1300
1521
|
/** The clinical profiles: a page, or a FHIR searchset of the StructureDefinitions. */
|
|
@@ -1321,7 +1542,31 @@ class IpsModule {
|
|
|
1321
1542
|
res.type('application/fhir+json');
|
|
1322
1543
|
return res.send(JSON.stringify(sd));
|
|
1323
1544
|
}
|
|
1324
|
-
|
|
1545
|
+
const ctx = { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url), profileTitle: (url) => this.profileTitle(url) };
|
|
1546
|
+
// the administrator edits the rows in place, a row at a time
|
|
1547
|
+
const editor = ctx.user?.isAdmin ? {
|
|
1548
|
+
constraints: this.editorConstraints(sd),
|
|
1549
|
+
sections: profiles.IPS_SECTIONS, systems: profiles.SYSTEMS, resourceTypes: profiles.RESOURCE_TYPES, sectionSystem: editorSchema.LOINC, canLookup: !!this.tx
|
|
1550
|
+
} : null;
|
|
1551
|
+
this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, ctx, editor), Date.now(), req);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* What to call a profile a rule says resources conform to: one of the clinical profiles (by
|
|
1556
|
+
* its title), or one of the national profiles (by its jurisdiction and package), or null.
|
|
1557
|
+
*/
|
|
1558
|
+
profileTitle(url) {
|
|
1559
|
+
if (typeof url !== 'string') {
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
const plain = url.split('|')[0];
|
|
1563
|
+
const m = /\/StructureDefinition\/profile-([a-f0-9]{12})$/.exec(plain);
|
|
1564
|
+
const sd = m ? this.store.profile(m[1]) : null;
|
|
1565
|
+
if (sd && sd.url === plain) {
|
|
1566
|
+
return sd.title;
|
|
1567
|
+
}
|
|
1568
|
+
const np = this.store.nationalProfiles().find(p => p.url === plain);
|
|
1569
|
+
return np ? `${np.jurisdiction ? `${np.jurisdiction} ` : ''}national profile (${np.packageId}#${np.version})` : null;
|
|
1325
1570
|
}
|
|
1326
1571
|
|
|
1327
1572
|
/** The title of one of this module's own value sets, or null. */
|
|
@@ -1331,8 +1576,12 @@ class IpsModule {
|
|
|
1331
1576
|
}
|
|
1332
1577
|
|
|
1333
1578
|
/** The editor, for a new profile or an existing one. */
|
|
1579
|
+
editorConstraints(sd) {
|
|
1580
|
+
return profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1334
1583
|
profileEditor(req, res, sd) {
|
|
1335
|
-
const constraints =
|
|
1584
|
+
const constraints = this.editorConstraints(sd);
|
|
1336
1585
|
this.sendHtml(res, sd ? `Edit Clinical Profile: ${sd.title}` : 'New Clinical Profile',
|
|
1337
1586
|
render.renderProfileEditor(sd, this.ctx(req, res), {
|
|
1338
1587
|
profile: { id: sd ? sd.id : null, title: sd ? sd.title : '', constraints },
|
|
@@ -1369,6 +1618,53 @@ class IpsModule {
|
|
|
1369
1618
|
res.status(previous ? 200 : 201).json({ id: sd.id, url: `${req.baseUrl}/StructureDefinition/${sd.id}` });
|
|
1370
1619
|
}
|
|
1371
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* Add a constraint to a profile ({human, expression}), or replace one (by its key, which it
|
|
1623
|
+
* keeps). 201/200 with {key}, 404 when there is no constraint with that key, 422 with {problems}.
|
|
1624
|
+
*/
|
|
1625
|
+
constraintSave(req, res, key) {
|
|
1626
|
+
const previous = req.profile;
|
|
1627
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
1628
|
+
const row = { key, human: body.human, expression: body.expression };
|
|
1629
|
+
const constraints = profiles.constraintsOf(previous);
|
|
1630
|
+
const at = key === null ? constraints.length : constraints.findIndex(c => c.key === key);
|
|
1631
|
+
if (at < 0) {
|
|
1632
|
+
return res.status(404).json({ problems: [`The profile has no constraint '${key}'`] });
|
|
1633
|
+
}
|
|
1634
|
+
// the row by itself first, so what is wrong is said of it, not of a number in the list
|
|
1635
|
+
const alone = profiles.checkInput({ title: previous.title, constraints: [{ human: row.human, expression: row.expression }] }, null);
|
|
1636
|
+
if (alone.problems.length) {
|
|
1637
|
+
return res.status(422).json({ problems: alone.problems.map(p => p.replace(/^Constraint 1/, 'The rule')) });
|
|
1638
|
+
}
|
|
1639
|
+
constraints.splice(at, key === null ? 0 : 1, row);
|
|
1640
|
+
const checked = profiles.checkInput({ title: previous.title, constraints }, previous);
|
|
1641
|
+
if (checked.problems.length) {
|
|
1642
|
+
return res.status(422).json({ problems: checked.problems });
|
|
1643
|
+
}
|
|
1644
|
+
this.saveConstraints(previous, checked.constraints);
|
|
1645
|
+
this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${checked.constraints[at].key} ${key === null ? 'added' : 'saved'}`);
|
|
1646
|
+
res.status(key === null ? 201 : 200).json({ key: checked.constraints[at].key });
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** Delete one of a profile's constraints, back to the profile's page. */
|
|
1650
|
+
constraintDelete(req, res) {
|
|
1651
|
+
const previous = req.profile;
|
|
1652
|
+
const constraints = profiles.constraintsOf(previous);
|
|
1653
|
+
const at = constraints.findIndex(c => c.key === req.params.key);
|
|
1654
|
+
if (at < 0) {
|
|
1655
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`The profile has no constraint '${req.params.key}'`), 404);
|
|
1656
|
+
}
|
|
1657
|
+
constraints.splice(at, 1);
|
|
1658
|
+
this.saveConstraints(previous, constraints);
|
|
1659
|
+
this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${req.params.key} deleted`);
|
|
1660
|
+
res.redirect(`${req.baseUrl}/StructureDefinition/${previous.id}`);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
saveConstraints(previous, constraints) {
|
|
1664
|
+
const id = previous.id.substring('profile-'.length);
|
|
1665
|
+
this.store.saveProfile(id, profiles.build({ id, url: previous.url, title: previous.title, constraints }));
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1372
1668
|
// ---- the editor ----
|
|
1373
1669
|
|
|
1374
1670
|
sendCompose(req, res) {
|
|
@@ -1588,7 +1884,7 @@ class IpsModule {
|
|
|
1588
1884
|
if (!row) {
|
|
1589
1885
|
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
|
|
1590
1886
|
}
|
|
1591
|
-
const view = render.
|
|
1887
|
+
const { view, narrative } = render.viewOf(req.query);
|
|
1592
1888
|
row.results = {};
|
|
1593
1889
|
for (const step of this.steps) {
|
|
1594
1890
|
row.results[step.name] = this.store.readResult(row.id, step.name);
|
|
@@ -1597,7 +1893,9 @@ class IpsModule {
|
|
|
1597
1893
|
...this.ctx(req, res),
|
|
1598
1894
|
steps: this.steps.map(st => st.name),
|
|
1599
1895
|
level: typeof req.query.level === 'string' ? req.query.level : undefined,
|
|
1600
|
-
|
|
1896
|
+
np: typeof req.query.np === 'string' ? req.query.np : undefined,
|
|
1897
|
+
problemsOnly: req.query.problems === '1',
|
|
1898
|
+
narrative
|
|
1601
1899
|
};
|
|
1602
1900
|
return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
|
|
1603
1901
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Jurisdictions, as http://hl7.org/fhir/ValueSet/jurisdiction has them: a country, by its
|
|
9
|
+
* ISO 3166-1 two-letter code (urn:iso:std:iso:3166), or a region, by its UN M49 three-digit
|
|
10
|
+
* code (http://unstats.un.org/unsd/methods/m49/m49.htm). The codes and names are the ones
|
|
11
|
+
* the terminology module's providers hold (tx/cs/cs-country.js, tx/cs/cs-areacode.js).
|
|
12
|
+
*
|
|
13
|
+
* @module ips/jurisdictions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { CountryCodeFactoryProvider } = require('../tx/cs/cs-country');
|
|
17
|
+
const { AreaCodeFactoryProvider } = require('../tx/cs/cs-areacode');
|
|
18
|
+
|
|
19
|
+
const ISO_3166 = 'urn:iso:std:iso:3166';
|
|
20
|
+
const M49 = 'http://unstats.un.org/unsd/methods/m49/m49.htm';
|
|
21
|
+
// in the country list, but not countries
|
|
22
|
+
const UNKNOWN = new Set(['XX', 'ZZ']);
|
|
23
|
+
|
|
24
|
+
let tables = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The providers' code lists, read once. Only their data is wanted, not code system
|
|
28
|
+
* providers, so their load() runs on an object with no i18n (it only fills codes/codeMap).
|
|
29
|
+
*/
|
|
30
|
+
function load() {
|
|
31
|
+
if (!tables) {
|
|
32
|
+
const countries = Object.create(CountryCodeFactoryProvider.prototype);
|
|
33
|
+
const areas = Object.create(AreaCodeFactoryProvider.prototype);
|
|
34
|
+
// load() is async in form, but does its work synchronously
|
|
35
|
+
countries.load();
|
|
36
|
+
areas.load();
|
|
37
|
+
tables = { countries: countries.codeMap, areas: areas.codeMap };
|
|
38
|
+
}
|
|
39
|
+
return tables;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A jurisdiction by its code: two upper case letters (a country), or three digits (a
|
|
44
|
+
* region - an M49 region, not a country's numeric code).
|
|
45
|
+
* @param {string} code
|
|
46
|
+
* @returns {{system: string, code: string, display: string}|null} null if it is neither
|
|
47
|
+
*/
|
|
48
|
+
function jurisdiction(code) {
|
|
49
|
+
if (typeof code !== 'string') {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const t = load();
|
|
53
|
+
if (/^[A-Z]{2}$/.test(code)) {
|
|
54
|
+
const c = t.countries.get(code);
|
|
55
|
+
// not the user-assigned codes (XK, QM-QZ...), nor the 'unknown' ones (XX, ZZ): they aren't countries ISO 3166 names
|
|
56
|
+
return c && !c.userDefined && !UNKNOWN.has(code) ? { system: ISO_3166, code, display: c.display } : null;
|
|
57
|
+
}
|
|
58
|
+
if (/^\d{3}$/.test(code)) {
|
|
59
|
+
const a = t.areas.get(code);
|
|
60
|
+
return a && a.class_ === 'region' ? { system: M49, code, display: a.display } : null;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { jurisdiction, ISO_3166, M49 };
|