fhirsmith 0.11.2 → 0.13.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/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* export-icd11-codesystem.js -- write FHIR resources out of the database built by
|
|
4
|
+
* import-icd11.js: CodeSystems, language supplements, and the postcoordination scale
|
|
5
|
+
* ValueSets, optionally assembled into a FHIR NPM package.
|
|
6
|
+
*
|
|
7
|
+
* node export-icd11-codesystem.js --src data/icd11-2026-01.db --out out/ --package
|
|
8
|
+
*
|
|
9
|
+
* For each code system it writes a base CodeSystem in the base language, plus one
|
|
10
|
+
* CodeSystem supplement per additional language. MMS and ICF are emitted with a nested
|
|
11
|
+
* concept hierarchy; the Foundation is a DAG so it is emitted flat, with parent and child
|
|
12
|
+
* properties carrying the structure.
|
|
13
|
+
*
|
|
14
|
+
* These are big files, so everything is streamed -- no complete resource is ever held in
|
|
15
|
+
* memory, and JSON.stringify is only ever called on one concept at a time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const zlib = require('zlib');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const Database = require('better-sqlite3');
|
|
25
|
+
const { Command } = require('commander');
|
|
26
|
+
|
|
27
|
+
const TOOL_VERSION = '1.0.0';
|
|
28
|
+
const SCHEMA_NS = 'http://id.who.int/icd/schema';
|
|
29
|
+
|
|
30
|
+
// How the native field names map onto the resource.
|
|
31
|
+
// `definition` becomes CodeSystem.concept.definition; everything else is a designation.
|
|
32
|
+
const DESIGNATION_KINDS = [
|
|
33
|
+
'fullySpecifiedName', 'longDefinition', 'codingNote', 'diagnosticCriteria',
|
|
34
|
+
'inclusion', 'indexTerm',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// a minimal streaming JSON writer
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
class JsonWriter {
|
|
42
|
+
constructor(stream, pretty) {
|
|
43
|
+
this.s = stream;
|
|
44
|
+
this.pretty = pretty;
|
|
45
|
+
this.pending = Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
write(chunk) {
|
|
48
|
+
if (!this.s.write(chunk)) {
|
|
49
|
+
this.pending = this.pending.then(() => new Promise(r => this.s.once('drain', r)));
|
|
50
|
+
}
|
|
51
|
+
return this.pending;
|
|
52
|
+
}
|
|
53
|
+
/** Serialise one value. Only ever called on a single concept, never the whole resource. */
|
|
54
|
+
value(v, indent) {
|
|
55
|
+
return this.pretty ? JSON.stringify(v, null, 2).replace(/\n/g, '\n' + ' '.repeat(indent))
|
|
56
|
+
: JSON.stringify(v);
|
|
57
|
+
}
|
|
58
|
+
async end() {
|
|
59
|
+
await this.pending;
|
|
60
|
+
await new Promise((res, rej) => { this.s.end(err => err ? rej(err) : res()); });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// a minimal streaming ustar writer, so packaging needs no external tar binary
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
function tarHeader(name, size, mtime) {
|
|
69
|
+
const h = Buffer.alloc(512);
|
|
70
|
+
let prefix = '';
|
|
71
|
+
let nm = name;
|
|
72
|
+
if (Buffer.byteLength(nm) > 100) { // split long paths across name/prefix
|
|
73
|
+
const cut = nm.lastIndexOf('/', nm.length - 100);
|
|
74
|
+
if (cut < 0) throw new Error(`path too long for tar: ${nm}`);
|
|
75
|
+
prefix = nm.slice(0, cut);
|
|
76
|
+
nm = nm.slice(cut + 1);
|
|
77
|
+
}
|
|
78
|
+
const put = (s, off, len) => h.write(s.slice(0, len), off, len, 'utf8');
|
|
79
|
+
const oct = (n, off, len) => h.write(n.toString(8).padStart(len - 1, '0') + '\0', off, len, 'ascii');
|
|
80
|
+
put(nm, 0, 100);
|
|
81
|
+
oct(0o644, 100, 8); oct(0, 108, 8); oct(0, 116, 8);
|
|
82
|
+
oct(size, 124, 12); oct(Math.floor(mtime / 1000), 136, 12);
|
|
83
|
+
h.write(' ', 148, 8, 'ascii'); // checksum placeholder: spaces
|
|
84
|
+
h.write('0', 156, 1, 'ascii'); // typeflag: regular file
|
|
85
|
+
h.write('ustar\0', 257, 6, 'ascii');
|
|
86
|
+
h.write('00', 263, 2, 'ascii');
|
|
87
|
+
if (prefix) put(prefix, 345, 155);
|
|
88
|
+
let sum = 0;
|
|
89
|
+
for (const b of h) sum += b;
|
|
90
|
+
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
|
91
|
+
return h;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** tar + gzip every file in `dir` (recursively) into `outFile`, streaming. */
|
|
95
|
+
async function tarGzDirectory(dir, outFile, root) {
|
|
96
|
+
const gz = zlib.createGzip({ level: 9 });
|
|
97
|
+
const out = fs.createWriteStream(outFile);
|
|
98
|
+
const done = new Promise((res, rej) => { out.on('finish', res); out.on('error', rej); });
|
|
99
|
+
gz.pipe(out);
|
|
100
|
+
const write = (chunk) => new Promise((res) => { if (gz.write(chunk)) res(); else gz.once('drain', res); });
|
|
101
|
+
|
|
102
|
+
const walk = function* (d, rel) {
|
|
103
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
104
|
+
const full = path.join(d, e.name);
|
|
105
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
106
|
+
if (e.isDirectory()) yield* walk(full, r); else yield [full, r];
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
for (const [full, rel] of walk(dir, root)) {
|
|
110
|
+
const st = fs.statSync(full);
|
|
111
|
+
await write(tarHeader(rel, st.size, st.mtimeMs));
|
|
112
|
+
await new Promise((res, rej) => {
|
|
113
|
+
const rs = fs.createReadStream(full);
|
|
114
|
+
rs.on('data', c => { if (!gz.write(c)) { rs.pause(); gz.once('drain', () => rs.resume()); } });
|
|
115
|
+
rs.on('end', res); rs.on('error', rej);
|
|
116
|
+
});
|
|
117
|
+
const pad = (512 - (st.size % 512)) % 512;
|
|
118
|
+
if (pad) await write(Buffer.alloc(pad));
|
|
119
|
+
}
|
|
120
|
+
await write(Buffer.alloc(1024)); // two zero blocks terminate the archive
|
|
121
|
+
gz.end();
|
|
122
|
+
await done;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function openOut(file, gzip) {
|
|
126
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
127
|
+
const target = gzip ? file + '.gz' : file;
|
|
128
|
+
const out = fs.createWriteStream(target);
|
|
129
|
+
if (!gzip) return { stream: out, target, finished: new Promise(r => out.on('finish', r)) };
|
|
130
|
+
const gz = zlib.createGzip({ level: 9 });
|
|
131
|
+
gz.pipe(out);
|
|
132
|
+
return { stream: gz, target, finished: new Promise(r => out.on('finish', r)) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// exporter
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class CodeSystemExporter {
|
|
140
|
+
constructor(opts) {
|
|
141
|
+
this.opts = opts;
|
|
142
|
+
this.db = new Database(opts.src, { readonly: true });
|
|
143
|
+
this.log = (...a) => console.log(...a);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
meta(key) {
|
|
147
|
+
const r = this.db.prepare('SELECT value FROM meta WHERE key=?').get(key);
|
|
148
|
+
return r ? r.value : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Which string is CodeSystem.concept.code. This is the unresolved community question --
|
|
153
|
+
* the Geneva straw poll ran 7-1 for bare entity ids over the current hybrid -- so it is
|
|
154
|
+
* a switch rather than a decision baked into the output.
|
|
155
|
+
*
|
|
156
|
+
* hybrid : the short code where there is one, the entity uri otherwise (what the WHO
|
|
157
|
+
* FHIR server does today, and what existing data uses)
|
|
158
|
+
* short : the short code; concepts without one are dropped from the resource
|
|
159
|
+
* uri : the unversioned entity uri, always
|
|
160
|
+
* id : the bare entity id, always (straw poll option 4)
|
|
161
|
+
*/
|
|
162
|
+
codeOf(c) {
|
|
163
|
+
switch (this.opts.codeForm) {
|
|
164
|
+
case 'short': return c.code;
|
|
165
|
+
case 'uri': return c.uri;
|
|
166
|
+
case 'id': return c.entity_id;
|
|
167
|
+
default: return c.code || c.uri;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -- the declarations WHO's own CodeSystem omits --------------------------
|
|
172
|
+
|
|
173
|
+
// NOTE on types: CodeSystem.property.type is bound to a required value set that does not
|
|
174
|
+
// include `uri`, and concept.property.value[x] has no valueUri choice either -- the
|
|
175
|
+
// allowed types are code | Coding | string | integer | boolean | dateTime | decimal.
|
|
176
|
+
// Uri-valued properties are therefore declared and emitted as strings. (This is the same
|
|
177
|
+
// mistake the WHO server makes on ValueSet.expansion.contains.property.value[x], where it
|
|
178
|
+
// returns valueUri and no conformant client can parse the result.)
|
|
179
|
+
properties(sys) {
|
|
180
|
+
const p = [
|
|
181
|
+
{ code: 'entityId', uri: `${SCHEMA_NS}/entityId`, type: 'string',
|
|
182
|
+
description: 'The stable WHO entity id. Residual categories carry a derived id such as 1363559646/other.' },
|
|
183
|
+
{ code: 'uri', uri: `${SCHEMA_NS}/uri`, type: 'string',
|
|
184
|
+
description: 'The canonical entity uri, unversioned.' },
|
|
185
|
+
// Coding, not code: CodeSystem.property gives no way to say which code system a
|
|
186
|
+
// code-typed property draws its values from, so the validator assumes this one, and
|
|
187
|
+
// 'category' is not a code in ICD-11. A Coding names the schema code system outright.
|
|
188
|
+
{ code: 'classKind', uri: `${SCHEMA_NS}/classKind`, type: 'Coding',
|
|
189
|
+
description: 'chapter | block | window | category. Only categories may be used to code with.' },
|
|
190
|
+
{ code: 'notSelectable', uri: 'http://hl7.org/fhir/concept-properties#notSelectable', type: 'boolean',
|
|
191
|
+
description: 'True where the concept is a grouper: it has no short code and must not be used to code with.' },
|
|
192
|
+
{ code: 'child', uri: 'http://hl7.org/fhir/concept-properties#child', type: 'code',
|
|
193
|
+
description: 'A child of this concept.' },
|
|
194
|
+
{ code: 'parent', uri: 'http://hl7.org/fhir/concept-properties#parent', type: 'code',
|
|
195
|
+
description: 'A parent of this concept.' },
|
|
196
|
+
];
|
|
197
|
+
if (this.opts.codeForm !== 'short') {
|
|
198
|
+
p.push({ code: 'shortCode', uri: `${SCHEMA_NS}/code`, type: 'string',
|
|
199
|
+
description: 'The short "pretty" code, where the entity has one. Short codes may change between releases; entity ids do not.' });
|
|
200
|
+
}
|
|
201
|
+
if (sys.code !== 'foundation') {
|
|
202
|
+
p.push(
|
|
203
|
+
{ code: 'foundationUri', uri: `${SCHEMA_NS}/source`, type: 'string',
|
|
204
|
+
description: 'The Foundation entity this linearization entry was derived from.' },
|
|
205
|
+
{ code: 'blockId', uri: `${SCHEMA_NS}/blockId`, type: 'string' },
|
|
206
|
+
{ code: 'codeRange', uri: `${SCHEMA_NS}/codeRange`, type: 'string' },
|
|
207
|
+
{ code: 'postcoordinationScale', uri: `${SCHEMA_NS}/postcoordinationScale`, type: 'string',
|
|
208
|
+
description: 'The ValueSet of permitted values for one postcoordination axis of this concept. Repeats, once per axis.' },
|
|
209
|
+
{ code: 'requiredPostcoordination', uri: `${SCHEMA_NS}/requiredPostcoordination`, type: 'string',
|
|
210
|
+
description: 'A postcoordination axis that WHO marks as required. In practice this means strongly encouraged: the code stays valid without it.' },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
p.push({ code: 'browserUrl', uri: `${SCHEMA_NS}/browserUrl`, type: 'string' });
|
|
214
|
+
return p;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
filters() {
|
|
218
|
+
return [
|
|
219
|
+
{ code: 'concept', description: 'Filter on the classification hierarchy.',
|
|
220
|
+
operator: ['is-a', 'descendent-of'], value: 'A code in this code system' },
|
|
221
|
+
{ code: 'classKind', description: 'Filter by the kind of entity.',
|
|
222
|
+
operator: ['=', 'in'], value: 'chapter | block | window | category' },
|
|
223
|
+
{ code: 'notSelectable', description: 'Include or exclude groupers.',
|
|
224
|
+
operator: ['='], value: 'true or false' },
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// -- data loading ---------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
loadDesignations(systemId, lang) {
|
|
231
|
+
const map = new Map();
|
|
232
|
+
const stmt = this.db.prepare(`
|
|
233
|
+
SELECT d.concept, d.kind, d.value FROM designation d
|
|
234
|
+
JOIN concept c ON c.id = d.concept
|
|
235
|
+
WHERE c.system = ? AND d.lang = ?
|
|
236
|
+
ORDER BY d.concept, d.seq`);
|
|
237
|
+
for (const r of stmt.iterate(systemId, lang)) {
|
|
238
|
+
let e = map.get(r.concept);
|
|
239
|
+
if (!e) { e = {}; map.set(r.concept, e); }
|
|
240
|
+
(e[r.kind] ||= []).push(r.value);
|
|
241
|
+
}
|
|
242
|
+
return map;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
loadScales(systemId) {
|
|
246
|
+
const map = new Map();
|
|
247
|
+
for (const r of this.db.prepare(`
|
|
248
|
+
SELECT ps.concept, ps.value_set_uri, ps.required FROM pc_scale ps
|
|
249
|
+
JOIN concept c ON c.id = ps.concept WHERE c.system = ? ORDER BY ps.seq`).iterate(systemId)) {
|
|
250
|
+
let e = map.get(r.concept);
|
|
251
|
+
if (!e) { e = []; map.set(r.concept, e); }
|
|
252
|
+
e.push(r);
|
|
253
|
+
}
|
|
254
|
+
return map;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
loadConcepts(systemId) {
|
|
258
|
+
const rows = this.db.prepare(`
|
|
259
|
+
SELECT id, entity_id, uri, code, class_kind, selectable, residual, depth,
|
|
260
|
+
block_id, code_range, source_uri, browser_url, lft
|
|
261
|
+
FROM concept WHERE system = ? ORDER BY COALESCE(lft, id)`).all(systemId);
|
|
262
|
+
const byId = new Map(rows.map(r => [r.id, r]));
|
|
263
|
+
const kids = new Map();
|
|
264
|
+
const parents = new Map();
|
|
265
|
+
for (const r of this.db.prepare(`
|
|
266
|
+
SELECT cp.child, cp.parent FROM concept_parent cp
|
|
267
|
+
JOIN concept c ON c.id = cp.child WHERE c.system = ? ORDER BY cp.seq`).iterate(systemId)) {
|
|
268
|
+
(kids.get(r.parent) ?? kids.set(r.parent, []).get(r.parent)).push(r.child);
|
|
269
|
+
(parents.get(r.child) ?? parents.set(r.child, []).get(r.child)).push(r.parent);
|
|
270
|
+
}
|
|
271
|
+
const roots = rows.filter(r => !parents.has(r.id)).map(r => r.id);
|
|
272
|
+
return { rows, byId, kids, parents, roots };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// -- one concept ----------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
conceptJson(c, ctx, { nested }) {
|
|
278
|
+
const code = this.codeOf(c);
|
|
279
|
+
if (code == null) return null;
|
|
280
|
+
const d = ctx.desig.get(c.id) || {};
|
|
281
|
+
const out = { code };
|
|
282
|
+
|
|
283
|
+
const title = d.title && d.title[0];
|
|
284
|
+
if (title) out.display = title;
|
|
285
|
+
if (d.definition && d.definition[0]) out.definition = d.definition[0];
|
|
286
|
+
|
|
287
|
+
const designation = [];
|
|
288
|
+
for (const kind of DESIGNATION_KINDS) {
|
|
289
|
+
for (const v of d[kind] || []) {
|
|
290
|
+
designation.push({
|
|
291
|
+
language: ctx.lang,
|
|
292
|
+
use: { system: SCHEMA_NS, code: kind },
|
|
293
|
+
value: v,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (designation.length) out.designation = designation;
|
|
298
|
+
|
|
299
|
+
const property = [];
|
|
300
|
+
const put = (c_, k, v) => { if (v != null && v !== '') property.push({ code: c_, [k]: v }); };
|
|
301
|
+
put('entityId', 'valueString', c.entity_id);
|
|
302
|
+
put('uri', 'valueString', c.uri);
|
|
303
|
+
if (this.opts.codeForm !== 'short') put('shortCode', 'valueString', c.code);
|
|
304
|
+
if (c.class_kind) property.push({ code: 'classKind',
|
|
305
|
+
valueCoding: { system: SCHEMA_NS, code: c.class_kind } });
|
|
306
|
+
if (!c.selectable) property.push({ code: 'notSelectable', valueBoolean: true });
|
|
307
|
+
put('blockId', 'valueString', c.block_id);
|
|
308
|
+
put('codeRange', 'valueString', c.code_range);
|
|
309
|
+
put('foundationUri', 'valueString', c.source_uri);
|
|
310
|
+
put('browserUrl', 'valueString', c.browser_url);
|
|
311
|
+
for (const s of ctx.scales.get(c.id) || []) {
|
|
312
|
+
property.push({ code: 'postcoordinationScale', valueString: s.value_set_uri });
|
|
313
|
+
if (s.required) property.push({ code: 'requiredPostcoordination', valueString: s.value_set_uri });
|
|
314
|
+
}
|
|
315
|
+
// A flat resource has to carry the hierarchy in properties; a nested one does not.
|
|
316
|
+
if (!nested) {
|
|
317
|
+
for (const p of ctx.parents.get(c.id) || []) {
|
|
318
|
+
const pc = this.codeOf(ctx.byId.get(p));
|
|
319
|
+
if (pc != null) property.push({ code: 'parent', valueCode: pc });
|
|
320
|
+
}
|
|
321
|
+
for (const k of ctx.kids.get(c.id) || []) {
|
|
322
|
+
const kc = this.codeOf(ctx.byId.get(k));
|
|
323
|
+
if (kc != null) property.push({ code: 'child', valueCode: kc });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (property.length) out.property = property;
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// -- the base resource ----------------------------------------------------
|
|
331
|
+
|
|
332
|
+
async exportSystem(sys, languages) {
|
|
333
|
+
const baseLang = languages[0];
|
|
334
|
+
const nested = !!sys.is_tree && this.opts.hierarchy !== 'flat';
|
|
335
|
+
const ctx = this.loadConcepts(sys.id);
|
|
336
|
+
ctx.desig = this.loadDesignations(sys.id, baseLang);
|
|
337
|
+
ctx.scales = this.loadScales(sys.id);
|
|
338
|
+
ctx.lang = baseLang;
|
|
339
|
+
|
|
340
|
+
const count = ctx.rows.filter(r => this.codeOf(r) != null).length;
|
|
341
|
+
const base = `CodeSystem-icd11-${sys.code}-${sys.version}.json`;
|
|
342
|
+
const file = path.join(this.dir, base);
|
|
343
|
+
this.index.push({ filename: base, resourceType: 'CodeSystem',
|
|
344
|
+
id: `icd11-${sys.code}-${sys.version}`, url: sys.url, version: sys.version });
|
|
345
|
+
const { stream, target, finished } = openOut(file, this.opts.gzip);
|
|
346
|
+
const w = new JsonWriter(stream, this.opts.pretty);
|
|
347
|
+
const nl = this.opts.pretty ? '\n' : '';
|
|
348
|
+
const ind = this.opts.pretty ? ' ' : '';
|
|
349
|
+
|
|
350
|
+
const header = {
|
|
351
|
+
resourceType: 'CodeSystem',
|
|
352
|
+
id: `icd11-${sys.code}-${sys.version}`,
|
|
353
|
+
url: sys.url,
|
|
354
|
+
version: sys.version,
|
|
355
|
+
name: `ICD11${sys.code.toUpperCase()}`,
|
|
356
|
+
title: sys.title,
|
|
357
|
+
status: 'active',
|
|
358
|
+
experimental: false,
|
|
359
|
+
date: this.meta('importStarted') || new Date().toISOString(),
|
|
360
|
+
publisher: 'World Health Organization',
|
|
361
|
+
contact: [{ telecom: [{ system: 'url', value: 'https://icd.who.int' }] }],
|
|
362
|
+
description: `${sys.title}. Generated from the WHO ICD-API by export-icd11-codesystem ${TOOL_VERSION}.`,
|
|
363
|
+
copyright: 'World Health Organization. ICD-11 is licensed under CC BY-ND 3.0 IGO. '
|
|
364
|
+
+ 'The licence prohibits distributing mappings to or from other classifications, '
|
|
365
|
+
+ 'and translations require a separate written agreement with WHO.',
|
|
366
|
+
caseSensitive: this.opts.caseSensitive,
|
|
367
|
+
valueSet: this.opts.valueSet ? `${sys.url}/vs` : undefined,
|
|
368
|
+
hierarchyMeaning: sys.code === 'foundation' ? 'is-a' : 'classified-with',
|
|
369
|
+
compositional: sys.code !== 'foundation',
|
|
370
|
+
versionNeeded: true,
|
|
371
|
+
content: 'complete',
|
|
372
|
+
count,
|
|
373
|
+
filter: this.filters(),
|
|
374
|
+
property: this.properties(sys),
|
|
375
|
+
};
|
|
376
|
+
for (const k of Object.keys(header)) if (header[k] === undefined) delete header[k];
|
|
377
|
+
|
|
378
|
+
// header, then the concept array streamed one entry at a time
|
|
379
|
+
const head = JSON.stringify(header, null, this.opts.pretty ? 2 : 0);
|
|
380
|
+
await w.write(head.slice(0, head.lastIndexOf('}')).replace(/\s*$/, '') + `,${nl}${ind}"concept": [`);
|
|
381
|
+
|
|
382
|
+
let written = 0, first = true;
|
|
383
|
+
const emit = async (json, depth) => {
|
|
384
|
+
await w.write((first ? '' : ',') + nl + (this.opts.pretty ? ind.repeat(depth + 1) : '')
|
|
385
|
+
+ w.value(json, this.opts.pretty ? 2 * (depth + 1) : 0));
|
|
386
|
+
first = false;
|
|
387
|
+
if (++written % 20000 === 0) this.log(` ${written}/${count}`);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (nested) {
|
|
391
|
+
// Depth-first, emitting each concept with its children inline. Children are written
|
|
392
|
+
// by hand rather than nested into the parent object, so no subtree is ever fully
|
|
393
|
+
// materialised in memory.
|
|
394
|
+
const self = this;
|
|
395
|
+
const walk = async (id, depth) => {
|
|
396
|
+
const c = ctx.byId.get(id);
|
|
397
|
+
const json = this.conceptJson(c, ctx, { nested: true });
|
|
398
|
+
if (!json) { // a concept with no code under --code-form short: skip it, keep its children
|
|
399
|
+
for (const k of ctx.kids.get(id) || []) await walk(k, depth);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const kids = ctx.kids.get(id) || [];
|
|
403
|
+
const body = w.value(json, this.opts.pretty ? 2 * (depth + 1) : 0);
|
|
404
|
+
const pad = this.opts.pretty ? ind.repeat(depth + 1) : '';
|
|
405
|
+
if (!kids.length) {
|
|
406
|
+
await w.write((first ? '' : ',') + nl + pad + body);
|
|
407
|
+
first = false;
|
|
408
|
+
} else {
|
|
409
|
+
await w.write((first ? '' : ',') + nl + pad
|
|
410
|
+
+ body.slice(0, body.lastIndexOf('}')).replace(/\s*$/, '')
|
|
411
|
+
+ `,${nl}${this.opts.pretty ? ind.repeat(depth + 2) : ''}"concept": [`);
|
|
412
|
+
const outerFirst = first; first = true;
|
|
413
|
+
for (const k of kids) await walk(k, depth + 2);
|
|
414
|
+
first = outerFirst;
|
|
415
|
+
await w.write(`${nl}${this.opts.pretty ? ind.repeat(depth + 2) : ''}]${nl}${pad}}`);
|
|
416
|
+
first = false;
|
|
417
|
+
}
|
|
418
|
+
if (++written % 20000 === 0) this.log(` ${written}/${count}`);
|
|
419
|
+
};
|
|
420
|
+
for (const r of ctx.roots) await walk(r, 0);
|
|
421
|
+
} else {
|
|
422
|
+
for (const c of ctx.rows) {
|
|
423
|
+
const json = this.conceptJson(c, ctx, { nested: false });
|
|
424
|
+
if (json) await emit(json, 0);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
await w.write(`${nl}${ind}]${nl}}${nl}`);
|
|
429
|
+
await w.end();
|
|
430
|
+
await finished;
|
|
431
|
+
const size = fs.statSync(target).size;
|
|
432
|
+
this.log(` ${path.basename(target)} ${written} concepts, ${(size / 1048576).toFixed(1)} MB`
|
|
433
|
+
+ ` [${nested ? 'nested' : 'flat'}, ${this.opts.codeForm}]`);
|
|
434
|
+
return { target, written, ctx };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// -- supplements ----------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
async exportSupplement(sys, lang, ctx) {
|
|
440
|
+
const desig = this.loadDesignations(sys.id, lang);
|
|
441
|
+
const base = `CodeSystem-icd11-${sys.code}-${sys.version}-${lang}.json`;
|
|
442
|
+
const file = path.join(this.dir, base);
|
|
443
|
+
this.index.push({ filename: base, resourceType: 'CodeSystem',
|
|
444
|
+
id: `icd11-${sys.code}-${sys.version}-${lang}`,
|
|
445
|
+
url: `${sys.url}/supplement/${lang}`, version: sys.version });
|
|
446
|
+
const { stream, target, finished } = openOut(file, this.opts.gzip);
|
|
447
|
+
const w = new JsonWriter(stream, this.opts.pretty);
|
|
448
|
+
const nl = this.opts.pretty ? '\n' : '';
|
|
449
|
+
const ind = this.opts.pretty ? ' ' : '';
|
|
450
|
+
|
|
451
|
+
const header = {
|
|
452
|
+
resourceType: 'CodeSystem',
|
|
453
|
+
id: `icd11-${sys.code}-${sys.version}-${lang}`,
|
|
454
|
+
url: `${sys.url}/supplement/${lang}`,
|
|
455
|
+
version: sys.version,
|
|
456
|
+
name: `ICD11${sys.code.toUpperCase()}${lang.toUpperCase()}`,
|
|
457
|
+
title: `${sys.title} -- ${lang}`,
|
|
458
|
+
status: 'active',
|
|
459
|
+
experimental: false,
|
|
460
|
+
language: lang,
|
|
461
|
+
date: this.meta('importStarted') || new Date().toISOString(),
|
|
462
|
+
publisher: 'World Health Organization',
|
|
463
|
+
description: `${lang} language supplement for ${sys.url}.`,
|
|
464
|
+
copyright: 'World Health Organization. Translations of ICD-11 are produced under WHO '
|
|
465
|
+
+ 'control; redistributing a translation requires a separate written agreement with WHO.',
|
|
466
|
+
content: 'supplement',
|
|
467
|
+
supplements: `${sys.url}|${sys.version}`,
|
|
468
|
+
};
|
|
469
|
+
const head = JSON.stringify(header, null, this.opts.pretty ? 2 : 0);
|
|
470
|
+
await w.write(head.slice(0, head.lastIndexOf('}')).replace(/\s*$/, '') + `,${nl}${ind}"concept": [`);
|
|
471
|
+
|
|
472
|
+
let written = 0, first = true;
|
|
473
|
+
for (const c of ctx.rows) {
|
|
474
|
+
const code = this.codeOf(c);
|
|
475
|
+
if (code == null) continue;
|
|
476
|
+
const d = desig.get(c.id);
|
|
477
|
+
if (!d) continue;
|
|
478
|
+
const designation = [];
|
|
479
|
+
const title = d.title && d.title[0];
|
|
480
|
+
if (title) designation.push({ language: lang, value: title });
|
|
481
|
+
if (d.definition && d.definition[0]) {
|
|
482
|
+
designation.push({ language: lang, use: { system: SCHEMA_NS, code: 'definition' }, value: d.definition[0] });
|
|
483
|
+
}
|
|
484
|
+
for (const kind of DESIGNATION_KINDS) {
|
|
485
|
+
for (const v of d[kind] || []) {
|
|
486
|
+
designation.push({ language: lang, use: { system: SCHEMA_NS, code: kind }, value: v });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (!designation.length) continue;
|
|
490
|
+
await w.write((first ? '' : ',') + nl + (this.opts.pretty ? ind.repeat(2) : '')
|
|
491
|
+
+ w.value({ code, designation }, this.opts.pretty ? 4 : 0));
|
|
492
|
+
first = false;
|
|
493
|
+
written++;
|
|
494
|
+
}
|
|
495
|
+
await w.write(`${nl}${ind}]${nl}}${nl}`);
|
|
496
|
+
await w.end();
|
|
497
|
+
await finished;
|
|
498
|
+
const size = fs.statSync(target).size;
|
|
499
|
+
this.log(` ${path.basename(target)} ${written} concepts, ${(size / 1048576).toFixed(1)} MB [supplement]`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The vocabulary the exported resources lean on: the designation.use codes and the
|
|
504
|
+
* classKind values. Without this, every designation raises "a definition for CodeSystem
|
|
505
|
+
* http://id.who.int/icd/schema could not be found" and the codes cannot be validated.
|
|
506
|
+
*/
|
|
507
|
+
writeSchemaCodeSystem() {
|
|
508
|
+
const cs = {
|
|
509
|
+
resourceType: 'CodeSystem',
|
|
510
|
+
id: 'icd11-schema',
|
|
511
|
+
url: SCHEMA_NS,
|
|
512
|
+
version: this.meta('release') || '2026-01',
|
|
513
|
+
name: 'ICD11Schema',
|
|
514
|
+
title: 'ICD-11 schema vocabulary',
|
|
515
|
+
status: 'active',
|
|
516
|
+
experimental: false,
|
|
517
|
+
publisher: 'World Health Organization',
|
|
518
|
+
description: 'The designation kinds and class kinds used by the generated ICD-11 CodeSystem '
|
|
519
|
+
+ 'resources. These names are WHO\'s own, taken from the ICD-API entity model.',
|
|
520
|
+
caseSensitive: true,
|
|
521
|
+
content: 'complete',
|
|
522
|
+
concept: [
|
|
523
|
+
{ code: 'title', display: 'Title', definition: 'The preferred display for the entity.' },
|
|
524
|
+
{ code: 'definition', display: 'Definition', definition: 'The short definition of the entity.' },
|
|
525
|
+
{ code: 'longDefinition', display: 'Long definition' },
|
|
526
|
+
{ code: 'fullySpecifiedName', display: 'Fully specified name' },
|
|
527
|
+
{ code: 'codingNote', display: 'Coding note', definition: 'Guidance to the coder, not a synonym.' },
|
|
528
|
+
{ code: 'diagnosticCriteria', display: 'Diagnostic criteria' },
|
|
529
|
+
{ code: 'inclusion', display: 'Inclusion term', definition: 'A term the entity explicitly includes.' },
|
|
530
|
+
{ code: 'indexTerm', display: 'Index term', definition: 'A term the alphabetical index maps to this entity. These are what the ICD Browser searches on.' },
|
|
531
|
+
{ code: 'chapter', display: 'Chapter', definition: 'A top-level division of the classification.' },
|
|
532
|
+
{ code: 'block', display: 'Block', definition: 'A grouper. Has no short code and cannot be used to code with.' },
|
|
533
|
+
{ code: 'window', display: 'Window', definition: 'A presentation grouper. Cannot be used to code with.' },
|
|
534
|
+
{ code: 'category', display: 'Category', definition: 'A codeable entity.' },
|
|
535
|
+
],
|
|
536
|
+
};
|
|
537
|
+
cs.count = cs.concept.length;
|
|
538
|
+
const file = path.join(this.dir, 'CodeSystem-icd11-schema.json');
|
|
539
|
+
fs.writeFileSync(file, JSON.stringify(cs, null, 2) + '\n');
|
|
540
|
+
this.index.push({ filename: 'CodeSystem-icd11-schema.json', resourceType: 'CodeSystem',
|
|
541
|
+
id: cs.id, url: cs.url, version: cs.version });
|
|
542
|
+
this.log(` ${path.basename(file)} ${cs.count} concepts [schema vocabulary]`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// -- postcoordination value sets -----------------------------------------
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* One ValueSet per postcoordination axis per stem -- 25,354 of them for 2026-01.
|
|
549
|
+
*
|
|
550
|
+
* These are compose-only. Pre-expanding the set would be 14.6 million expansion rows
|
|
551
|
+
* (1,743 axes have over a thousand members each), so membership is expressed the way
|
|
552
|
+
* WHO expresses it: one `concept is-a` include per scale root.
|
|
553
|
+
*
|
|
554
|
+
* The axis rules -- whether the axis is required, and whether it admits more than one
|
|
555
|
+
* value -- have nowhere to live in a ValueSet, so they go on as extensions. A validator
|
|
556
|
+
* checking a postcoordinated expression needs them.
|
|
557
|
+
*/
|
|
558
|
+
writeValueSets(sys) {
|
|
559
|
+
const rows = this.db.prepare(`
|
|
560
|
+
SELECT ps.id, ps.axis, ps.axis_name, ps.required, ps.allow_multiple, ps.value_set_uri,
|
|
561
|
+
c.id AS cid, c.entity_id, c.uri AS stem_uri, c.code AS stem_code
|
|
562
|
+
FROM pc_scale ps JOIN concept c ON c.id = ps.concept
|
|
563
|
+
WHERE c.system = ? ORDER BY c.id, ps.seq`).all(sys.id);
|
|
564
|
+
if (!rows.length) return 0;
|
|
565
|
+
|
|
566
|
+
const entities = new Map();
|
|
567
|
+
for (const r of this.db.prepare(`
|
|
568
|
+
SELECT e.scale, e.target_uri, c.code, c.entity_id, c.uri
|
|
569
|
+
FROM pc_scale_entity e
|
|
570
|
+
JOIN pc_scale ps ON ps.id = e.scale
|
|
571
|
+
JOIN concept s ON s.id = ps.concept
|
|
572
|
+
LEFT JOIN concept c ON c.id = e.target
|
|
573
|
+
WHERE s.system = ? ORDER BY e.scale, e.seq`).iterate(sys.id)) {
|
|
574
|
+
(entities.get(r.scale) ?? entities.set(r.scale, []).get(r.scale)).push(r);
|
|
575
|
+
}
|
|
576
|
+
const titleOf = new Map();
|
|
577
|
+
for (const r of this.db.prepare(`
|
|
578
|
+
SELECT d.concept, d.value FROM designation d JOIN concept c ON c.id = d.concept
|
|
579
|
+
WHERE c.system = ? AND d.lang = ? AND d.kind = 'title'`).iterate(sys.id, this.baseLang)) {
|
|
580
|
+
if (!titleOf.has(r.concept)) titleOf.set(r.concept, r.value);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const date = this.meta('importStarted') || new Date().toISOString();
|
|
584
|
+
const seen = new Set();
|
|
585
|
+
let n = 0, longest = 0, hashed = 0;
|
|
586
|
+
for (const r of rows) {
|
|
587
|
+
// FHIR ids are capped at 64 chars. Rather than truncate and hope, anything over the
|
|
588
|
+
// limit keeps a deterministic hash of the full name so two long axis names on one
|
|
589
|
+
// stem can never collide.
|
|
590
|
+
let id = `icd11-${sys.code}-${r.entity_id.replace(/\//g, '-')}-${r.axis_name}`
|
|
591
|
+
.replace(/[^A-Za-z0-9.-]/g, '-');
|
|
592
|
+
if (id.length > 64) {
|
|
593
|
+
const h = crypto.createHash('sha1').update(id).digest('hex').slice(0, 7);
|
|
594
|
+
id = `${id.slice(0, 56)}-${h}`;
|
|
595
|
+
}
|
|
596
|
+
if (seen.has(id)) throw new Error(`duplicate ValueSet id ${id}`);
|
|
597
|
+
seen.add(id);
|
|
598
|
+
longest = Math.max(longest, id.length);
|
|
599
|
+
if (id.length === 64 && id[56] === '-') hashed++;
|
|
600
|
+
const stemLabel = titleOf.get(r.cid) || r.entity_id;
|
|
601
|
+
const include = (entities.get(r.id) || []).map(e => ({
|
|
602
|
+
system: sys.url,
|
|
603
|
+
version: sys.version,
|
|
604
|
+
filter: [{ property: 'concept', op: 'is-a',
|
|
605
|
+
value: e.code == null && e.uri == null ? e.target_uri
|
|
606
|
+
: this.codeOf({ code: e.code, uri: e.uri, entity_id: e.entity_id }) }],
|
|
607
|
+
})).filter(i => i.filter[0].value != null);
|
|
608
|
+
|
|
609
|
+
const vs = {
|
|
610
|
+
resourceType: 'ValueSet',
|
|
611
|
+
id,
|
|
612
|
+
extension: [
|
|
613
|
+
{ url: `${SCHEMA_NS}/postcoordinationAxis`, valueUri: r.axis },
|
|
614
|
+
{ url: `${SCHEMA_NS}/stem`, valueUri: r.stem_uri },
|
|
615
|
+
{ url: `${SCHEMA_NS}/requiredPostcoordination`, valueBoolean: !!r.required },
|
|
616
|
+
{ url: `${SCHEMA_NS}/allowMultipleValues`, valueCode: r.allow_multiple || 'NotAllowed' },
|
|
617
|
+
],
|
|
618
|
+
url: r.value_set_uri,
|
|
619
|
+
version: sys.version,
|
|
620
|
+
// ValueSet.name is bound to [A-Z]([A-Za-z0-9_]){1,254}
|
|
621
|
+
name: 'ICD11_' + id.replace(/^icd11-/, '').replace(/[^A-Za-z0-9]/g, '_'),
|
|
622
|
+
title: `Postcoordination scale ${r.axis_name} for ${r.stem_code || r.entity_id}`,
|
|
623
|
+
status: 'active',
|
|
624
|
+
experimental: false,
|
|
625
|
+
date,
|
|
626
|
+
publisher: 'World Health Organization',
|
|
627
|
+
description: `Values permitted on the ${r.axis_name} postcoordination axis of `
|
|
628
|
+
+ `${r.stem_code ? r.stem_code + ' ' : ''}${stemLabel}. `
|
|
629
|
+
+ `${r.required ? 'WHO marks this axis as required, which in practice means strongly '
|
|
630
|
+
+ 'encouraged: the stem code stays valid without it. ' : ''}`
|
|
631
|
+
+ `Multiple values: ${r.allow_multiple || 'NotAllowed'}.`,
|
|
632
|
+
compose: { include },
|
|
633
|
+
};
|
|
634
|
+
fs.writeFileSync(path.join(this.dir, `ValueSet-${id}.json`), JSON.stringify(vs) + '\n');
|
|
635
|
+
this.index.push({ filename: `ValueSet-${id}.json`, resourceType: 'ValueSet',
|
|
636
|
+
id, url: vs.url, version: vs.version });
|
|
637
|
+
if (++n % 10000 === 0) this.log(` ${n}/${rows.length}`);
|
|
638
|
+
}
|
|
639
|
+
this.log(` ${n} ValueSets [compose-only, longest id ${longest} chars`
|
|
640
|
+
+ `${hashed ? `, ${hashed} hash-suffixed` : ''}]`);
|
|
641
|
+
return n;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* StructureDefinitions for the four extensions the ValueSets carry. An undefined
|
|
646
|
+
* extension is an ERROR to the validator ("could not be found so is not allowed here"),
|
|
647
|
+
* not a warning, so a package that uses extensions has to define them to be loadable.
|
|
648
|
+
*/
|
|
649
|
+
writeExtensionDefinitions() {
|
|
650
|
+
const exts = [
|
|
651
|
+
['postcoordinationAxis', 'uri', 'Postcoordination axis',
|
|
652
|
+
'The WHO schema uri of the postcoordination axis this value set enumerates.'],
|
|
653
|
+
['stem', 'uri', 'Stem concept',
|
|
654
|
+
'The concept whose postcoordination axis this value set belongs to.'],
|
|
655
|
+
['requiredPostcoordination', 'boolean', 'Required postcoordination',
|
|
656
|
+
'Whether WHO marks this axis as required. In practice this means strongly encouraged: '
|
|
657
|
+
+ 'the stem code remains valid without a value on the axis.'],
|
|
658
|
+
['allowMultipleValues', 'code', 'Allow multiple values',
|
|
659
|
+
'AllowAlways | NotAllowed | AllowedExceptFromSameBlock -- whether more than one value '
|
|
660
|
+
+ 'may be given on this axis of this stem.'],
|
|
661
|
+
];
|
|
662
|
+
for (const [name, type, title, desc] of exts) {
|
|
663
|
+
const id = `icd11-${name}`;
|
|
664
|
+
const url = `${SCHEMA_NS}/${name}`;
|
|
665
|
+
const sd = {
|
|
666
|
+
resourceType: 'StructureDefinition',
|
|
667
|
+
id, url,
|
|
668
|
+
version: this.meta('release') || '2026-01',
|
|
669
|
+
name: `ICD11${name[0].toUpperCase()}${name.slice(1)}`,
|
|
670
|
+
title, status: 'active', experimental: false,
|
|
671
|
+
publisher: 'World Health Organization',
|
|
672
|
+
description: desc,
|
|
673
|
+
fhirVersion: '5.0.0',
|
|
674
|
+
kind: 'complex-type', abstract: false,
|
|
675
|
+
context: [{ type: 'element', expression: 'ValueSet' }],
|
|
676
|
+
type: 'Extension',
|
|
677
|
+
baseDefinition: 'http://hl7.org/fhir/StructureDefinition/Extension',
|
|
678
|
+
derivation: 'constraint',
|
|
679
|
+
differential: { element: [
|
|
680
|
+
{ id: 'Extension', path: 'Extension', short: title, definition: desc, min: 0, max: '1' },
|
|
681
|
+
{ id: 'Extension.extension', path: 'Extension.extension', max: '0' },
|
|
682
|
+
{ id: 'Extension.url', path: 'Extension.url', fixedUri: url },
|
|
683
|
+
{ id: 'Extension.value[x]', path: 'Extension.value[x]', min: 1, max: '1',
|
|
684
|
+
type: [{ code: type }] },
|
|
685
|
+
] },
|
|
686
|
+
};
|
|
687
|
+
fs.writeFileSync(path.join(this.dir, `StructureDefinition-${id}.json`),
|
|
688
|
+
JSON.stringify(sd, null, 2) + '\n');
|
|
689
|
+
this.index.push({ filename: `StructureDefinition-${id}.json`,
|
|
690
|
+
resourceType: 'StructureDefinition', id, url, version: sd.version });
|
|
691
|
+
}
|
|
692
|
+
this.log(` ${exts.length} extension StructureDefinitions`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// -- package --------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
writePackageJson(systems) {
|
|
698
|
+
const pkg = {
|
|
699
|
+
name: this.opts.packageId,
|
|
700
|
+
version: this.opts.packageVersion,
|
|
701
|
+
description: 'ICD-11 as FHIR CodeSystem and ValueSet resources, generated from the WHO '
|
|
702
|
+
+ `ICD-API release ${this.meta('release')} by export-icd11-codesystem ${TOOL_VERSION}. `
|
|
703
|
+
+ 'Not a WHO publication.',
|
|
704
|
+
fhirVersions: ['5.0.0'],
|
|
705
|
+
type: 'fhir.ig',
|
|
706
|
+
canonical: 'http://id.who.int/icd',
|
|
707
|
+
url: 'https://icd.who.int',
|
|
708
|
+
author: 'World Health Organization (content); generated by FHIRsmith tooling',
|
|
709
|
+
license: 'CC-BY-ND-3.0-IGO',
|
|
710
|
+
dependencies: { 'hl7.fhir.r5.core': '5.0.0' },
|
|
711
|
+
'icd11:systems': systems,
|
|
712
|
+
'icd11:release': this.meta('release'),
|
|
713
|
+
'icd11:sourceEndpoint': this.meta('nativeEndpoint'),
|
|
714
|
+
};
|
|
715
|
+
fs.writeFileSync(path.join(this.dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
writeIndex() {
|
|
719
|
+
fs.writeFileSync(path.join(this.dir, '.index.json'),
|
|
720
|
+
JSON.stringify({ 'index-version': 2, files: this.index }, null, 2) + '\n');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// -- driver ---------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
async run() {
|
|
726
|
+
const t0 = Date.now();
|
|
727
|
+
if (!this.opts.packageVersion) {
|
|
728
|
+
const rel = this.meta('release') || '0.0.0';
|
|
729
|
+
const m = rel.match(/^(\d{4})-(\d{2})$/);
|
|
730
|
+
this.opts.packageVersion = m ? `${m[1]}.${parseInt(m[2], 10)}.0` : rel;
|
|
731
|
+
}
|
|
732
|
+
const available = (this.meta('languages') || 'en').split(',');
|
|
733
|
+
const languages = this.opts.languages === 'all'
|
|
734
|
+
? available
|
|
735
|
+
: this.opts.languages.split(',').map(s => s.trim()).filter(Boolean);
|
|
736
|
+
const wanted = this.opts.systems.split(',').map(s => s.trim()).filter(Boolean);
|
|
737
|
+
this.baseLang = languages[0];
|
|
738
|
+
this.index = [];
|
|
739
|
+
|
|
740
|
+
// When packaging, every resource goes under out/package/ so the directory can be
|
|
741
|
+
// tarred as-is into a FHIR NPM package.
|
|
742
|
+
this.dir = this.opts.package ? path.join(this.opts.out, 'package') : this.opts.out;
|
|
743
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
744
|
+
|
|
745
|
+
this.log(`ICD-11 FHIR export`);
|
|
746
|
+
this.log(` source : ${this.opts.src}`);
|
|
747
|
+
this.log(` out : ${this.dir}`);
|
|
748
|
+
this.log(` code form : ${this.opts.codeForm}`);
|
|
749
|
+
this.log(` languages : ${languages.join(', ')} (base ${this.baseLang})`);
|
|
750
|
+
this.log(` value sets: ${this.opts.valueSets ? 'yes' : 'no'}`
|
|
751
|
+
+ ` package: ${this.opts.package ? `${this.opts.packageId}#${this.opts.packageVersion}` : 'no'}`);
|
|
752
|
+
|
|
753
|
+
this.log('');
|
|
754
|
+
this.writeSchemaCodeSystem();
|
|
755
|
+
if (this.opts.valueSets) this.writeExtensionDefinitions();
|
|
756
|
+
|
|
757
|
+
const done = [];
|
|
758
|
+
for (const code of wanted) {
|
|
759
|
+
const sys = this.db.prepare('SELECT * FROM system WHERE code=?').get(code);
|
|
760
|
+
if (!sys) { this.log(` ! ${code} is not in this database, skipping`); continue; }
|
|
761
|
+
this.log(`\n${sys.code} -- ${sys.title}`);
|
|
762
|
+
const { ctx } = await this.exportSystem(sys, languages);
|
|
763
|
+
for (const lang of languages.slice(1)) await this.exportSupplement(sys, lang, ctx);
|
|
764
|
+
if (this.opts.valueSets) this.writeValueSets(sys);
|
|
765
|
+
done.push(sys.code);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (this.opts.package) {
|
|
769
|
+
this.writePackageJson(done);
|
|
770
|
+
this.writeIndex();
|
|
771
|
+
const tgz = path.join(this.opts.out, `${this.opts.packageId}-${this.opts.packageVersion}.tgz`);
|
|
772
|
+
this.log(`\npackaging ${this.index.length + 2} files...`);
|
|
773
|
+
await tarGzDirectory(this.dir, tgz, 'package');
|
|
774
|
+
const size = fs.statSync(tgz).size;
|
|
775
|
+
this.log(` ${path.basename(tgz)} ${(size / 1048576).toFixed(1)} MB`);
|
|
776
|
+
}
|
|
777
|
+
this.log(`\ndone in ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
778
|
+
this.db.close();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// cli
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
function main(argv) {
|
|
787
|
+
const program = new Command();
|
|
788
|
+
program
|
|
789
|
+
.name('export-icd11-codesystem')
|
|
790
|
+
.description('Write FHIR CodeSystem resources from an ICD-11 import database')
|
|
791
|
+
.requiredOption('-s, --src <file>', 'the database built by import-icd11.js')
|
|
792
|
+
.requiredOption('-o, --out <dir>', 'output directory')
|
|
793
|
+
.option('--systems <list>', 'which code systems to export', 'mms,icf,foundation')
|
|
794
|
+
.option('-l, --languages <list>', "comma separated, or 'all'; the first is the base language", 'all')
|
|
795
|
+
.option('--code-form <form>', 'hybrid | short | uri | id -- what goes in concept.code', 'hybrid')
|
|
796
|
+
.option('--hierarchy <how>', 'nested | flat (the Foundation is always flat)', 'nested')
|
|
797
|
+
.option('--no-value-set', 'do not declare an all-codes value set in CodeSystem.valueSet')
|
|
798
|
+
.option('--case-sensitive <b>', 'value for CodeSystem.caseSensitive', v => v !== 'false', true)
|
|
799
|
+
.option('--no-value-sets', 'skip the postcoordination scale ValueSets')
|
|
800
|
+
.option('--package', 'assemble the output as a FHIR NPM package (.tgz)', false)
|
|
801
|
+
.option('--package-id <id>', 'package name', 'who.icd11')
|
|
802
|
+
.option('--package-version <v>', 'package version (semver); default derived from the release')
|
|
803
|
+
.option('--gzip', 'gzip individual files (ignored when --package is set)', false)
|
|
804
|
+
.option('--pretty', 'pretty print (much larger)', false);
|
|
805
|
+
|
|
806
|
+
program.parse(argv);
|
|
807
|
+
const opts = program.opts();
|
|
808
|
+
if (!['hybrid', 'short', 'uri', 'id'].includes(opts.codeForm)) {
|
|
809
|
+
throw new Error(`--code-form must be hybrid, short, uri or id`);
|
|
810
|
+
}
|
|
811
|
+
// A package holds plain JSON; the tarball does the compressing.
|
|
812
|
+
if (opts.package) opts.gzip = false;
|
|
813
|
+
return new CodeSystemExporter(opts).run();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (require.main === module) {
|
|
817
|
+
main(process.argv).catch(e => {
|
|
818
|
+
console.error(`\nexport failed: ${e && e.stack || e}`);
|
|
819
|
+
process.exit(1);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
module.exports = { CodeSystemExporter, main };
|