fhirsmith 0.17.0 → 0.18.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 +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/ips/profiles.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
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
|
+
* Clinical profiles: what a clinical document is expected to contain, as a profile on
|
|
9
|
+
* Bundle (the document) whose root element carries the expectations as constraints - each
|
|
10
|
+
* a human description and a FHIRPath expression on the document, most of them in one of
|
|
11
|
+
* three shapes:
|
|
12
|
+
*
|
|
13
|
+
* Bundle.section(system, code [, min, max]).exists()
|
|
14
|
+
* Bundle.section(system, code [, min, max]).entry(system, code [, min, max]).exists()
|
|
15
|
+
* Bundle.section(system, code [, min, max]).entryIn(valueSet [, min, max]).exists()
|
|
16
|
+
* Bundle.resources(system, code [, type]).{the rest of the rule}
|
|
17
|
+
* Bundle.resourcesIn(valueSet [, type]).{the rest of the rule}
|
|
18
|
+
*
|
|
19
|
+
* (section(), entry() and resources() are the module's own functions: fhirpath-ips.js;
|
|
20
|
+
* section() on a document looks in its Composition). An expression in one of the first two
|
|
21
|
+
* shapes is edited as a row of a table - the section's code and cardinality, the entry's
|
|
22
|
+
* code and cardinality; one in the third as a row of another - the type, system and code,
|
|
23
|
+
* and the rest of the rule; anything else as FHIRPath.
|
|
24
|
+
*
|
|
25
|
+
* The StructureDefinition's id ('profile-' + a random id), its url ([base]/StructureDefinition/{id}),
|
|
26
|
+
* its name (derived from the title) and the constraints' keys are the server's: the
|
|
27
|
+
* administrator edits the title and the constraints. An uploaded StructureDefinition must
|
|
28
|
+
* be no more than that - a profile on Bundle with constraints on its root element - or it
|
|
29
|
+
* is rejected; its id, url and keys are reassigned. Profiles made when they were on
|
|
30
|
+
* Composition are moved onto Bundle (migrate()).
|
|
31
|
+
*
|
|
32
|
+
* @module ips/profiles
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
const fhirpath = require('fhirpath');
|
|
37
|
+
const r4Model = require('fhirpath/fhir-context/r4');
|
|
38
|
+
const { ipsFunctions } = require('./fhirpath-ips');
|
|
39
|
+
const { SECTIONS, LOINC } = require('./editor-schema');
|
|
40
|
+
|
|
41
|
+
const BUNDLE = 'http://hl7.org/fhir/StructureDefinition/Bundle';
|
|
42
|
+
const OLD_BASE = 'http://hl7.org/fhir/StructureDefinition/Composition';
|
|
43
|
+
const MAX_TITLE = 200;
|
|
44
|
+
const MAX_HUMAN = 1000;
|
|
45
|
+
const MAX_EXPRESSION = 2000;
|
|
46
|
+
const MAX_CONSTRAINTS = 200;
|
|
47
|
+
const KEY_RE = /^cp-([1-9][0-9]{0,5})$/;
|
|
48
|
+
const ID_RE = /^[a-f0-9]{12}$/;
|
|
49
|
+
|
|
50
|
+
/** The code systems the editor offers for an entry's code (any other can be given by url). */
|
|
51
|
+
const SYSTEMS = [
|
|
52
|
+
{ url: 'http://snomed.info/sct', name: 'SNOMED CT' },
|
|
53
|
+
{ url: 'http://loinc.org', name: 'LOINC' },
|
|
54
|
+
{ url: 'http://hl7.org/fhir/sid/icd-10', name: 'ICD-10' },
|
|
55
|
+
{ url: 'http://id.who.int/icd/release/11/mms', name: 'ICD-11 MMS' },
|
|
56
|
+
{ url: 'http://www.whocc.no/atc', name: 'ATC' },
|
|
57
|
+
{ url: 'http://standardterms.edqm.eu', name: 'EDQM Standard Terms' },
|
|
58
|
+
{ url: 'http://www.nlm.nih.gov/research/umls/rxnorm', name: 'RxNorm' },
|
|
59
|
+
{ url: 'http://hl7.org/fhir/sid/cvx', name: 'CVX' },
|
|
60
|
+
{ url: 'http://unitsofmeasure.org', name: 'UCUM' }
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** The IPS sections, for the editor's shortcuts and the read-only view. */
|
|
64
|
+
const IPS_SECTIONS = SECTIONS.map(s => ({ code: s.code, title: s.title }));
|
|
65
|
+
|
|
66
|
+
function isObject(v) {
|
|
67
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- the two shapes ----
|
|
71
|
+
|
|
72
|
+
const STR = "'((?:[^'\\\\]|\\\\.)*)'";
|
|
73
|
+
const ARGS = `\\s*${STR}\\s*,\\s*${STR}\\s*(?:,\\s*(\\d{1,6})\\s*,\\s*(?:(\\d{1,6})|'\\*')\\s*)?`;
|
|
74
|
+
const CARD = `(?:,\\s*(\\d{1,6})\\s*,\\s*(?:(\\d{1,6})|'\\*')\\s*)?`;
|
|
75
|
+
// the entry part: entry(system, code [, min, max]) or entryIn(valueSet [, min, max])
|
|
76
|
+
const SHAPE_RE = new RegExp(`^\\s*Bundle\\s*\\.\\s*section\\s*\\(${ARGS}\\)\\s*(?:\\.\\s*(?:entry\\s*\\(${ARGS}\\)|entryIn\\s*\\(\\s*${STR}\\s*${CARD}\\))\\s*)?\\.\\s*exists\\s*\\(\\s*\\)\\s*$`);
|
|
77
|
+
// a value set's canonical, as entryIn() and resourcesIn() take it
|
|
78
|
+
const CANONICAL_RE = /^[a-z][a-z0-9+.-]*:[^\s|]{1,250}(\|[^\s|]{1,64})?$/i;
|
|
79
|
+
|
|
80
|
+
// a FHIRPath string's escapes: \' \" \` \\ \/ \f \n \r \t and \uXXXX
|
|
81
|
+
const ESCAPES = { n: '\n', r: '\r', t: '\t', f: '\f' };
|
|
82
|
+
function unquote(s) {
|
|
83
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (m, c) => (c.length === 5 ? String.fromCharCode(parseInt(c.substring(1), 16)) : (ESCAPES[c] || c)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function quote(s) {
|
|
87
|
+
return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function card(min, max, hasCard) {
|
|
91
|
+
return { min: hasCard ? parseInt(min, 10) : null, max: hasCard ? (max === undefined ? '*' : parseInt(max, 10)) : null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function part(system, code, min, max, hasCard) {
|
|
95
|
+
return { system: unquote(system), code: unquote(code), ...card(min, max, hasCard) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The parts of an expression in one of the two shapes, or null.
|
|
100
|
+
* @returns {{section: {system, code, min, max}, entry: {system, code, min, max}|{valueSet, min, max}|null}|null}
|
|
101
|
+
* min and max are null for no bounds; max is a number or '*'; an entry has a system and
|
|
102
|
+
* code, or a value set
|
|
103
|
+
*/
|
|
104
|
+
function parseShape(expression) {
|
|
105
|
+
if (typeof expression !== 'string' || expression.length > MAX_EXPRESSION) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const m = SHAPE_RE.exec(expression);
|
|
109
|
+
if (!m) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const section = part(m[1], m[2], m[3], m[4], m[3] !== undefined);
|
|
113
|
+
let entry = null;
|
|
114
|
+
if (m[5] !== undefined) {
|
|
115
|
+
entry = part(m[5], m[6], m[7], m[8], m[7] !== undefined);
|
|
116
|
+
} else if (m[9] !== undefined) {
|
|
117
|
+
entry = { valueSet: unquote(m[9]), ...card(m[10], m[11], m[10] !== undefined) };
|
|
118
|
+
if (!CANONICAL_RE.test(entry.valueSet)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const p of [section, entry].filter(Boolean)) {
|
|
123
|
+
if ((p.valueSet === undefined && (!p.system || !p.code)) || (p.min !== null && p.max !== '*' && p.max < p.min)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { section, entry };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TYPE = `(?:,\\s*'([A-Z][A-Za-z]{1,63})'\\s*)?`;
|
|
131
|
+
const REST = '(\\.\\s*\\S[\\s\\S]*?)';
|
|
132
|
+
const RSHAPE_RE = new RegExp(`^\\s*Bundle\\s*\\.\\s*resources\\s*\\(\\s*${STR}\\s*,\\s*${STR}\\s*${TYPE}\\)\\s*${REST}\\s*$`);
|
|
133
|
+
const RSHAPE_IN_RE = new RegExp(`^\\s*Bundle\\s*\\.\\s*resourcesIn\\s*\\(\\s*${STR}\\s*${TYPE}\\)\\s*${REST}\\s*$`);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The parts of a rule about resources, or null.
|
|
137
|
+
* @returns {{type: string|null, system: string, code: string, rest: string}|{type: string|null, valueSet: string, rest: string}|null}
|
|
138
|
+
* rest starts with '.'
|
|
139
|
+
*/
|
|
140
|
+
function parseResourceShape(expression) {
|
|
141
|
+
if (typeof expression !== 'string' || expression.length > MAX_EXPRESSION) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
let m = RSHAPE_RE.exec(expression);
|
|
145
|
+
if (m) {
|
|
146
|
+
const r = { type: m[3] || null, system: unquote(m[1]), code: unquote(m[2]), rest: m[4] };
|
|
147
|
+
return r.system && r.code ? r : null;
|
|
148
|
+
}
|
|
149
|
+
m = RSHAPE_IN_RE.exec(expression);
|
|
150
|
+
if (m) {
|
|
151
|
+
const r = { type: m[2] || null, valueSet: unquote(m[1]), rest: m[3] };
|
|
152
|
+
return CANONICAL_RE.test(r.valueSet) ? r : null;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The expression for a rule about resources. */
|
|
158
|
+
function resourceShapeExpression(r) {
|
|
159
|
+
const rest = String(r.rest || '').trim();
|
|
160
|
+
const type = r.type ? `, ${quote(r.type)}` : '';
|
|
161
|
+
const head = r.valueSet !== undefined ? `Bundle.resourcesIn(${quote(r.valueSet)}${type})` : `Bundle.resources(${quote(r.system)}, ${quote(r.code)}${type})`;
|
|
162
|
+
return head + (rest.startsWith('.') ? rest : '.' + rest);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Which part of the editor an expression belongs in: 'section', 'resource' or 'other'. */
|
|
166
|
+
function kindOf(expression) {
|
|
167
|
+
return parseShape(expression) ? 'section' : (parseResourceShape(expression) ? 'resource' : 'other');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cardArgs(p) {
|
|
171
|
+
return p.min !== null && p.min !== undefined ? `, ${p.min}, ${p.max === '*' ? "'*'" : p.max}` : '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function args(p) {
|
|
175
|
+
return `${quote(p.system)}, ${quote(p.code)}` + cardArgs(p);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The expression for a shape. */
|
|
179
|
+
function shapeExpression(shape) {
|
|
180
|
+
const e = shape.entry;
|
|
181
|
+
const entry = !e ? '' : (e.valueSet !== undefined ? `.entryIn(${quote(e.valueSet)}${cardArgs(e)})` : `.entry(${args(e)})`);
|
|
182
|
+
return `Bundle.section(${args(shape.section)})` + entry + '.exists()';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A cardinality as written: '' for none, else 'min..max'. */
|
|
186
|
+
function cardText(p) {
|
|
187
|
+
return p.min === null || p.min === undefined ? '' : `${p.min}..${p.max}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 'min..max' (max a number or *), or blank: {min, max} (nulls for blank), or null when it isn't one. */
|
|
191
|
+
function parseCard(text) {
|
|
192
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
193
|
+
return { min: null, max: null };
|
|
194
|
+
}
|
|
195
|
+
const m = /^\s*(\d{1,6})\s*\.\.\s*(\d{1,6}|\*)\s*$/.exec(text);
|
|
196
|
+
if (!m) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const min = parseInt(m[1], 10);
|
|
200
|
+
const max = m[2] === '*' ? '*' : parseInt(m[2], 10);
|
|
201
|
+
return max !== '*' && max < min ? null : { min, max };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- checking ----
|
|
205
|
+
|
|
206
|
+
/** A problem with an expression (it doesn't parse, or fails on an empty document), or null. */
|
|
207
|
+
function expressionProblem(expression) {
|
|
208
|
+
if (typeof expression !== 'string' || !expression.trim()) {
|
|
209
|
+
return 'has no FHIRPath expression';
|
|
210
|
+
}
|
|
211
|
+
if (expression.length > MAX_EXPRESSION) {
|
|
212
|
+
return `has a FHIRPath expression longer than ${MAX_EXPRESSION} characters`;
|
|
213
|
+
}
|
|
214
|
+
const userInvocationTable = ipsFunctions();
|
|
215
|
+
// fhirpath.js only warns (and returns nothing) when a function has the wrong number of
|
|
216
|
+
// arguments: the trial run is synchronous, so its warnings are caught here
|
|
217
|
+
const warnings = [];
|
|
218
|
+
const warn = console.warn;
|
|
219
|
+
console.warn = (...a) => warnings.push(a.join(' '));
|
|
220
|
+
try {
|
|
221
|
+
const compiled = fhirpath.compile(expression, r4Model, { userInvocationTable });
|
|
222
|
+
// a trial run on a document with nothing in it finds unknown functions and the like
|
|
223
|
+
void compiled({ resourceType: 'Bundle', type: 'document', entry: [{ fullUrl: 'urn:uuid:c', resource: { resourceType: 'Composition', status: 'final', section: [] } }] });
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return `has a FHIRPath expression that can't be used: ${String(e.message || e).split('\n')[0].substring(0, 200)}`;
|
|
226
|
+
} finally {
|
|
227
|
+
console.warn = warn;
|
|
228
|
+
}
|
|
229
|
+
const arity = warnings.find(w => /wrong arity/.test(w));
|
|
230
|
+
return arity ? `has a FHIRPath expression that can't be used: ${arity.replace(/ wrong arity: got (\d+)/, '() has the wrong number of arguments ($1)')}` : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A name (an identifier, as FHIR wants StructureDefinition.name) from a title. */
|
|
234
|
+
function nameFromTitle(title) {
|
|
235
|
+
const words = String(title || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^A-Za-z0-9 ]+/g, ' ').trim().split(/\s+/).filter(Boolean);
|
|
236
|
+
let name = words.map(w => w.charAt(0).toUpperCase() + w.substring(1)).join('');
|
|
237
|
+
if (!/^[A-Z]/.test(name)) {
|
|
238
|
+
name = 'Profile' + name;
|
|
239
|
+
}
|
|
240
|
+
return name.substring(0, 255);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A new private id. */
|
|
244
|
+
function newId() {
|
|
245
|
+
return crypto.randomBytes(6).toString('hex');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check what the editor (or an upload) gives - a title and constraints - and give each
|
|
250
|
+
* constraint its key: a key it already has in the profile it replaces is kept, the others
|
|
251
|
+
* get new ones.
|
|
252
|
+
*
|
|
253
|
+
* @param {{title: *, constraints: *}} input
|
|
254
|
+
* @param {Object|null} previous - the StructureDefinition being replaced
|
|
255
|
+
* @returns {{title: string, constraints: Array<{key, human, expression}>, problems: string[]}}
|
|
256
|
+
*/
|
|
257
|
+
function checkInput(input, previous = null) {
|
|
258
|
+
const problems = [];
|
|
259
|
+
const title = typeof input?.title === 'string' ? input.title.trim() : '';
|
|
260
|
+
if (!title) {
|
|
261
|
+
problems.push('The profile needs a title');
|
|
262
|
+
} else if (title.length > MAX_TITLE) {
|
|
263
|
+
problems.push(`The title is longer than ${MAX_TITLE} characters`);
|
|
264
|
+
}
|
|
265
|
+
const list = Array.isArray(input?.constraints) ? input.constraints : [];
|
|
266
|
+
if (input?.constraints !== undefined && !Array.isArray(input.constraints)) {
|
|
267
|
+
problems.push('The constraints must be a list');
|
|
268
|
+
}
|
|
269
|
+
if (list.length > MAX_CONSTRAINTS) {
|
|
270
|
+
problems.push(`There can be no more than ${MAX_CONSTRAINTS} constraints`);
|
|
271
|
+
}
|
|
272
|
+
const known = new Set(constraintsOf(previous).map(c => c.key));
|
|
273
|
+
let next = Math.max(0, ...[...known].map(k => parseInt(KEY_RE.exec(k)[1], 10))) + 1;
|
|
274
|
+
const used = new Set();
|
|
275
|
+
const constraints = [];
|
|
276
|
+
list.slice(0, MAX_CONSTRAINTS).forEach((c, i) => {
|
|
277
|
+
const n = i + 1;
|
|
278
|
+
if (!isObject(c)) {
|
|
279
|
+
problems.push(`Constraint ${n} is not an object`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const human = typeof c.human === 'string' ? c.human.trim() : '';
|
|
283
|
+
const expression = typeof c.expression === 'string' ? c.expression.trim() : '';
|
|
284
|
+
if (!human) {
|
|
285
|
+
problems.push(`Constraint ${n} has no description`);
|
|
286
|
+
} else if (human.length > MAX_HUMAN) {
|
|
287
|
+
problems.push(`Constraint ${n}'s description is longer than ${MAX_HUMAN} characters`);
|
|
288
|
+
}
|
|
289
|
+
const p = expressionProblem(expression);
|
|
290
|
+
if (p) {
|
|
291
|
+
problems.push(`Constraint ${n} ${p}`);
|
|
292
|
+
}
|
|
293
|
+
let key = typeof c.key === 'string' && known.has(c.key) && !used.has(c.key) ? c.key : null;
|
|
294
|
+
if (!key) {
|
|
295
|
+
key = `cp-${next++}`;
|
|
296
|
+
}
|
|
297
|
+
used.add(key);
|
|
298
|
+
constraints.push({ key, human, expression });
|
|
299
|
+
});
|
|
300
|
+
return { title, constraints, problems };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The StructureDefinition.
|
|
305
|
+
* @param {{id: string, url: string, title: string, constraints: Array, date?: string}} p
|
|
306
|
+
*/
|
|
307
|
+
function build(p) {
|
|
308
|
+
return {
|
|
309
|
+
resourceType: 'StructureDefinition',
|
|
310
|
+
id: `profile-${p.id}`,
|
|
311
|
+
url: p.url,
|
|
312
|
+
name: nameFromTitle(p.title),
|
|
313
|
+
title: p.title,
|
|
314
|
+
status: 'active',
|
|
315
|
+
date: p.date || new Date().toISOString(),
|
|
316
|
+
fhirVersion: '4.0.1',
|
|
317
|
+
kind: 'resource',
|
|
318
|
+
abstract: false,
|
|
319
|
+
type: 'Bundle',
|
|
320
|
+
baseDefinition: BUNDLE,
|
|
321
|
+
derivation: 'constraint',
|
|
322
|
+
differential: {
|
|
323
|
+
element: [{
|
|
324
|
+
id: 'Bundle',
|
|
325
|
+
path: 'Bundle',
|
|
326
|
+
constraint: p.constraints.map(c => ({ key: c.key, severity: 'error', human: c.human, expression: c.expression, source: p.url }))
|
|
327
|
+
}]
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A StructureDefinition's constraints, as the editor has them. */
|
|
333
|
+
function constraintsOf(sd) {
|
|
334
|
+
const el = sd?.differential?.element?.[0];
|
|
335
|
+
return (Array.isArray(el?.constraint) ? el.constraint : [])
|
|
336
|
+
.filter(c => isObject(c) && typeof c.key === 'string' && KEY_RE.test(c.key))
|
|
337
|
+
.map(c => ({ key: c.key, human: c.human || '', expression: c.expression || '' }));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// what an uploaded StructureDefinition may have: what build() makes, and the metadata a
|
|
341
|
+
// tool might add that is replaced anyway
|
|
342
|
+
const UPLOAD_KEYS = new Set(['resourceType', 'id', 'meta', 'text', 'url', 'version', 'name', 'title', 'status', 'date', 'publisher',
|
|
343
|
+
'fhirVersion', 'kind', 'abstract', 'type', 'baseDefinition', 'derivation', 'differential']);
|
|
344
|
+
const ELEMENT_KEYS = new Set(['id', 'path', 'constraint']);
|
|
345
|
+
const CONSTRAINT_KEYS = new Set(['key', 'severity', 'human', 'expression', 'source']);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* An uploaded StructureDefinition, as the editor's input (title and constraints, the
|
|
349
|
+
* keys dropped so they are assigned afresh), or the reasons it can't be taken.
|
|
350
|
+
* @returns {{input: {title, constraints}|null, problems: string[]}}
|
|
351
|
+
*/
|
|
352
|
+
function fromUpload(sd) {
|
|
353
|
+
const problems = [];
|
|
354
|
+
if (!isObject(sd) || sd.resourceType !== 'StructureDefinition') {
|
|
355
|
+
return { input: null, problems: ['That is not a StructureDefinition'] };
|
|
356
|
+
}
|
|
357
|
+
if (sd.type === 'Composition' && sd.baseDefinition === OLD_BASE) {
|
|
358
|
+
// one downloaded when profiles were on Composition: moved onto the document
|
|
359
|
+
sd = structuredClone(sd);
|
|
360
|
+
sd.type = 'Bundle';
|
|
361
|
+
sd.baseDefinition = BUNDLE;
|
|
362
|
+
for (const el of Array.isArray(sd.differential?.element) ? sd.differential.element : []) {
|
|
363
|
+
if (isObject(el) && el.path === 'Composition') {
|
|
364
|
+
el.path = 'Bundle';
|
|
365
|
+
if (el.id === 'Composition') {
|
|
366
|
+
el.id = 'Bundle';
|
|
367
|
+
}
|
|
368
|
+
for (const c of Array.isArray(el.constraint) ? el.constraint : []) {
|
|
369
|
+
if (isObject(c) && typeof c.expression === 'string') {
|
|
370
|
+
c.expression = onDocument(c.expression);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const extra = Object.keys(sd).filter(k => !UPLOAD_KEYS.has(k));
|
|
377
|
+
if (extra.length) {
|
|
378
|
+
problems.push(`It has more than a clinical profile can hold: ${extra.join(', ')}`);
|
|
379
|
+
}
|
|
380
|
+
if (sd.type !== 'Bundle' || sd.baseDefinition !== BUNDLE || (sd.derivation !== undefined && sd.derivation !== 'constraint') ||
|
|
381
|
+
(sd.kind !== undefined && sd.kind !== 'resource') || (sd.abstract !== undefined && sd.abstract !== false)) {
|
|
382
|
+
problems.push(`It must be a profile on Bundle (type Bundle, baseDefinition ${BUNDLE}, derivation constraint)`);
|
|
383
|
+
}
|
|
384
|
+
const elements = Array.isArray(sd.differential?.element) ? sd.differential.element : null;
|
|
385
|
+
if (sd.differential !== undefined && (!isObject(sd.differential) || Object.keys(sd.differential).some(k => k !== 'element'))) {
|
|
386
|
+
problems.push('Its differential may only have elements');
|
|
387
|
+
}
|
|
388
|
+
let list = [];
|
|
389
|
+
if (!elements || elements.length !== 1 || !isObject(elements[0]) || elements[0].path !== 'Bundle' ||
|
|
390
|
+
(elements[0].id !== undefined && elements[0].id !== 'Bundle')) {
|
|
391
|
+
problems.push('Its differential must have just one element, the root (Bundle)');
|
|
392
|
+
} else {
|
|
393
|
+
const el = elements[0];
|
|
394
|
+
const extraEl = Object.keys(el).filter(k => !ELEMENT_KEYS.has(k));
|
|
395
|
+
if (extraEl.length) {
|
|
396
|
+
problems.push(`Its root element has more than constraints: ${extraEl.join(', ')}`);
|
|
397
|
+
}
|
|
398
|
+
if (el.constraint !== undefined && !Array.isArray(el.constraint)) {
|
|
399
|
+
problems.push('Its root element\'s constraints must be a list');
|
|
400
|
+
}
|
|
401
|
+
list = Array.isArray(el.constraint) ? el.constraint : [];
|
|
402
|
+
list.forEach((c, i) => {
|
|
403
|
+
if (!isObject(c)) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const extraC = Object.keys(c).filter(k => !CONSTRAINT_KEYS.has(k));
|
|
407
|
+
if (extraC.length) {
|
|
408
|
+
problems.push(`Constraint ${i + 1} has more than a key, description and expression: ${extraC.join(', ')}`);
|
|
409
|
+
}
|
|
410
|
+
if (c.severity !== undefined && c.severity !== 'error') {
|
|
411
|
+
problems.push(`Constraint ${i + 1} is not an error (the constraints of a clinical profile are)`);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const title = typeof sd.title === 'string' && sd.title.trim() ? sd.title : (typeof sd.name === 'string' ? sd.name : '');
|
|
416
|
+
return { input: { title, constraints: list.map(c => (isObject(c) ? { human: c.human, expression: c.expression } : c)) }, problems };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The read-only description of a shaped part: which section, which code or which value
|
|
421
|
+
* set, with bounds. titleOf(url) gives a value set's title, when it is known.
|
|
422
|
+
*/
|
|
423
|
+
function describePart(p, isSection, titleOf) {
|
|
424
|
+
if (p.valueSet !== undefined) {
|
|
425
|
+
const title = typeof titleOf === 'function' ? titleOf(p.valueSet) : null;
|
|
426
|
+
return { what: title || p.valueSet, code: title ? p.valueSet : null, card: cardText(p), valueSet: true };
|
|
427
|
+
}
|
|
428
|
+
const named = isSection && p.system === LOINC ? IPS_SECTIONS.find(s => s.code === p.code) : null;
|
|
429
|
+
const sys = SYSTEMS.find(s => s.url === p.system);
|
|
430
|
+
const what = named ? named.title : `${sys ? sys.name : p.system} ${p.code}`;
|
|
431
|
+
return { what, code: named ? `${sys ? sys.name : p.system} ${p.code}` : null, card: cardText(p) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* An expression written for a Composition, for its document: each Composition.section(...)
|
|
436
|
+
* becomes Bundle.section(...) (section() on a document looks in its Composition), and any
|
|
437
|
+
* other Composition.x becomes Bundle.entry.resource.ofType(Composition).x - every one in the
|
|
438
|
+
* expression, not only a leading one, and never inside a string or a `quoted` name. A path
|
|
439
|
+
* that doesn't start with a type name (title.exists()) can't be told from the document's
|
|
440
|
+
* own, and is left as it is.
|
|
441
|
+
*/
|
|
442
|
+
function onDocument(expression) {
|
|
443
|
+
let out = '';
|
|
444
|
+
let i = 0;
|
|
445
|
+
const n = expression.length;
|
|
446
|
+
while (i < n) {
|
|
447
|
+
const ch = expression[i];
|
|
448
|
+
if (ch === "'" || ch === '`') {
|
|
449
|
+
// a string or a delimited name, with its escapes, as it is
|
|
450
|
+
let j = i + 1;
|
|
451
|
+
while (j < n && expression[j] !== ch) {
|
|
452
|
+
j += expression[j] === '\\' ? 2 : 1;
|
|
453
|
+
}
|
|
454
|
+
out += expression.substring(i, j + 1);
|
|
455
|
+
i = j + 1;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// Composition at the start of a path: not part of a longer name, not after a dot
|
|
459
|
+
const prev = i > 0 ? expression[i - 1] : '';
|
|
460
|
+
if (expression.startsWith('Composition', i) && !/[A-Za-z0-9_.]/.test(prev) && !/[A-Za-z0-9_]/.test(expression[i + 11] || '')) {
|
|
461
|
+
const m = /^Composition\s*\.\s*(section\s*\()?/.exec(expression.substring(i));
|
|
462
|
+
if (m) {
|
|
463
|
+
out += m[1] ? 'Bundle.section(' : 'Bundle.entry.resource.ofType(Composition).';
|
|
464
|
+
i += m[0].length;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
out += ch;
|
|
469
|
+
i++;
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A profile made when profiles were on Composition, moved onto Bundle: the same id, url,
|
|
476
|
+
* title and keys, the expressions moved onto the document (onDocument).
|
|
477
|
+
* @returns {Object|null} the new StructureDefinition, or null when there is nothing to do
|
|
478
|
+
*/
|
|
479
|
+
function migrate(sd) {
|
|
480
|
+
if (!isObject(sd) || sd.type !== 'Composition' || sd.baseDefinition !== OLD_BASE) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
const constraints = constraintsOf(sd).map(c => ({ ...c, expression: onDocument(c.expression) }));
|
|
484
|
+
return build({ id: String(sd.id).replace(/^profile-/, ''), url: sd.url, title: sd.title || sd.name || 'Profile', constraints, date: sd.date });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The resource types the editor offers for a rule about resources (any other can be typed). */
|
|
488
|
+
const RESOURCE_TYPES = ['Condition', 'AllergyIntolerance', 'MedicationStatement', 'MedicationRequest', 'MedicationAdministration', 'MedicationDispense',
|
|
489
|
+
'Immunization', 'Procedure', 'Observation', 'DiagnosticReport', 'AdverseEvent', 'DetectedIssue'];
|
|
490
|
+
|
|
491
|
+
/** The read-only description of a rule about resources. */
|
|
492
|
+
function describeResource(r, titleOf) {
|
|
493
|
+
if (r.valueSet !== undefined) {
|
|
494
|
+
const title = typeof titleOf === 'function' ? titleOf(r.valueSet) : null;
|
|
495
|
+
return { type: r.type, valueSet: r.valueSet, title, rest: r.rest };
|
|
496
|
+
}
|
|
497
|
+
const sys = SYSTEMS.find(s => s.url === r.system);
|
|
498
|
+
return { type: r.type, code: `${sys ? sys.name : r.system} ${r.code}`, rest: r.rest };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
module.exports = {
|
|
502
|
+
SYSTEMS, IPS_SECTIONS, RESOURCE_TYPES, BUNDLE, MAX_CONSTRAINTS, ID_RE, migrate,
|
|
503
|
+
parseResourceShape, resourceShapeExpression, kindOf, describeResource,
|
|
504
|
+
parseShape, shapeExpression, parseCard, cardText, expressionProblem, nameFromTitle, newId,
|
|
505
|
+
checkInput, build, constraintsOf, fromUpload, describePart
|
|
506
|
+
};
|