fhirsmith 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -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 +432 -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 +8 -3
- 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 +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- 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 +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- 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 +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/ips/composer.js
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
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
|
+
* The composer: between what the IPS editor holds (a model - the patient, the document's
|
|
9
|
+
* header, and each section's mode and rows, as editor-schema.js describes them) and a FHIR
|
|
10
|
+
* document Bundle. `build` makes the document from a model, checking the model as it goes;
|
|
11
|
+
* `modelOf` reads a document back into a model, as far as the form can hold it, so an
|
|
12
|
+
* existing document can be edited.
|
|
13
|
+
*
|
|
14
|
+
* The model:
|
|
15
|
+
* { patient: { family, given, gender, birthDate, identifierSystem, identifier, phone, email, address },
|
|
16
|
+
* document: { title, author, authorType, custodian, date },
|
|
17
|
+
* sections: { <key>: { mode: 'data' | 'narrative' | 'empty' | 'omit', emptyReason, narrative, rows: [ { <field>: value, <sub>: [ {...} ] } ] } } }
|
|
18
|
+
* A code value is { system, code, display, text }; a quantity is { value, unit }; a select
|
|
19
|
+
* is its code; a list field holds an array of those.
|
|
20
|
+
*
|
|
21
|
+
* @module ips/composer
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
const escape = require('escape-html');
|
|
26
|
+
const parse5 = require('parse5');
|
|
27
|
+
const { HEADER, SECTIONS, EMPTY_REASONS, EMPTY_REASON_SYSTEM, LOINC } = require('./editor-schema');
|
|
28
|
+
const { Presenter } = require('./presenter');
|
|
29
|
+
|
|
30
|
+
const UCUM = 'http://unitsofmeasure.org';
|
|
31
|
+
const XHTML = 'http://www.w3.org/1999/xhtml';
|
|
32
|
+
const COMPOSITION_TYPE = { coding: [{ system: LOINC, code: '60591-5', display: 'Patient summary Document' }] };
|
|
33
|
+
const MAX_ROWS = 200;
|
|
34
|
+
const MAX_TEXT = 4000;
|
|
35
|
+
const MAX_NARRATIVE = 20000;
|
|
36
|
+
// the elements that repeat, when a path goes through them
|
|
37
|
+
const ARRAYS = new Set(['protocolApplied', 'dosage', 'referenceRange', 'category', 'note', 'bodySite', 'reasonCode', 'interpretation', 'manifestation', 'reaction', 'component', 'targetDisease', 'deviceName']);
|
|
38
|
+
|
|
39
|
+
function isObject(v) {
|
|
40
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function str(v) {
|
|
44
|
+
return typeof v === 'string' ? v.trim() : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const uuid = () => `urn:uuid:${crypto.randomUUID()}`;
|
|
48
|
+
|
|
49
|
+
// ---- values -------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** A code value from the form as a CodeableConcept, or null when it is empty. */
|
|
52
|
+
function codeableOf(v) {
|
|
53
|
+
if (typeof v === 'string') {
|
|
54
|
+
return str(v) ? { text: str(v) } : null;
|
|
55
|
+
}
|
|
56
|
+
if (!isObject(v)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const cc = {};
|
|
60
|
+
if (str(v.system) && str(v.code)) {
|
|
61
|
+
const coding = { system: str(v.system), code: str(v.code) };
|
|
62
|
+
if (str(v.display)) {
|
|
63
|
+
coding.display = str(v.display);
|
|
64
|
+
}
|
|
65
|
+
cc.coding = [coding];
|
|
66
|
+
}
|
|
67
|
+
if (str(v.text)) {
|
|
68
|
+
cc.text = str(v.text);
|
|
69
|
+
} else if (!cc.coding && str(v.display)) {
|
|
70
|
+
cc.text = str(v.display);
|
|
71
|
+
}
|
|
72
|
+
return cc.coding || cc.text ? cc : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function quantityOf(v) {
|
|
76
|
+
if (!isObject(v)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const n = typeof v.value === 'number' ? v.value : (str(v.value) ? Number(v.value) : NaN);
|
|
80
|
+
if (!Number.isFinite(n)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const q = { value: n };
|
|
84
|
+
if (str(v.unit)) {
|
|
85
|
+
q.unit = str(v.unit);
|
|
86
|
+
q.system = UCUM;
|
|
87
|
+
q.code = str(v.unit);
|
|
88
|
+
}
|
|
89
|
+
return q;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The value of a field, as FHIR: null when there is nothing. */
|
|
93
|
+
function fhirValue(field, v) {
|
|
94
|
+
switch (field.type) {
|
|
95
|
+
case 'code': return codeableOf(v);
|
|
96
|
+
case 'quantity': return quantityOf(v);
|
|
97
|
+
case 'select': {
|
|
98
|
+
const c = str(v);
|
|
99
|
+
if (!c) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const opt = (field.options || []).find(o => o.code === c);
|
|
103
|
+
if (!opt) {
|
|
104
|
+
return undefined; // not one of the options
|
|
105
|
+
}
|
|
106
|
+
return field.system ? { coding: [{ system: field.system, code: opt.code, display: opt.display }] } : opt.code;
|
|
107
|
+
}
|
|
108
|
+
case 'date': {
|
|
109
|
+
const d = str(v);
|
|
110
|
+
if (!d) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return /^\d{4}(-\d\d(-\d\d)?)?$/.test(d) ? d : undefined;
|
|
114
|
+
}
|
|
115
|
+
default: {
|
|
116
|
+
const t = str(v);
|
|
117
|
+
if (!t) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return field.as === 'codeable' ? { text: t } : t;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The name a choice element takes for a field's type: onset[x] + date -> onsetDateTime. */
|
|
126
|
+
function choiceName(base, field, value) {
|
|
127
|
+
if (field.type === 'quantity') {
|
|
128
|
+
return base + 'Quantity';
|
|
129
|
+
}
|
|
130
|
+
if (field.type === 'date') {
|
|
131
|
+
return base + 'DateTime';
|
|
132
|
+
}
|
|
133
|
+
if (field.type === 'code' || (field.type === 'select' && field.system)) {
|
|
134
|
+
return base + 'CodeableConcept';
|
|
135
|
+
}
|
|
136
|
+
return base + (typeof value === 'object' ? 'CodeableConcept' : 'String');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The choice types a field reads back (the reverse of choiceName), so that fields sharing a
|
|
141
|
+
* value[x] each read only their own: a text field a valueString, not the valueCodeableConcept
|
|
142
|
+
* a code field made. A date reads any (dateTime, Period, ...); null for any.
|
|
143
|
+
*/
|
|
144
|
+
function choiceTypes(field) {
|
|
145
|
+
if (!field || field.type === 'date') {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (field.type === 'quantity') {
|
|
149
|
+
return ['Quantity'];
|
|
150
|
+
}
|
|
151
|
+
if (field.type === 'code' || (field.type === 'select' && field.system)) {
|
|
152
|
+
return ['CodeableConcept'];
|
|
153
|
+
}
|
|
154
|
+
if (field.type === 'text' || field.type === 'textarea' || field.type === 'select') {
|
|
155
|
+
return ['String'];
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// path parts that would reach an object's prototype rather than a property of its own
|
|
161
|
+
const UNSAFE_PARTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The parts of a path. The paths are the schema's own (editor-schema.js), never a
|
|
165
|
+
* request's; even so, a part that would reach a prototype is refused.
|
|
166
|
+
*/
|
|
167
|
+
function pathParts(path) {
|
|
168
|
+
const parts = String(path).split('.');
|
|
169
|
+
if (parts.some(p => UNSAFE_PARTS.has(p) || UNSAFE_PARTS.has(p.replace(/\[x\]$/, '')))) {
|
|
170
|
+
throw new Error(`The path '${path}' is not one a resource can have`);
|
|
171
|
+
}
|
|
172
|
+
return parts;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Put a value at a path in a resource: a.b nests, an array element takes its first member. */
|
|
176
|
+
function setPath(resource, path, value, field) {
|
|
177
|
+
const parts = pathParts(path);
|
|
178
|
+
let node = resource;
|
|
179
|
+
for (let i = 0; i < parts.length; i++) {
|
|
180
|
+
let name = parts[i];
|
|
181
|
+
const last = i === parts.length - 1;
|
|
182
|
+
if (name.endsWith('[x]')) {
|
|
183
|
+
name = choiceName(name.substring(0, name.length - 3), field, value);
|
|
184
|
+
}
|
|
185
|
+
if (last) {
|
|
186
|
+
if (field.list || ARRAYS.has(name) && !['protocolApplied', 'dosage', 'referenceRange', 'reaction', 'component', 'deviceName'].includes(name)) {
|
|
187
|
+
const list = Array.isArray(value) ? value : [value];
|
|
188
|
+
node[name] = (node[name] || []).concat(list);
|
|
189
|
+
} else {
|
|
190
|
+
node[name] = value;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (ARRAYS.has(name)) {
|
|
195
|
+
if (!Array.isArray(node[name]) || node[name].length === 0) {
|
|
196
|
+
node[name] = [{}];
|
|
197
|
+
}
|
|
198
|
+
node = node[name][0];
|
|
199
|
+
} else {
|
|
200
|
+
if (!isObject(node[name])) {
|
|
201
|
+
node[name] = {};
|
|
202
|
+
}
|
|
203
|
+
node = node[name];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Read a value at a path: the reverse of setPath, taking first members of arrays. */
|
|
209
|
+
function getPath(resource, path, field) {
|
|
210
|
+
const parts = pathParts(path);
|
|
211
|
+
let node = resource;
|
|
212
|
+
for (let i = 0; i < parts.length; i++) {
|
|
213
|
+
if (!isObject(node)) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
let name = parts[i];
|
|
217
|
+
if (name.endsWith('[x]')) {
|
|
218
|
+
const base = name.substring(0, name.length - 3);
|
|
219
|
+
const types = i === parts.length - 1 ? choiceTypes(field) : null;
|
|
220
|
+
name = Object.keys(node).find(k => k.startsWith(base) && k.length > base.length && /^[A-Z]/.test(k[base.length]) &&
|
|
221
|
+
(!types || types.includes(k.substring(base.length))));
|
|
222
|
+
if (!name) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let v = node[name];
|
|
227
|
+
if (Array.isArray(v) && !(i === parts.length - 1 && field?.list)) {
|
|
228
|
+
v = v[0];
|
|
229
|
+
}
|
|
230
|
+
if (i === parts.length - 1) {
|
|
231
|
+
return v;
|
|
232
|
+
}
|
|
233
|
+
node = v;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A form value from FHIR: the reverse of fhirValue. */
|
|
239
|
+
function formValue(field, v) {
|
|
240
|
+
if (v === undefined || v === null) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
const one = (x) => {
|
|
244
|
+
switch (field.type) {
|
|
245
|
+
case 'code': {
|
|
246
|
+
if (typeof x === 'string') {
|
|
247
|
+
return { text: x };
|
|
248
|
+
}
|
|
249
|
+
if (!isObject(x)) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const c = (Array.isArray(x.coding) ? x.coding : []).find(isObject) || {};
|
|
253
|
+
const out = { system: str(c.system), code: str(c.code), display: str(c.display), text: str(x.text) };
|
|
254
|
+
return out.code || out.text ? out : undefined;
|
|
255
|
+
}
|
|
256
|
+
case 'quantity': return isObject(x) && typeof x.value === 'number' ? { value: x.value, unit: str(x.unit) || str(x.code) } : undefined;
|
|
257
|
+
case 'select': {
|
|
258
|
+
const c = typeof x === 'string' ? x : (isObject(x) ? str((Array.isArray(x.coding) ? x.coding : []).find(isObject)?.code) : '');
|
|
259
|
+
return (field.options || []).some(o => o.code === c) ? c : undefined;
|
|
260
|
+
}
|
|
261
|
+
case 'date': return typeof x === 'string' && /^\d{4}(-\d\d(-\d\d)?)?/.test(x) ? x.substring(0, 10) : undefined;
|
|
262
|
+
default: {
|
|
263
|
+
if (typeof x === 'string') {
|
|
264
|
+
return x;
|
|
265
|
+
}
|
|
266
|
+
if (isObject(x)) {
|
|
267
|
+
return str(x.text) || str((Array.isArray(x.coding) ? x.coding : []).find(isObject)?.display) || undefined;
|
|
268
|
+
}
|
|
269
|
+
if (Array.isArray(x)) {
|
|
270
|
+
return x.map(n => (isObject(n) ? str(n.text) : str(n))).filter(Boolean).join('\n') || undefined;
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
if (field.list) {
|
|
277
|
+
const list = (Array.isArray(v) ? v : [v]).map(one).filter(x => x !== undefined);
|
|
278
|
+
return list.length ? list : undefined;
|
|
279
|
+
}
|
|
280
|
+
return one(Array.isArray(v) ? v[0] : v);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- building -------------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the document.
|
|
287
|
+
* @param {Object} model
|
|
288
|
+
* @param {{now?: Date}} [options]
|
|
289
|
+
* @returns {Promise<{bundle: Object|null, problems: string[]}>} the document, or the problems with the model
|
|
290
|
+
*/
|
|
291
|
+
async function build(model, options = {}) {
|
|
292
|
+
const problems = [];
|
|
293
|
+
if (!isObject(model)) {
|
|
294
|
+
return { bundle: null, problems: ['The model must be an object'] };
|
|
295
|
+
}
|
|
296
|
+
const now = options.now || new Date();
|
|
297
|
+
const patientIn = isObject(model.patient) ? model.patient : {};
|
|
298
|
+
const docIn = isObject(model.document) ? model.document : {};
|
|
299
|
+
const sectionsIn = isObject(model.sections) ? model.sections : {};
|
|
300
|
+
const bad = (what) => problems.push(what);
|
|
301
|
+
|
|
302
|
+
// the people and organizations
|
|
303
|
+
const patient = { resourceType: 'Patient', id: crypto.randomUUID() };
|
|
304
|
+
const family = str(patientIn.family);
|
|
305
|
+
if (!family) {
|
|
306
|
+
bad('The patient needs a family name');
|
|
307
|
+
}
|
|
308
|
+
const name = { family };
|
|
309
|
+
const given = str(patientIn.given);
|
|
310
|
+
if (given) {
|
|
311
|
+
name.given = given.split(/\s+/).filter(Boolean);
|
|
312
|
+
}
|
|
313
|
+
patient.name = [name];
|
|
314
|
+
const gender = fhirValue(HEADER.patient.find(f => f.name === 'gender'), patientIn.gender);
|
|
315
|
+
if (gender === undefined) {
|
|
316
|
+
bad('The patient\'s gender is not one of the choices');
|
|
317
|
+
} else if (gender) {
|
|
318
|
+
patient.gender = gender;
|
|
319
|
+
}
|
|
320
|
+
const birthDate = fhirValue(HEADER.patient.find(f => f.name === 'birthDate'), patientIn.birthDate);
|
|
321
|
+
if (birthDate === undefined) {
|
|
322
|
+
bad('The patient\'s date of birth is not a date (YYYY, YYYY-MM or YYYY-MM-DD)');
|
|
323
|
+
} else if (birthDate) {
|
|
324
|
+
patient.birthDate = birthDate;
|
|
325
|
+
}
|
|
326
|
+
if (str(patientIn.identifier)) {
|
|
327
|
+
patient.identifier = [{ value: str(patientIn.identifier).substring(0, 200), ...(str(patientIn.identifierSystem) ? { system: str(patientIn.identifierSystem).substring(0, 500) } : {}) }];
|
|
328
|
+
}
|
|
329
|
+
const telecom = [];
|
|
330
|
+
if (str(patientIn.phone)) {
|
|
331
|
+
telecom.push({ system: 'phone', value: str(patientIn.phone).substring(0, 100) });
|
|
332
|
+
}
|
|
333
|
+
if (str(patientIn.email)) {
|
|
334
|
+
telecom.push({ system: 'email', value: str(patientIn.email).substring(0, 200) });
|
|
335
|
+
}
|
|
336
|
+
if (telecom.length) {
|
|
337
|
+
patient.telecom = telecom;
|
|
338
|
+
}
|
|
339
|
+
if (str(patientIn.address)) {
|
|
340
|
+
patient.address = [{ text: str(patientIn.address).substring(0, 500) }];
|
|
341
|
+
}
|
|
342
|
+
const authorName = str(docIn.author).substring(0, 200);
|
|
343
|
+
if (!authorName) {
|
|
344
|
+
bad('The document needs an author');
|
|
345
|
+
}
|
|
346
|
+
const authorType = docIn.authorType === 'Organization' ? 'Organization' : 'Practitioner';
|
|
347
|
+
const author = authorType === 'Organization'
|
|
348
|
+
? { resourceType: 'Organization', id: crypto.randomUUID(), name: authorName }
|
|
349
|
+
: { resourceType: 'Practitioner', id: crypto.randomUUID(), name: [{ text: authorName }] };
|
|
350
|
+
const custodian = str(docIn.custodian) ? { resourceType: 'Organization', id: crypto.randomUUID(), name: str(docIn.custodian).substring(0, 200) } : null;
|
|
351
|
+
const date = str(docIn.date) ? fhirValue({ type: 'date' }, docIn.date) : now.toISOString();
|
|
352
|
+
if (date === undefined) {
|
|
353
|
+
bad('The document\'s date is not a date');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const entries = [];
|
|
357
|
+
const add = (resource) => {
|
|
358
|
+
const fullUrl = uuid();
|
|
359
|
+
entries.push({ fullUrl, resource });
|
|
360
|
+
return fullUrl;
|
|
361
|
+
};
|
|
362
|
+
const composition = {
|
|
363
|
+
resourceType: 'Composition', id: crypto.randomUUID(), status: 'final', type: COMPOSITION_TYPE, date: date || now.toISOString(),
|
|
364
|
+
title: str(docIn.title).substring(0, 200) || `Patient Summary as of ${(date || now.toISOString()).substring(0, 10)}`,
|
|
365
|
+
section: []
|
|
366
|
+
};
|
|
367
|
+
add(composition);
|
|
368
|
+
const patientUrl = add(patient);
|
|
369
|
+
const authorUrl = add(author);
|
|
370
|
+
composition.subject = { reference: patientUrl, display: [given, family].filter(Boolean).join(' ') };
|
|
371
|
+
composition.author = [{ reference: authorUrl, display: authorName }];
|
|
372
|
+
if (custodian) {
|
|
373
|
+
composition.custodian = { reference: add(custodian), display: custodian.name };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// the sections
|
|
377
|
+
const userNarratives = new Map();
|
|
378
|
+
for (const def of SECTIONS) {
|
|
379
|
+
const s = isObject(sectionsIn[def.key]) ? sectionsIn[def.key] : null;
|
|
380
|
+
const mode = s && ['data', 'narrative', 'empty', 'omit'].includes(s.mode) ? s.mode : (s && Array.isArray(s.rows) && s.rows.length ? 'data' : 'omit');
|
|
381
|
+
if (mode === 'omit') {
|
|
382
|
+
if (def.required) {
|
|
383
|
+
bad(`The ${def.title} section is required: enter its entries, a narrative, or why it is empty`);
|
|
384
|
+
}
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const section = { title: def.title, code: { coding: [{ system: LOINC, code: def.code, display: def.title }] } };
|
|
388
|
+
composition.section.push(section);
|
|
389
|
+
if (mode === 'empty') {
|
|
390
|
+
const reason = EMPTY_REASONS.find(r => r.code === str(s.emptyReason));
|
|
391
|
+
if (!reason) {
|
|
392
|
+
bad(`The ${def.title} section needs a reason for being empty`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
section.emptyReason = { coding: [{ system: EMPTY_REASON_SYSTEM, code: reason.code, display: reason.display }] };
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (mode === 'narrative') {
|
|
399
|
+
const narrative = str(s.narrative);
|
|
400
|
+
if (!narrative) {
|
|
401
|
+
bad(`The ${def.title} section's narrative is empty`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (narrative.length > MAX_NARRATIVE) {
|
|
405
|
+
bad(`The ${def.title} section's narrative is too long (${MAX_NARRATIVE} characters at most)`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
userNarratives.set(section, narrative);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const rows = Array.isArray(s.rows) ? s.rows.filter(isObject) : [];
|
|
412
|
+
if (rows.length === 0) {
|
|
413
|
+
bad(`The ${def.title} section has no entries: add some, or choose a narrative or a reason for being empty`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (rows.length > MAX_ROWS) {
|
|
417
|
+
bad(`The ${def.title} section has too many entries (${MAX_ROWS} at most)`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
section.entry = [];
|
|
421
|
+
rows.forEach((row, i) => {
|
|
422
|
+
const where = `${def.title}, entry ${i + 1}`;
|
|
423
|
+
const built = buildRow(def, row, where, patientUrl, bad);
|
|
424
|
+
if (!built) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
entries.push(...built.extras);
|
|
428
|
+
section.entry.push({ reference: add(built.resource) });
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (problems.length > 0) {
|
|
432
|
+
return { bundle: null, problems };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const bundle = {
|
|
436
|
+
resourceType: 'Bundle',
|
|
437
|
+
identifier: { system: 'urn:ietf:rfc:3986', value: uuid() },
|
|
438
|
+
type: 'document',
|
|
439
|
+
timestamp: now.toISOString(),
|
|
440
|
+
entry: entries
|
|
441
|
+
};
|
|
442
|
+
// the narratives: the presenter's for the header and the data sections, the user's for theirs
|
|
443
|
+
const presenter = new Presenter();
|
|
444
|
+
const out = await presenter.render(bundle);
|
|
445
|
+
composition.text = { status: 'generated', div: out.header };
|
|
446
|
+
for (const sec of out.sections) {
|
|
447
|
+
sec.section.text = userNarratives.has(sec.section)
|
|
448
|
+
? { status: 'additional', div: narrativeDiv(userNarratives.get(sec.section)) }
|
|
449
|
+
: { status: 'generated', div: sec.html };
|
|
450
|
+
}
|
|
451
|
+
return { bundle, problems: [] };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A user's narrative as xhtml: paragraphs at blank lines, everything escaped. */
|
|
455
|
+
function narrativeDiv(text) {
|
|
456
|
+
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
|
457
|
+
return `<div xmlns="${XHTML}">${paragraphs.map(p => `<p>${escape(p).replace(/\n/g, '<br/>')}</p>`).join('')}</div>`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* One row of a section as its resource (and any companion resource - a Device for a
|
|
462
|
+
* DeviceUseStatement).
|
|
463
|
+
*/
|
|
464
|
+
function buildRow(def, row, where, patientUrl, bad) {
|
|
465
|
+
const resource = { resourceType: def.resourceType, id: crypto.randomUUID() };
|
|
466
|
+
const extras = [];
|
|
467
|
+
let ok = true;
|
|
468
|
+
let device = null;
|
|
469
|
+
let diastolic = null;
|
|
470
|
+
const fail = (m) => {
|
|
471
|
+
bad(`${where}: ${m}`);
|
|
472
|
+
ok = false;
|
|
473
|
+
};
|
|
474
|
+
const apply = (target, field, raw, label) => {
|
|
475
|
+
if (raw === undefined || raw === null || raw === '' || (Array.isArray(raw) && raw.length === 0)) {
|
|
476
|
+
if (field.required) {
|
|
477
|
+
fail(`${label} is required`);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const values = field.list ? (Array.isArray(raw) ? raw : [raw]) : [raw];
|
|
482
|
+
const out = [];
|
|
483
|
+
for (const v of values) {
|
|
484
|
+
if (typeof v === 'string' && v.length > MAX_TEXT) {
|
|
485
|
+
fail(`${label} is too long`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const fv = fhirValue(field, v);
|
|
489
|
+
if (fv === undefined) {
|
|
490
|
+
fail(`${label} is not valid`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (fv !== null) {
|
|
494
|
+
out.push(fv);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (out.length === 0) {
|
|
498
|
+
if (field.required) {
|
|
499
|
+
fail(`${label} is required`);
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (field.path === '$diastolic') {
|
|
504
|
+
diastolic = out[0];
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (field.path.startsWith('$device.')) {
|
|
508
|
+
device = device || { resourceType: 'Device', id: crypto.randomUUID() };
|
|
509
|
+
setPath(device, field.path.substring('$device.'.length), out[0], field);
|
|
510
|
+
if (field.path === '$device.deviceName.name') {
|
|
511
|
+
device.deviceName[0].type = 'user-friendly-name';
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (field.path === '$narrative') {
|
|
516
|
+
target.text = { status: 'additional', div: narrativeDiv(out[0]) };
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (field.name === 'note') {
|
|
520
|
+
target.note = [{ text: out[0] }];
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// a choice element takes one type: a second field for the same value[x] (a quantity
|
|
524
|
+
// and a text, say) would make two values, which FHIR doesn't allow
|
|
525
|
+
if (field.path.endsWith('[x]') && !field.path.includes('.')) {
|
|
526
|
+
const base = field.path.substring(0, field.path.length - 3);
|
|
527
|
+
const taken = Object.keys(target).find(k => k.startsWith(base) && k.length > base.length && /^[A-Z]/.test(k[base.length]));
|
|
528
|
+
if (taken) {
|
|
529
|
+
fail(`${label}: only one of the ${base} fields can be filled in (${taken} already is)`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
setPath(target, field.path, field.list ? out : out[0], field);
|
|
534
|
+
};
|
|
535
|
+
for (const field of def.fields) {
|
|
536
|
+
apply(resource, field, row[field.name], field.label);
|
|
537
|
+
}
|
|
538
|
+
for (const sub of def.subs || []) {
|
|
539
|
+
const items = Array.isArray(row[sub.name]) ? row[sub.name].filter(isObject) : [];
|
|
540
|
+
if (items.length > MAX_ROWS) {
|
|
541
|
+
fail(`too many ${sub.label.toLowerCase()}`);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const built = [];
|
|
545
|
+
for (const item of items) {
|
|
546
|
+
const part = {};
|
|
547
|
+
for (const field of sub.fields) {
|
|
548
|
+
apply(part, field, item[field.name], `${sub.label}: ${field.label}`);
|
|
549
|
+
}
|
|
550
|
+
if (Object.keys(part).length > 0) {
|
|
551
|
+
built.push(part);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (built.length > 0) {
|
|
555
|
+
resource[sub.path] = built;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!ok) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
// what every resource of the type carries
|
|
562
|
+
for (const [k, v] of Object.entries(def.fixed || {})) {
|
|
563
|
+
if (resource[k] === undefined) {
|
|
564
|
+
resource[k] = structuredClone(v);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// the patient
|
|
568
|
+
const subjectField = { Condition: 'subject', AllergyIntolerance: 'patient', MedicationStatement: 'subject', Immunization: 'patient', Procedure: 'subject', DeviceUseStatement: 'subject', Observation: 'subject', CarePlan: 'subject', Consent: 'patient', Flag: 'subject' }[def.resourceType];
|
|
569
|
+
if (subjectField) {
|
|
570
|
+
resource[subjectField] = { reference: patientUrl };
|
|
571
|
+
}
|
|
572
|
+
// a blood pressure: the two values as components, no value of its own
|
|
573
|
+
if (def.key === 'vitals') {
|
|
574
|
+
const option = def.fields[0].options.find(o => o.code === row.code);
|
|
575
|
+
if (option && option.components) {
|
|
576
|
+
const systolic = resource.valueQuantity;
|
|
577
|
+
delete resource.valueQuantity;
|
|
578
|
+
if (!systolic || !diastolic) {
|
|
579
|
+
fail('a blood pressure needs both the systolic and the diastolic value');
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
resource.component = [
|
|
583
|
+
{ code: { coding: [{ system: LOINC, ...option.components[0] }] }, valueQuantity: systolic },
|
|
584
|
+
{ code: { coding: [{ system: LOINC, ...option.components[1] }] }, valueQuantity: diastolic }
|
|
585
|
+
];
|
|
586
|
+
} else if (!resource.valueQuantity) {
|
|
587
|
+
fail('a value is required');
|
|
588
|
+
return null;
|
|
589
|
+
} else if (diastolic) {
|
|
590
|
+
fail('only a blood pressure has a diastolic value');
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
if (option && option.units && resource.valueQuantity && !resource.valueQuantity.unit) {
|
|
594
|
+
resource.valueQuantity.unit = option.units;
|
|
595
|
+
resource.valueQuantity.system = UCUM;
|
|
596
|
+
resource.valueQuantity.code = option.units;
|
|
597
|
+
}
|
|
598
|
+
for (const c of resource.component || []) {
|
|
599
|
+
if (!c.valueQuantity.unit) {
|
|
600
|
+
c.valueQuantity.unit = option.units;
|
|
601
|
+
c.valueQuantity.system = UCUM;
|
|
602
|
+
c.valueQuantity.code = option.units;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (def.resourceType === 'Observation' && !resource.valueQuantity && !resource.valueString && !resource.valueCodeableConcept && !resource.valueDateTime && !resource.component && !resource.dataAbsentReason) {
|
|
607
|
+
fail('a value is required');
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
if (device) {
|
|
611
|
+
const deviceUrl = uuid();
|
|
612
|
+
extras.push({ fullUrl: deviceUrl, resource: device });
|
|
613
|
+
resource.device = { reference: deviceUrl, display: device.deviceName?.[0]?.name };
|
|
614
|
+
}
|
|
615
|
+
return { resource, extras };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- reading a document back ------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A model from a document, as far as the form can hold it (what it cannot - other
|
|
622
|
+
* resource types, elements the form has no field for - is not carried; a section with
|
|
623
|
+
* entries the form cannot represent becomes a narrative section with the document's own).
|
|
624
|
+
*/
|
|
625
|
+
function modelOf(bundle) {
|
|
626
|
+
const entries = Array.isArray(bundle?.entry) ? bundle.entry.filter(isObject) : [];
|
|
627
|
+
const byRef = new Map();
|
|
628
|
+
for (const e of entries) {
|
|
629
|
+
if (isObject(e.resource)) {
|
|
630
|
+
if (typeof e.fullUrl === 'string') {
|
|
631
|
+
byRef.set(e.fullUrl, e.resource);
|
|
632
|
+
}
|
|
633
|
+
if (typeof e.resource.id === 'string') {
|
|
634
|
+
byRef.set(`${e.resource.resourceType}/${e.resource.id}`, e.resource);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const resolve = (ref) => (isObject(ref) && typeof ref.reference === 'string' ? byRef.get(ref.reference) || null : null);
|
|
639
|
+
const composition = entries[0]?.resource?.resourceType === 'Composition' ? entries[0].resource : null;
|
|
640
|
+
const model = { patient: {}, document: {}, sections: {} };
|
|
641
|
+
const patient = resolve(composition?.subject) || entries.map(e => e.resource).find(r => r?.resourceType === 'Patient') || null;
|
|
642
|
+
if (patient) {
|
|
643
|
+
const name = (Array.isArray(patient.name) ? patient.name : []).find(n => isObject(n) && n.use === 'official') || (Array.isArray(patient.name) ? patient.name : []).find(isObject) || {};
|
|
644
|
+
model.patient.family = str(name.family) || str(name.text);
|
|
645
|
+
model.patient.given = (Array.isArray(name.given) ? name.given : []).map(str).filter(Boolean).join(' ');
|
|
646
|
+
model.patient.gender = str(patient.gender);
|
|
647
|
+
model.patient.birthDate = str(patient.birthDate);
|
|
648
|
+
const id = (Array.isArray(patient.identifier) ? patient.identifier : []).find(isObject);
|
|
649
|
+
if (id) {
|
|
650
|
+
model.patient.identifier = str(id.value);
|
|
651
|
+
model.patient.identifierSystem = str(id.system);
|
|
652
|
+
}
|
|
653
|
+
for (const t of Array.isArray(patient.telecom) ? patient.telecom : []) {
|
|
654
|
+
if (isObject(t) && t.system === 'phone' && !model.patient.phone) {
|
|
655
|
+
model.patient.phone = str(t.value);
|
|
656
|
+
}
|
|
657
|
+
if (isObject(t) && t.system === 'email' && !model.patient.email) {
|
|
658
|
+
model.patient.email = str(t.value);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const a = (Array.isArray(patient.address) ? patient.address : []).find(isObject);
|
|
662
|
+
if (a) {
|
|
663
|
+
model.patient.address = str(a.text) || [...(Array.isArray(a.line) ? a.line : []), a.city, a.state, a.postalCode, a.country].map(str).filter(Boolean).join(', ');
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (composition) {
|
|
667
|
+
model.document.title = str(composition.title);
|
|
668
|
+
model.document.date = str(composition.date).substring(0, 10);
|
|
669
|
+
const authorRef = (Array.isArray(composition.author) ? composition.author : []).find(isObject);
|
|
670
|
+
const author = resolve(authorRef);
|
|
671
|
+
if (author?.resourceType === 'Organization') {
|
|
672
|
+
model.document.authorType = 'Organization';
|
|
673
|
+
model.document.author = str(author.name);
|
|
674
|
+
} else if (author) {
|
|
675
|
+
model.document.authorType = 'Practitioner';
|
|
676
|
+
const n = (Array.isArray(author.name) ? author.name : []).find(isObject) || {};
|
|
677
|
+
model.document.author = str(n.text) || [...(Array.isArray(n.given) ? n.given : []), n.family].map(str).filter(Boolean).join(' ');
|
|
678
|
+
} else if (authorRef) {
|
|
679
|
+
model.document.author = str(authorRef.display);
|
|
680
|
+
}
|
|
681
|
+
const custodian = resolve(composition.custodian);
|
|
682
|
+
model.document.custodian = custodian ? str(custodian.name) : str(composition.custodian?.display);
|
|
683
|
+
}
|
|
684
|
+
const sections = Array.isArray(composition?.section) ? composition.section.filter(isObject) : [];
|
|
685
|
+
for (const def of SECTIONS) {
|
|
686
|
+
const section = sections.find(s => (Array.isArray(s.code?.coding) ? s.code.coding : []).some(c => isObject(c) && c.code === def.code));
|
|
687
|
+
if (!section) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const out = { mode: 'omit' };
|
|
691
|
+
model.sections[def.key] = out;
|
|
692
|
+
const refs = Array.isArray(section.entry) ? section.entry : [];
|
|
693
|
+
if (refs.length > 0) {
|
|
694
|
+
const rows = [];
|
|
695
|
+
let unheld = false;
|
|
696
|
+
for (const ref of refs) {
|
|
697
|
+
const r = resolve(ref);
|
|
698
|
+
if (!r || r.resourceType !== def.resourceType) {
|
|
699
|
+
unheld = true;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
rows.push(rowOf(def, r, resolve));
|
|
703
|
+
}
|
|
704
|
+
if (rows.length > 0) {
|
|
705
|
+
out.mode = 'data';
|
|
706
|
+
out.rows = rows;
|
|
707
|
+
out.partial = unheld;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const reason = (Array.isArray(section.emptyReason?.coding) ? section.emptyReason.coding : []).find(isObject);
|
|
712
|
+
if (reason && EMPTY_REASONS.some(e => e.code === reason.code)) {
|
|
713
|
+
out.mode = 'empty';
|
|
714
|
+
out.emptyReason = reason.code;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const div = str(section.text?.div);
|
|
718
|
+
if (div) {
|
|
719
|
+
out.mode = 'narrative';
|
|
720
|
+
out.narrative = textOf(div);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return model;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** One resource as a row of its section. */
|
|
727
|
+
function rowOf(def, resource, resolve) {
|
|
728
|
+
const row = {};
|
|
729
|
+
const device = def.key === 'devices' ? resolve(resource.device) : null;
|
|
730
|
+
for (const field of def.fields) {
|
|
731
|
+
let v;
|
|
732
|
+
if (field.path === '$diastolic') {
|
|
733
|
+
const c = (Array.isArray(resource.component) ? resource.component : []).find(x => (Array.isArray(x?.code?.coding) ? x.code.coding : []).some(k => k?.code === '8462-4'));
|
|
734
|
+
v = c ? formValue(field, c.valueQuantity) : undefined;
|
|
735
|
+
} else if (field.path.startsWith('$device.')) {
|
|
736
|
+
v = device ? formValue(field, getPath(device, field.path.substring('$device.'.length), field)) : undefined;
|
|
737
|
+
} else if (field.path === '$narrative') {
|
|
738
|
+
v = str(resource.text?.div) ? textOf(resource.text.div) : undefined;
|
|
739
|
+
} else if (field.name === 'note') {
|
|
740
|
+
v = (Array.isArray(resource.note) ? resource.note : []).map(n => str(n?.text)).filter(Boolean).join('\n') || undefined;
|
|
741
|
+
} else if (def.key === 'vitals' && field.name === 'value' && Array.isArray(resource.component)) {
|
|
742
|
+
const c = resource.component.find(x => (Array.isArray(x?.code?.coding) ? x.code.coding : []).some(k => k?.code === '8480-6'));
|
|
743
|
+
v = c ? formValue(field, c.valueQuantity) : formValue(field, getPath(resource, field.path, field));
|
|
744
|
+
} else {
|
|
745
|
+
v = formValue(field, getPath(resource, field.path, field));
|
|
746
|
+
}
|
|
747
|
+
if (v !== undefined) {
|
|
748
|
+
row[field.name] = v;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
for (const sub of def.subs || []) {
|
|
752
|
+
const items = Array.isArray(resource[sub.path]) ? resource[sub.path].filter(isObject) : [];
|
|
753
|
+
if (items.length) {
|
|
754
|
+
row[sub.name] = items.map(item => {
|
|
755
|
+
const part = {};
|
|
756
|
+
for (const field of sub.fields) {
|
|
757
|
+
const v = formValue(field, getPath(item, field.path, field));
|
|
758
|
+
if (v !== undefined) {
|
|
759
|
+
part[field.name] = v;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return part;
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return row;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// elements whose end is a line break in the text, and those whose content isn't text
|
|
770
|
+
const BLOCKS = new Set(['p', 'div', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'ul', 'ol', 'dl', 'dt', 'dd', 'pre', 'blockquote']);
|
|
771
|
+
const NOT_TEXT = new Set(['script', 'style', 'template', 'head', 'title']);
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The text of a narrative, for the editor's narrative box: what a reader sees, with block
|
|
775
|
+
* ends and <br/> as line breaks. From a parse (parse5), not by stripping tags: the result
|
|
776
|
+
* is plain text - characters such as < are themselves - and is only ever used as text
|
|
777
|
+
* (a textarea's value; narrativeDiv() escapes it on the way back).
|
|
778
|
+
*/
|
|
779
|
+
function textOf(div) {
|
|
780
|
+
const out = [];
|
|
781
|
+
const walk = (node) => {
|
|
782
|
+
for (const child of node.childNodes || []) {
|
|
783
|
+
if (child.nodeName === '#text') {
|
|
784
|
+
out.push(child.value);
|
|
785
|
+
} else if (child.nodeName === 'br') {
|
|
786
|
+
out.push('\n');
|
|
787
|
+
} else if (child.tagName && !NOT_TEXT.has(child.tagName)) {
|
|
788
|
+
walk(child.content || child);
|
|
789
|
+
if (child.tagName === 'p') {
|
|
790
|
+
out.push('\n\n');
|
|
791
|
+
} else if (BLOCKS.has(child.tagName)) {
|
|
792
|
+
out.push('\n');
|
|
793
|
+
} else if (child.tagName === 'td' || child.tagName === 'th') {
|
|
794
|
+
out.push(' ');
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
walk(parse5.parseFragment(String(div)));
|
|
800
|
+
// lines, with a blank line where a paragraph ended (as narrativeDiv() reads them back)
|
|
801
|
+
return out.join('').split('\n').map(l => l.replace(/\s+/g, ' ').trim()).join('\n')
|
|
802
|
+
.replace(/\n{3,}/g, '\n\n').replace(/^\n+|\n+$/g, '');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
module.exports = { build, modelOf, fhirValue, formValue, setPath, getPath, narrativeDiv, textOf, MAX_ROWS, MAX_TEXT, MAX_NARRATIVE };
|