fhirsmith 0.16.1 → 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 +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- 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/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- 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 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -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 +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -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/readme.md +1 -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/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- 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 +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- 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 +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- 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/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
package/ips/presenter.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
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 IPS presenter: generates a narrative for an IPS document, as xhtml.
|
|
9
|
+
*
|
|
10
|
+
* It follows the FHIR rules for presenting a document - the Composition's header (subject,
|
|
11
|
+
* author, date, ...) and then its sections - with the sections in the IPS order
|
|
12
|
+
* (terminology.js: SECTION_ORDER), not the order they appear in. Each section gets its title
|
|
13
|
+
* (its own, else the IPS's default for its code); a section with entries is a table of its
|
|
14
|
+
* resources, one row each, with the columns that suit the resource type (roughly the IPS
|
|
15
|
+
* profile's must-support elements - COLUMNS below), leaving out any column no resource has
|
|
16
|
+
* a value for; nested parts of a resource (reactions, components, dosages, notes...) are
|
|
17
|
+
* bullet points in a follow-up row under the resource. A section with no entries shows its
|
|
18
|
+
* own narrative, else its emptyReason, else "Nothing found to render". The patient and the
|
|
19
|
+
* other people and organisations are summarised the way the Java renderers do.
|
|
20
|
+
*
|
|
21
|
+
* Coded values show their text, else the first coding's display, else any coding's display,
|
|
22
|
+
* else what the terminology server's $lookup says for a coding (the step passes a lookup
|
|
23
|
+
* function; it caches and bounds the questions), else '--'.
|
|
24
|
+
*
|
|
25
|
+
* The output is plain xhtml - tables with class names, no styles or scripts - and every
|
|
26
|
+
* value from the document is escaped.
|
|
27
|
+
*
|
|
28
|
+
* @module ips/presenter
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const escape = require('escape-html');
|
|
32
|
+
const parse5 = require('parse5');
|
|
33
|
+
const { compositionOf, resolveInBundle } = require('./search');
|
|
34
|
+
const { SECTION_ORDER, sectionRank } = require('./terminology');
|
|
35
|
+
|
|
36
|
+
const XHTML = 'http://www.w3.org/1999/xhtml';
|
|
37
|
+
const NONE = '--';
|
|
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 : (typeof v === 'number' || typeof v === 'boolean' ? String(v) : '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function list(v) {
|
|
48
|
+
return Array.isArray(v) ? v.filter(x => x !== null && x !== undefined) : (v === null || v === undefined ? [] : [v]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The value[x] (or onset[x], ...) of an object: its key and value, or null. */
|
|
52
|
+
function choice(obj, prefix) {
|
|
53
|
+
if (!isObject(obj)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
for (const k of Object.keys(obj)) {
|
|
57
|
+
if (k.startsWith(prefix) && k.length > prefix.length && /^[A-Z]/.test(k[prefix.length]) && !k.startsWith('_')) {
|
|
58
|
+
return { type: k.substring(prefix.length), value: obj[k] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- datatypes ------------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
67
|
+
|
|
68
|
+
/** A date as dd-mmm yy, a dateTime as dd-mmm yy hh:nn (tz); a partial date as far as it goes. */
|
|
69
|
+
function dateText(d) {
|
|
70
|
+
const s = str(d);
|
|
71
|
+
// 2026-03-01T09:30:00+10:00 -> 01-Mar 26 09:30 (+10:00)
|
|
72
|
+
const m = /^(\d{4})(?:-(\d\d)(?:-(\d\d))?)?(?:T(\d\d:\d\d)(?::\d\d(?:\.\d+)?)?(Z|[+-]\d\d:\d\d)?)?$/.exec(s);
|
|
73
|
+
if (!m) {
|
|
74
|
+
return escape(s);
|
|
75
|
+
}
|
|
76
|
+
const yy = m[1].substring(2);
|
|
77
|
+
const mon = m[2] ? MONTHS[parseInt(m[2], 10) - 1] || m[2] : '';
|
|
78
|
+
let out = !m[2] ? m[1] : (m[3] ? `${m[3]}-${mon} ${yy}` : `${mon} ${yy}`);
|
|
79
|
+
if (m[4]) {
|
|
80
|
+
out += ' ' + m[4] + (m[5] ? ` (${m[5] === 'Z' ? 'UTC' : m[5]})` : '');
|
|
81
|
+
}
|
|
82
|
+
return escape(out);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function periodText(p) {
|
|
86
|
+
if (!isObject(p)) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
const a = str(p.start);
|
|
90
|
+
const b = str(p.end);
|
|
91
|
+
if (a && b) {
|
|
92
|
+
return `${dateText(a)} - ${dateText(b)}`;
|
|
93
|
+
}
|
|
94
|
+
if (a) {
|
|
95
|
+
return `from ${dateText(a)}`;
|
|
96
|
+
}
|
|
97
|
+
if (b) {
|
|
98
|
+
return `until ${dateText(b)}`;
|
|
99
|
+
}
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function quantityText(q) {
|
|
104
|
+
if (!isObject(q)) {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
const unit = str(q.unit) || str(q.code);
|
|
108
|
+
const v = q.value === undefined || q.value === null ? '' : String(q.value);
|
|
109
|
+
return escape([str(q.comparator) + v, unit].filter(Boolean).join(' '));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rangeText(r) {
|
|
113
|
+
if (!isObject(r)) {
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
const lo = quantityText(r.low);
|
|
117
|
+
const hi = quantityText(r.high);
|
|
118
|
+
if (lo && hi) {
|
|
119
|
+
return `${lo} - ${hi}`;
|
|
120
|
+
}
|
|
121
|
+
return lo ? `>= ${lo}` : (hi ? `<= ${hi}` : '');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ratioText(r) {
|
|
125
|
+
if (!isObject(r)) {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
return [quantityText(r.numerator), quantityText(r.denominator)].filter(Boolean).join(' / ');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function humanNameText(n) {
|
|
132
|
+
if (!isObject(n)) {
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
if (str(n.text)) {
|
|
136
|
+
return escape(n.text);
|
|
137
|
+
}
|
|
138
|
+
const parts = [...list(n.prefix).map(str), ...list(n.given).map(str), str(n.family), ...list(n.suffix).map(str)].filter(Boolean);
|
|
139
|
+
return escape(parts.join(' '));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function addressText(a) {
|
|
143
|
+
if (!isObject(a)) {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
if (str(a.text)) {
|
|
147
|
+
return escape(a.text);
|
|
148
|
+
}
|
|
149
|
+
const parts = [...list(a.line).map(str), str(a.city), str(a.district), str(a.state), str(a.postalCode), str(a.country)].filter(Boolean);
|
|
150
|
+
return escape(parts.join(', '));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function contactPointText(t) {
|
|
154
|
+
if (!isObject(t)) {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
const v = str(t.value);
|
|
158
|
+
if (!v) {
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
const sys = str(t.system);
|
|
162
|
+
const use = str(t.use);
|
|
163
|
+
return escape((sys ? `${sys}: ` : '') + v + (use ? ` (${use})` : ''));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function identifierText(i) {
|
|
167
|
+
if (!isObject(i)) {
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
const v = str(i.value);
|
|
171
|
+
if (!v) {
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
const type = str(i.type?.text) || str(list(i.type?.coding)[0]?.display) || str(list(i.type?.coding)[0]?.code);
|
|
175
|
+
const sys = str(i.system);
|
|
176
|
+
const label = type || (sys ? sys.replace(/^(urn:oid:|urn:uuid:|https?:\/\/)/, '') : '');
|
|
177
|
+
return escape(label ? `${label}: ${v}` : v);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function annotationText(a) {
|
|
181
|
+
if (!isObject(a)) {
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
const t = str(a.text);
|
|
185
|
+
if (!t) {
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
const who = str(a.authorString) || '';
|
|
189
|
+
const when = str(a.time);
|
|
190
|
+
return escape(t) + (who || when ? ` <i>(${[escape(who), when ? dateText(when) : ''].filter(Boolean).join(', ')})</i>` : '');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function attachmentText(a) {
|
|
194
|
+
if (!isObject(a)) {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
return escape(str(a.title) || str(a.url) || (str(a.contentType) ? `${a.contentType} attachment` : 'attachment'));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The resource a reference points at, in the document, or null. */
|
|
201
|
+
function resolve(bundle, ref) {
|
|
202
|
+
if (!isObject(ref)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return resolveInBundle(bundle, ref.reference) || null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- the presenter ----------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
class Presenter {
|
|
211
|
+
/**
|
|
212
|
+
* @param {Object} [options]
|
|
213
|
+
* @param {Function} [options.lookup] - async (coding) -> display string or null: asks the
|
|
214
|
+
* terminology server (the step provides it, with its cache and budget)
|
|
215
|
+
*/
|
|
216
|
+
constructor(options = {}) {
|
|
217
|
+
this.lookup = typeof options.lookup === 'function' ? options.lookup : null;
|
|
218
|
+
this.bundle = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render a document.
|
|
223
|
+
* @param {Object} bundle - an IPS document Bundle
|
|
224
|
+
* @returns {Promise<{html: string, header: string, sections: Array<{section: Object, title: string, html: string}>}>}
|
|
225
|
+
* html: the whole narrative (a div in the xhtml namespace); header: the Composition's
|
|
226
|
+
* own narrative (a div); sections: each section rendered (a div each), in the IPS order
|
|
227
|
+
*/
|
|
228
|
+
async render(bundle) {
|
|
229
|
+
this.bundle = bundle;
|
|
230
|
+
const composition = compositionOf(bundle);
|
|
231
|
+
if (!composition) {
|
|
232
|
+
const html = `<div xmlns="${XHTML}"><p><i>The document has no Composition</i></p></div>`;
|
|
233
|
+
return { html, header: html, sections: [] };
|
|
234
|
+
}
|
|
235
|
+
const title = `<h1>${escape(str(composition.title) || 'International Patient Summary')}</h1>`;
|
|
236
|
+
const headerInner = await this.renderHeader(composition);
|
|
237
|
+
const sections = [];
|
|
238
|
+
const ordered = list(composition.section).filter(isObject).map((s, i) => ({ s, i, rank: sectionRank(s) }));
|
|
239
|
+
ordered.sort((a, b) => (a.rank < 0 ? 999 : a.rank) - (b.rank < 0 ? 999 : b.rank) || a.i - b.i);
|
|
240
|
+
for (const { s } of ordered) {
|
|
241
|
+
await this.renderSection(s, 2, sections);
|
|
242
|
+
}
|
|
243
|
+
// the whole document carries the title; the Composition's own narrative doesn't (the
|
|
244
|
+
// page it is shown on has it)
|
|
245
|
+
let html = `<div xmlns="${XHTML}">` + title + headerInner;
|
|
246
|
+
for (const sec of sections) {
|
|
247
|
+
html += `<h${sec.level}>${escape(sec.title)}</h${sec.level}>` + sec.inner;
|
|
248
|
+
}
|
|
249
|
+
html += '</div>';
|
|
250
|
+
return { html, header: `<div xmlns="${XHTML}">${headerInner}</div>`, sections: sections.map(s => ({ section: s.section, title: s.title, level: s.level, html: `<div xmlns="${XHTML}">${s.inner}</div>` })) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- the header ----
|
|
254
|
+
|
|
255
|
+
/** The Composition's own narrative: the patient, and the document's facts (the inside of the div). */
|
|
256
|
+
async renderHeader(composition) {
|
|
257
|
+
const b = this.bundle;
|
|
258
|
+
let h = '';
|
|
259
|
+
const patient = resolve(b, composition.subject);
|
|
260
|
+
if (patient && patient.resourceType === 'Patient') {
|
|
261
|
+
h += await this.renderPatient(patient);
|
|
262
|
+
} else if (isObject(composition.subject)) {
|
|
263
|
+
h += `<p>Subject: ${this.referenceText(composition.subject)}</p>`;
|
|
264
|
+
}
|
|
265
|
+
const rows = [];
|
|
266
|
+
const add = (label, value) => {
|
|
267
|
+
if (value && value !== NONE) {
|
|
268
|
+
rows.push(`<tr><td><b>${label}</b></td><td>${value}</td></tr>`);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
add('Date', dateText(composition.date));
|
|
272
|
+
add('Status', escape(str(composition.status)));
|
|
273
|
+
add('Type', await this.codeableText(composition.type));
|
|
274
|
+
add('Author', list(composition.author).map(r => this.referenceText(r)).filter(Boolean).join('; '));
|
|
275
|
+
add('Attester', list(composition.attester).filter(isObject).map(a => [escape(str(a.mode)), this.referenceText(a.party), a.time ? dateText(a.time) : ''].filter(Boolean).join(' ')).join('; '));
|
|
276
|
+
add('Custodian', this.referenceText(composition.custodian));
|
|
277
|
+
add('Encounter', this.referenceText(composition.encounter));
|
|
278
|
+
const events = [];
|
|
279
|
+
for (const e of list(composition.event).filter(isObject)) {
|
|
280
|
+
const codes = [];
|
|
281
|
+
for (const c of list(e.code)) {
|
|
282
|
+
codes.push(await this.codeableText(c));
|
|
283
|
+
}
|
|
284
|
+
events.push([codes.filter(x => x !== NONE).join(', '), periodText(e.period)].filter(Boolean).join(' '));
|
|
285
|
+
}
|
|
286
|
+
add('Event', events.filter(Boolean).join('; '));
|
|
287
|
+
add('Confidentiality', escape(str(composition.confidentiality)));
|
|
288
|
+
if (rows.length > 0) {
|
|
289
|
+
h += `<table class="ips-facts">${rows.join('')}</table>`;
|
|
290
|
+
}
|
|
291
|
+
return h;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The patient, as the Java PatientRenderer does: a summary line, then the details. */
|
|
295
|
+
async renderPatient(p) {
|
|
296
|
+
const names = list(p.name).filter(isObject);
|
|
297
|
+
const official = names.find(n => n.use === 'official') || names.find(n => !n.use) || names[0];
|
|
298
|
+
const line = [];
|
|
299
|
+
line.push(official ? `<b>${humanNameText(official)}</b>` : '<b>(no name)</b>');
|
|
300
|
+
if (str(p.gender)) {
|
|
301
|
+
line.push(escape(p.gender));
|
|
302
|
+
}
|
|
303
|
+
if (str(p.birthDate)) {
|
|
304
|
+
line.push(`DoB: ${dateText(p.birthDate)}`);
|
|
305
|
+
}
|
|
306
|
+
const ids = list(p.identifier).map(identifierText).filter(Boolean);
|
|
307
|
+
if (ids.length > 0) {
|
|
308
|
+
line.push(`( ${ids.join(', ')} )`);
|
|
309
|
+
}
|
|
310
|
+
if (p.deceasedBoolean === true || str(p.deceasedDateTime)) {
|
|
311
|
+
line.push(`<i>deceased${str(p.deceasedDateTime) ? ' ' + dateText(p.deceasedDateTime) : ''}</i>`);
|
|
312
|
+
}
|
|
313
|
+
let h = `<p class="ips-patient">${line.join(', ')}</p>`;
|
|
314
|
+
const rows = [];
|
|
315
|
+
const add = (label, value) => {
|
|
316
|
+
if (value) {
|
|
317
|
+
rows.push(`<tr><td><b>${label}</b></td><td>${value}</td></tr>`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
if (p.active === false) {
|
|
321
|
+
add('Active', 'No');
|
|
322
|
+
}
|
|
323
|
+
const others = names.filter(n => n !== official).map(n => humanNameText(n) + (str(n.use) ? ` (${escape(n.use)})` : '')).filter(Boolean);
|
|
324
|
+
add('Other names', others.join('; '));
|
|
325
|
+
add('Contact details', list(p.telecom).map(contactPointText).filter(Boolean).join('; '));
|
|
326
|
+
add('Address', list(p.address).map(a => addressText(a) + (str(a.use) ? ` (${escape(a.use)})` : '')).filter(Boolean).join('; '));
|
|
327
|
+
add('Marital status', p.maritalStatus ? await this.codeableText(p.maritalStatus) : '');
|
|
328
|
+
const langs = [];
|
|
329
|
+
for (const c of list(p.communication).filter(isObject)) {
|
|
330
|
+
const l = await this.codeableText(c.language);
|
|
331
|
+
if (l !== NONE) {
|
|
332
|
+
langs.push(l + (c.preferred === true ? ' (preferred)' : ''));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
add('Language', langs.join(', '));
|
|
336
|
+
add('Managing organization', this.referenceText(p.managingOrganization));
|
|
337
|
+
add('General practitioner', list(p.generalPractitioner).map(r => this.referenceText(r)).filter(Boolean).join('; '));
|
|
338
|
+
const contacts = [];
|
|
339
|
+
for (const c of list(p.contact).filter(isObject)) {
|
|
340
|
+
const rel = [];
|
|
341
|
+
for (const r of list(c.relationship)) {
|
|
342
|
+
rel.push(await this.codeableText(r));
|
|
343
|
+
}
|
|
344
|
+
contacts.push([humanNameText(c.name), rel.filter(x => x !== NONE).join('/') ? `(${rel.filter(x => x !== NONE).join('/')})` : '', list(c.telecom).map(contactPointText).filter(Boolean).join(', ')].filter(Boolean).join(' '));
|
|
345
|
+
}
|
|
346
|
+
add('Contacts', contacts.filter(Boolean).join('; '));
|
|
347
|
+
if (rows.length > 0) {
|
|
348
|
+
h += `<table class="ips-facts">${rows.join('')}</table>`;
|
|
349
|
+
}
|
|
350
|
+
return h;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- sections ----
|
|
354
|
+
|
|
355
|
+
async renderSection(section, level, out) {
|
|
356
|
+
const rank = sectionRank(section);
|
|
357
|
+
const title = str(section.title).trim() || (rank >= 0 ? SECTION_ORDER[rank][1] : '') || str(section.code?.text) || str(list(section.code?.coding)[0]?.display) || 'Section';
|
|
358
|
+
const resources = [];
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
const missing = [];
|
|
361
|
+
for (const e of list(section.entry).filter(isObject)) {
|
|
362
|
+
const r = resolve(this.bundle, e);
|
|
363
|
+
if (r && !seen.has(r)) {
|
|
364
|
+
seen.add(r);
|
|
365
|
+
resources.push(r);
|
|
366
|
+
} else if (!r) {
|
|
367
|
+
missing.push(str(e.display) || str(e.reference) || '(no reference)');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
let h = '';
|
|
371
|
+
if (resources.length > 0) {
|
|
372
|
+
h += await this.renderTable(resources);
|
|
373
|
+
} else if (missing.length === 0) {
|
|
374
|
+
const div = str(section.text?.div);
|
|
375
|
+
if (div) {
|
|
376
|
+
// the submitter's narrative, passed through: say so
|
|
377
|
+
h += '<p class="ips-as-submitted"><i>Narrative as submitted</i></p>' + innerOf(div);
|
|
378
|
+
} else if (isObject(section.emptyReason)) {
|
|
379
|
+
h += `<p><i>${await this.codeableText(section.emptyReason)}</i></p>`;
|
|
380
|
+
} else {
|
|
381
|
+
h += '<p><i>Nothing found to render</i></p>';
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (missing.length > 0) {
|
|
385
|
+
h += `<p><i>Not in the document: ${missing.map(escape).join(', ')}</i></p>`;
|
|
386
|
+
}
|
|
387
|
+
out.push({ section, title, level, inner: h });
|
|
388
|
+
for (const sub of list(section.section).filter(isObject)) {
|
|
389
|
+
await this.renderSection(sub, Math.min(level + 1, 6), out);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A table of resources: the columns of the first resource's type (the others of that
|
|
395
|
+
* type share them; a mixed section gets the generic columns), less the columns no row
|
|
396
|
+
* fills; follow-up rows for the nested parts.
|
|
397
|
+
*/
|
|
398
|
+
async renderTable(resources) {
|
|
399
|
+
const types = new Set(resources.map(r => r.resourceType));
|
|
400
|
+
const def = types.size === 1 && COLUMNS[resources[0].resourceType] ? COLUMNS[resources[0].resourceType] : GENERIC;
|
|
401
|
+
const rows = [];
|
|
402
|
+
for (const r of resources) {
|
|
403
|
+
const cells = [];
|
|
404
|
+
for (const col of def.columns) {
|
|
405
|
+
cells.push(await col.value(r, this));
|
|
406
|
+
}
|
|
407
|
+
const follow = [];
|
|
408
|
+
for (const f of def.follow || []) {
|
|
409
|
+
const items = (await f.items(r, this)).filter(Boolean);
|
|
410
|
+
if (items.length > 0) {
|
|
411
|
+
follow.push({ label: f.label, items });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
rows.push({ cells, follow });
|
|
415
|
+
}
|
|
416
|
+
const keep = def.columns.map((c, i) => rows.some(r => r.cells[i] && r.cells[i] !== NONE));
|
|
417
|
+
const cols = def.columns.filter((c, i) => keep[i]);
|
|
418
|
+
let h = '<table class="ips-section"><thead><tr>' + cols.map(c => `<th>${escape(c.label)}</th>`).join('') + '</tr></thead><tbody>';
|
|
419
|
+
for (const row of rows) {
|
|
420
|
+
h += '<tr>' + row.cells.filter((c, i) => keep[i]).map(c => `<td>${c || NONE}</td>`).join('') + '</tr>';
|
|
421
|
+
for (const f of row.follow) {
|
|
422
|
+
h += `<tr class="ips-follow"><td></td><td colspan="${Math.max(1, cols.length - 1)}"><b>${escape(f.label)}</b><ul>${f.items.map(i => `<li>${i}</li>`).join('')}</ul></td></tr>`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return h + '</tbody></table>';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---- values ----
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* A CodeableConcept's text: its text, else the first coding's display, else any coding's
|
|
432
|
+
* display, else a display the terminology server knows for a coding, else '--'.
|
|
433
|
+
*/
|
|
434
|
+
async codeableText(cc) {
|
|
435
|
+
if (!isObject(cc)) {
|
|
436
|
+
return NONE;
|
|
437
|
+
}
|
|
438
|
+
if (str(cc.text)) {
|
|
439
|
+
return escape(cc.text);
|
|
440
|
+
}
|
|
441
|
+
const codings = list(cc.coding).filter(isObject);
|
|
442
|
+
for (const c of codings) {
|
|
443
|
+
if (str(c.display)) {
|
|
444
|
+
return escape(c.display);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (this.lookup) {
|
|
448
|
+
for (const c of codings) {
|
|
449
|
+
if (str(c.system) && str(c.code)) {
|
|
450
|
+
const d = await this.lookup(c);
|
|
451
|
+
if (str(d)) {
|
|
452
|
+
return escape(d);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const c of codings) {
|
|
458
|
+
if (str(c.code)) {
|
|
459
|
+
return escape(c.code);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return NONE;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async codeablesText(ccs) {
|
|
466
|
+
const out = [];
|
|
467
|
+
for (const cc of list(ccs)) {
|
|
468
|
+
const t = await this.codeableText(cc);
|
|
469
|
+
if (t !== NONE) {
|
|
470
|
+
out.push(t);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return out.join(', ');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** A Reference: what the resource it points at is called, else the reference's display, else the reference. */
|
|
477
|
+
referenceText(ref) {
|
|
478
|
+
if (!isObject(ref)) {
|
|
479
|
+
return '';
|
|
480
|
+
}
|
|
481
|
+
const r = resolve(this.bundle, ref);
|
|
482
|
+
if (r) {
|
|
483
|
+
const d = describeResource(r);
|
|
484
|
+
if (d) {
|
|
485
|
+
return d;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return escape(str(ref.display) || str(ref.reference) || (isObject(ref.identifier) ? identifierText(ref.identifier) : ''));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* A reference followed: an Observation as "code: value", anything else as referenceText.
|
|
493
|
+
* For reasons and evidence, where the observation is the point.
|
|
494
|
+
*/
|
|
495
|
+
async referencedText(ref) {
|
|
496
|
+
const r = resolve(this.bundle, ref);
|
|
497
|
+
if (r && r.resourceType === 'Observation') {
|
|
498
|
+
return await this.observationSummary(r);
|
|
499
|
+
}
|
|
500
|
+
return this.referenceText(ref);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** An Observation in a line: code: value (interpretation). */
|
|
504
|
+
async observationSummary(obs) {
|
|
505
|
+
const value = await this.observationValue(obs);
|
|
506
|
+
const interp = await this.codeablesText(obs.interpretation);
|
|
507
|
+
return `${await this.codeableText(obs.code)}${value ? `: ${value}` : ''}${interp ? ` (${interp})` : ''}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* An Observation's value: value[x], else a blood pressure from its systolic and diastolic
|
|
512
|
+
* components (120/80 mmHg), else its dataAbsentReason, else ''.
|
|
513
|
+
*/
|
|
514
|
+
async observationValue(obs) {
|
|
515
|
+
const v = await this.valueText(obs, 'value');
|
|
516
|
+
if (v) {
|
|
517
|
+
return v;
|
|
518
|
+
}
|
|
519
|
+
const bp = bloodPressure(obs);
|
|
520
|
+
if (bp) {
|
|
521
|
+
return bp;
|
|
522
|
+
}
|
|
523
|
+
return isObject(obs.dataAbsentReason) ? `<i>${await this.codeableText(obs.dataAbsentReason)}</i>` : '';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** A choice value (value[x], onset[x], effective[x], ...) rendered by their type. */
|
|
527
|
+
async valueText(obj, prefix) {
|
|
528
|
+
const c = choice(obj, prefix);
|
|
529
|
+
if (!c) {
|
|
530
|
+
return '';
|
|
531
|
+
}
|
|
532
|
+
const v = c.value;
|
|
533
|
+
switch (c.type) {
|
|
534
|
+
case 'Quantity': case 'Age': case 'Duration': case 'Count': case 'Distance': case 'SimpleQuantity': return quantityText(v);
|
|
535
|
+
case 'CodeableConcept': return await this.codeableText(v);
|
|
536
|
+
case 'String': case 'Integer': case 'Decimal': case 'Boolean': case 'Uri': case 'Code': case 'Time': return escape(str(v));
|
|
537
|
+
case 'DateTime': case 'Date': case 'Instant': return dateText(v);
|
|
538
|
+
case 'Period': return periodText(v);
|
|
539
|
+
case 'Range': return rangeText(v);
|
|
540
|
+
case 'Ratio': return ratioText(v);
|
|
541
|
+
case 'SampledData': return '(sampled data)';
|
|
542
|
+
case 'Attachment': return attachmentText(v);
|
|
543
|
+
case 'Reference': return this.referenceText(v);
|
|
544
|
+
case 'Timing': return await this.timingText(v);
|
|
545
|
+
case 'Annotation': return annotationText(v);
|
|
546
|
+
case 'Identifier': return identifierText(v);
|
|
547
|
+
case 'HumanName': return humanNameText(v);
|
|
548
|
+
default: return isObject(v) ? '' : escape(str(v));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async timingText(t) {
|
|
553
|
+
if (!isObject(t)) {
|
|
554
|
+
return '';
|
|
555
|
+
}
|
|
556
|
+
if (isObject(t.code)) {
|
|
557
|
+
const c = await this.codeableText(t.code);
|
|
558
|
+
if (c !== NONE) {
|
|
559
|
+
return c;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const r = t.repeat;
|
|
563
|
+
if (!isObject(r)) {
|
|
564
|
+
return list(t.event).map(dateText).join(', ');
|
|
565
|
+
}
|
|
566
|
+
const parts = [];
|
|
567
|
+
if (r.frequency || r.period) {
|
|
568
|
+
// numbers in FHIR, but this is a document as submitted: whatever is there is text
|
|
569
|
+
const n = (v) => escape(String(v));
|
|
570
|
+
parts.push(`${n(r.frequency || 1)}${r.frequencyMax ? '-' + n(r.frequencyMax) : ''} time${(r.frequencyMax || r.frequency || 1) === 1 ? '' : 's'} per ${n(r.period || 1)}${r.periodMax ? '-' + n(r.periodMax) : ''} ${escape(str(r.periodUnit))}`.trim());
|
|
571
|
+
}
|
|
572
|
+
if (r.duration) {
|
|
573
|
+
parts.push(`for ${escape(String(r.duration))} ${escape(str(r.durationUnit))}`);
|
|
574
|
+
}
|
|
575
|
+
if (list(r.when).length > 0) {
|
|
576
|
+
parts.push(list(r.when).map(w => escape(str(w))).join(', '));
|
|
577
|
+
}
|
|
578
|
+
if (list(r.dayOfWeek).length > 0) {
|
|
579
|
+
parts.push(list(r.dayOfWeek).map(w => escape(str(w))).join(', '));
|
|
580
|
+
}
|
|
581
|
+
const bounds = await this.valueText(r, 'bounds');
|
|
582
|
+
if (bounds) {
|
|
583
|
+
parts.push(bounds);
|
|
584
|
+
}
|
|
585
|
+
return parts.join(' ');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async dosageText(d) {
|
|
589
|
+
if (!isObject(d)) {
|
|
590
|
+
return '';
|
|
591
|
+
}
|
|
592
|
+
if (str(d.text)) {
|
|
593
|
+
return escape(d.text);
|
|
594
|
+
}
|
|
595
|
+
const parts = [];
|
|
596
|
+
for (const dr of list(d.doseAndRate).filter(isObject)) {
|
|
597
|
+
const dose = await this.valueText(dr, 'dose');
|
|
598
|
+
const rate = await this.valueText(dr, 'rate');
|
|
599
|
+
if (dose) {
|
|
600
|
+
parts.push(dose);
|
|
601
|
+
}
|
|
602
|
+
if (rate) {
|
|
603
|
+
parts.push(`at ${rate}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const route = await this.codeableText(d.route);
|
|
607
|
+
if (route !== NONE) {
|
|
608
|
+
parts.push(route);
|
|
609
|
+
}
|
|
610
|
+
const timing = await this.timingText(d.timing);
|
|
611
|
+
if (timing) {
|
|
612
|
+
parts.push(timing);
|
|
613
|
+
}
|
|
614
|
+
const asNeeded = choice(d, 'asNeeded');
|
|
615
|
+
if (asNeeded) {
|
|
616
|
+
parts.push(asNeeded.type === 'Boolean' ? (asNeeded.value ? 'as needed' : '') : `as needed for ${await this.codeableText(asNeeded.value)}`);
|
|
617
|
+
}
|
|
618
|
+
const site = await this.codeableText(d.site);
|
|
619
|
+
if (site !== NONE) {
|
|
620
|
+
parts.push(`(${site})`);
|
|
621
|
+
}
|
|
622
|
+
if (str(d.patientInstruction)) {
|
|
623
|
+
parts.push(`- ${escape(d.patientInstruction)}`);
|
|
624
|
+
}
|
|
625
|
+
return parts.filter(Boolean).join(' ');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** medication[x]: the Medication resource's code, or the concept. */
|
|
629
|
+
async medicationText(r) {
|
|
630
|
+
const c = choice(r, 'medication');
|
|
631
|
+
if (!c) {
|
|
632
|
+
return NONE;
|
|
633
|
+
}
|
|
634
|
+
if (c.type === 'Reference') {
|
|
635
|
+
const med = resolve(this.bundle, c.value);
|
|
636
|
+
if (med && med.resourceType === 'Medication') {
|
|
637
|
+
return await this.codeableText(med.code);
|
|
638
|
+
}
|
|
639
|
+
return this.referenceText(c.value) || NONE;
|
|
640
|
+
}
|
|
641
|
+
return await this.codeableText(c.value);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async notes(r) {
|
|
645
|
+
return list(r.note).map(annotationText).filter(Boolean);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const SYSTOLIC = '8480-6';
|
|
650
|
+
const DIASTOLIC = '8462-4';
|
|
651
|
+
|
|
652
|
+
function componentWithLoinc(obs, code) {
|
|
653
|
+
return list(obs.component).filter(isObject).find(c => list(c.code?.coding).some(x => isObject(x) && x.system === 'http://loinc.org' && x.code === code)) || null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** 120/80 mmHg from an Observation's systolic and diastolic components, or ''. */
|
|
657
|
+
function bloodPressure(obs) {
|
|
658
|
+
const sys = componentWithLoinc(obs, SYSTOLIC);
|
|
659
|
+
const dia = componentWithLoinc(obs, DIASTOLIC);
|
|
660
|
+
if (!sys || !dia || !isObject(sys.valueQuantity) || !isObject(dia.valueQuantity)) {
|
|
661
|
+
return '';
|
|
662
|
+
}
|
|
663
|
+
const unit = str(sys.valueQuantity.unit) || str(sys.valueQuantity.code) || str(dia.valueQuantity.unit) || str(dia.valueQuantity.code);
|
|
664
|
+
const n = (q) => (q.value === undefined || q.value === null ? '?' : String(q.value));
|
|
665
|
+
return escape(`${n(sys.valueQuantity)}/${n(dia.valueQuantity)}${unit ? ' ' + unit : ''}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The inside of a narrative div, as parsed (never the string itself). */
|
|
669
|
+
function innerOf(div) {
|
|
670
|
+
const frag = parse5.parseFragment(str(div));
|
|
671
|
+
// the parsed fragment is <div ...>inner</div>; take the inner
|
|
672
|
+
const root = frag.childNodes.find(n => n.nodeName === 'div') || null;
|
|
673
|
+
return root ? parse5.serialize(root) : parse5.serialize(frag);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function codeText(cc) {
|
|
677
|
+
if (!isObject(cc)) {
|
|
678
|
+
return '';
|
|
679
|
+
}
|
|
680
|
+
const codings = list(cc.coding).filter(isObject);
|
|
681
|
+
return escape(str(cc.text) || str(codings.find(c => str(c.display))?.display) || str(codings[0]?.code) || '');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** What to call a resource when it is referred to: a person's name, a medication's code, ... */
|
|
685
|
+
function describeResource(r) {
|
|
686
|
+
if (!isObject(r)) {
|
|
687
|
+
return '';
|
|
688
|
+
}
|
|
689
|
+
switch (r.resourceType) {
|
|
690
|
+
case 'Patient': case 'Practitioner': case 'RelatedPerson': case 'Person': {
|
|
691
|
+
const n = list(r.name).filter(isObject);
|
|
692
|
+
const official = n.find(x => x.use === 'official') || n[0];
|
|
693
|
+
return humanNameText(official);
|
|
694
|
+
}
|
|
695
|
+
case 'PractitionerRole': {
|
|
696
|
+
const who = r.practitioner?.display ? escape(r.practitioner.display) : '';
|
|
697
|
+
const org = r.organization?.display ? escape(r.organization.display) : '';
|
|
698
|
+
return [who, org].filter(Boolean).join(', ') || list(r.code).map(c => str(c?.text) || str(list(c?.coding)[0]?.display)).filter(Boolean).map(escape).join(', ');
|
|
699
|
+
}
|
|
700
|
+
case 'Organization': case 'Location': case 'Device': {
|
|
701
|
+
const name = str(r.name) || str(list(r.deviceName)[0]?.name);
|
|
702
|
+
return escape(name || str(r.type?.text) || str(list(r.type?.coding)[0]?.display) || '');
|
|
703
|
+
}
|
|
704
|
+
case 'Encounter': return escape(str(r.class?.display) || str(list(r.type)[0]?.text) || '');
|
|
705
|
+
case 'Immunization': return codeText(r.vaccineCode);
|
|
706
|
+
default:
|
|
707
|
+
// a Medication, Condition, Observation, Procedure, AllergyIntolerance...: its code
|
|
708
|
+
return codeText(r.code) || (str(r.title) ? escape(r.title) : '');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ---- the columns, by resource type ----------------------------------------------------------
|
|
713
|
+
//
|
|
714
|
+
// column: { label, value: async (resource, presenter) -> html string ('' or '--' for none) }
|
|
715
|
+
// follow: { label, items: async (resource, presenter) -> html strings, one per bullet }
|
|
716
|
+
|
|
717
|
+
const col = (label, value) => ({ label, value });
|
|
718
|
+
async function referenceRangeText(rr, p) {
|
|
719
|
+
const type = await p.codeableText(rr.type);
|
|
720
|
+
return [str(rr.text) ? escape(rr.text) : rangeText(rr), type !== NONE ? `(${type})` : ''].filter(Boolean).join(' ');
|
|
721
|
+
}
|
|
722
|
+
const codeable = (label, field) => col(label, (r, p) => p.codeableText(r[field]));
|
|
723
|
+
const codeables = (label, field) => col(label, (r, p) => p.codeablesText(r[field]));
|
|
724
|
+
const plain = (label, field) => col(label, (r) => escape(str(r[field])));
|
|
725
|
+
const date = (label, field) => col(label, (r) => dateText(r[field]));
|
|
726
|
+
const which = (label, prefix) => col(label, (r, p) => p.valueText(r, prefix));
|
|
727
|
+
const ref = (label, field) => col(label, (r, p) => list(r[field]).map(x => p.referenceText(x)).filter(Boolean).join('; '));
|
|
728
|
+
const notesFollow = { label: 'Notes', items: (r, p) => p.notes(r) };
|
|
729
|
+
// clinical and verification status together: Active / Confirmed (whichever are there)
|
|
730
|
+
const statusCol = col('Status', async (r, p) => [await p.codeableText(r.clinicalStatus), await p.codeableText(r.verificationStatus)].filter(x => x !== NONE).join(' / '));
|
|
731
|
+
const reasonCol = col('Reason', async (r, p) => {
|
|
732
|
+
const refs = [];
|
|
733
|
+
for (const x of list(r.reasonReference)) {
|
|
734
|
+
refs.push(await p.referencedText(x));
|
|
735
|
+
}
|
|
736
|
+
return [await p.codeablesText(r.reasonCode), ...refs].filter(Boolean).join(', ');
|
|
737
|
+
});
|
|
738
|
+
const unique = (items) => [...new Set(items)];
|
|
739
|
+
|
|
740
|
+
const COLUMNS = {
|
|
741
|
+
AllergyIntolerance: {
|
|
742
|
+
columns: [
|
|
743
|
+
codeable('Allergy / intolerance', 'code'), plain('Type', 'type'), col('Category', (r) => list(r.category).map(c => escape(str(c))).join(', ')),
|
|
744
|
+
plain('Criticality', 'criticality'), statusCol, which('Onset', 'onset'), date('Recorded', 'recordedDate')
|
|
745
|
+
],
|
|
746
|
+
follow: [
|
|
747
|
+
{ label: 'Reactions', items: async (r, p) => {
|
|
748
|
+
const out = [];
|
|
749
|
+
for (const x of list(r.reaction).filter(isObject)) {
|
|
750
|
+
const parts = [await p.codeablesText(x.manifestation)];
|
|
751
|
+
const sub = await p.codeableText(x.substance);
|
|
752
|
+
if (sub !== NONE) {
|
|
753
|
+
parts.push(`to ${sub}`);
|
|
754
|
+
}
|
|
755
|
+
if (str(x.severity)) {
|
|
756
|
+
parts.push(`(${escape(x.severity)})`);
|
|
757
|
+
}
|
|
758
|
+
if (str(x.onset)) {
|
|
759
|
+
parts.push(dateText(x.onset));
|
|
760
|
+
}
|
|
761
|
+
if (str(x.description)) {
|
|
762
|
+
parts.push(`- ${escape(x.description)}`);
|
|
763
|
+
}
|
|
764
|
+
out.push(parts.filter(Boolean).join(' '));
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
} },
|
|
768
|
+
notesFollow
|
|
769
|
+
]
|
|
770
|
+
},
|
|
771
|
+
Condition: {
|
|
772
|
+
columns: [
|
|
773
|
+
codeable('Problem', 'code'), codeables('Category', 'category'), codeable('Severity', 'severity'), statusCol,
|
|
774
|
+
which('Onset', 'onset'), which('Abatement', 'abatement'), date('Recorded', 'recordedDate'), ref('Asserter', 'asserter')
|
|
775
|
+
],
|
|
776
|
+
follow: [
|
|
777
|
+
{ label: 'Body sites', items: async (r, p) => { const t = await p.codeablesText(r.bodySite); return t ? [t] : []; } },
|
|
778
|
+
{ label: 'Stage', items: async (r, p) => { const out = []; for (const s of list(r.stage).filter(isObject)) { const t = await p.codeableText(s.summary); if (t !== NONE) { out.push(t); } } return out; } },
|
|
779
|
+
{ label: 'Evidence', items: async (r, p) => {
|
|
780
|
+
const out = [];
|
|
781
|
+
for (const e of list(r.evidence).filter(isObject)) {
|
|
782
|
+
const details = [];
|
|
783
|
+
for (const x of list(e.detail)) {
|
|
784
|
+
details.push(await p.referencedText(x));
|
|
785
|
+
}
|
|
786
|
+
const t = [await p.codeablesText(e.code), details.filter(Boolean).join(', ')].filter(Boolean).join(': ');
|
|
787
|
+
if (t) {
|
|
788
|
+
out.push(t);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return unique(out);
|
|
792
|
+
} },
|
|
793
|
+
notesFollow
|
|
794
|
+
]
|
|
795
|
+
},
|
|
796
|
+
MedicationStatement: {
|
|
797
|
+
columns: [
|
|
798
|
+
col('Medication', (r, p) => p.medicationText(r)), plain('Status', 'status'), reasonCol, which('Effective', 'effective'), date('Asserted', 'dateAsserted'),
|
|
799
|
+
col('Dosage', async (r, p) => { const d = list(r.dosage).filter(isObject); return d.length === 1 ? await p.dosageText(d[0]) : ''; }), ref('Source', 'informationSource')
|
|
800
|
+
],
|
|
801
|
+
follow: [
|
|
802
|
+
{ label: 'Dosage', items: async (r, p) => { const d = list(r.dosage).filter(isObject); if (d.length < 2) { return []; } const out = []; for (const x of d) { out.push(await p.dosageText(x)); } return out; } },
|
|
803
|
+
notesFollow
|
|
804
|
+
]
|
|
805
|
+
},
|
|
806
|
+
MedicationRequest: {
|
|
807
|
+
columns: [
|
|
808
|
+
col('Medication', (r, p) => p.medicationText(r)), plain('Status', 'status'), plain('Intent', 'intent'), reasonCol, date('Authored', 'authoredOn'),
|
|
809
|
+
col('Dosage', async (r, p) => { const d = list(r.dosageInstruction).filter(isObject); return d.length === 1 ? await p.dosageText(d[0]) : ''; }), ref('Requester', 'requester')
|
|
810
|
+
],
|
|
811
|
+
follow: [
|
|
812
|
+
{ label: 'Dosage instructions', items: async (r, p) => { const d = list(r.dosageInstruction).filter(isObject); if (d.length < 2) { return []; } const out = []; for (const x of d) { out.push(await p.dosageText(x)); } return out; } },
|
|
813
|
+
notesFollow
|
|
814
|
+
]
|
|
815
|
+
},
|
|
816
|
+
Medication: {
|
|
817
|
+
columns: [codeable('Medication', 'code'), codeable('Form', 'form'), plain('Status', 'status'), col('Lot', (r) => escape(str(r.batch?.lotNumber))), col('Expiry', (r) => dateText(r.batch?.expirationDate))],
|
|
818
|
+
follow: [
|
|
819
|
+
{ label: 'Ingredients', items: async (r, p) => {
|
|
820
|
+
const out = [];
|
|
821
|
+
for (const i of list(r.ingredient).filter(isObject)) {
|
|
822
|
+
const item = await p.valueText(i, 'item');
|
|
823
|
+
out.push([item, ratioText(i.strength)].filter(Boolean).join(' '));
|
|
824
|
+
}
|
|
825
|
+
return out.filter(Boolean);
|
|
826
|
+
} }
|
|
827
|
+
]
|
|
828
|
+
},
|
|
829
|
+
Immunization: {
|
|
830
|
+
columns: [
|
|
831
|
+
codeable('Vaccine', 'vaccineCode'), plain('Status', 'status'), which('Date', 'occurrence'),
|
|
832
|
+
col('Target disease', async (r, p) => { const out = []; for (const pa of list(r.protocolApplied).filter(isObject)) { const t = await p.codeablesText(pa.targetDisease); if (t) { out.push(t); } } return out.join(', '); }),
|
|
833
|
+
col('Dose', (r) => list(r.protocolApplied).filter(isObject).map(pa => { const c = choice(pa, 'doseNumber'); return c ? escape(str(c.value)) : ''; }).filter(Boolean).join(', ')),
|
|
834
|
+
codeable('Route', 'route'), codeable('Site', 'site'), plain('Lot', 'lotNumber'), ref('Manufacturer', 'manufacturer'),
|
|
835
|
+
col('Performer', (r, p) => list(r.performer).filter(isObject).map(x => p.referenceText(x.actor)).filter(Boolean).join('; ')),
|
|
836
|
+
codeable('Status reason', 'statusReason')
|
|
837
|
+
],
|
|
838
|
+
follow: [
|
|
839
|
+
{ label: 'Reactions', items: (r, p) => list(r.reaction).filter(isObject).map(x => [x.date ? dateText(x.date) : '', p.referenceText(x.detail), x.reported ? '(reported)' : ''].filter(Boolean).join(' ')).filter(Boolean) },
|
|
840
|
+
notesFollow
|
|
841
|
+
]
|
|
842
|
+
},
|
|
843
|
+
Procedure: {
|
|
844
|
+
columns: [
|
|
845
|
+
codeable('Procedure', 'code'), plain('Status', 'status'), which('Performed', 'performed'), codeables('Body site', 'bodySite'), reasonCol, codeable('Outcome', 'outcome'),
|
|
846
|
+
col('Performer', (r, p) => list(r.performer).filter(isObject).map(x => p.referenceText(x.actor)).filter(Boolean).join('; ')), codeable('Status reason', 'statusReason')
|
|
847
|
+
],
|
|
848
|
+
follow: [
|
|
849
|
+
{ label: 'Complications', items: async (r, p) => { const t = await p.codeablesText(r.complication); return t ? [t] : []; } },
|
|
850
|
+
{ label: 'Follow up', items: async (r, p) => { const t = await p.codeablesText(r.followUp); return t ? [t] : []; } },
|
|
851
|
+
notesFollow
|
|
852
|
+
]
|
|
853
|
+
},
|
|
854
|
+
Observation: {
|
|
855
|
+
columns: [
|
|
856
|
+
codeable('Observation', 'code'),
|
|
857
|
+
col('Value', (r, p) => p.observationValue(r)),
|
|
858
|
+
codeables('Interpretation', 'interpretation'),
|
|
859
|
+
col('Reference range', async (r, p) => { const rr = list(r.referenceRange).filter(isObject); return rr.length === 1 ? await referenceRangeText(rr[0], p) : ''; }),
|
|
860
|
+
col('Date', async (r, p) => (await p.valueText(r, 'effective')) || dateText(r.issued)), plain('Status', 'status'), codeables('Category', 'category'), ref('Performer', 'performer'),
|
|
861
|
+
codeable('Body site', 'bodySite'), codeable('Method', 'method'), ref('Specimen', 'specimen'), ref('Device', 'device')
|
|
862
|
+
],
|
|
863
|
+
follow: [
|
|
864
|
+
{ label: 'Components', items: async (r, p) => {
|
|
865
|
+
const out = [];
|
|
866
|
+
const shownAsValue = bloodPressure(r) && !choice(r, 'value') ? [componentWithLoinc(r, SYSTOLIC), componentWithLoinc(r, DIASTOLIC)] : [];
|
|
867
|
+
for (const c of list(r.component).filter(isObject)) {
|
|
868
|
+
if (shownAsValue.includes(c)) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const value = (await p.valueText(c, 'value')) || (isObject(c.dataAbsentReason) ? `<i>${await p.codeableText(c.dataAbsentReason)}</i>` : NONE);
|
|
872
|
+
const interp = await p.codeablesText(c.interpretation);
|
|
873
|
+
out.push(`${await p.codeableText(c.code)}: ${value}${interp ? ` (${interp})` : ''}`);
|
|
874
|
+
}
|
|
875
|
+
return out;
|
|
876
|
+
} },
|
|
877
|
+
{ label: 'Reference ranges', items: async (r, p) => {
|
|
878
|
+
const rrs = list(r.referenceRange).filter(isObject);
|
|
879
|
+
if (rrs.length < 2) {
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
const out = [];
|
|
883
|
+
for (const rr of rrs) {
|
|
884
|
+
out.push(await referenceRangeText(rr, p));
|
|
885
|
+
}
|
|
886
|
+
return out.filter(Boolean);
|
|
887
|
+
} },
|
|
888
|
+
{ label: 'Members', items: async (r, p) => { const out = []; for (const x of list(r.hasMember)) { out.push(await p.referencedText(x)); } return unique(out.filter(Boolean)); } },
|
|
889
|
+
notesFollow
|
|
890
|
+
]
|
|
891
|
+
},
|
|
892
|
+
DiagnosticReport: {
|
|
893
|
+
columns: [
|
|
894
|
+
codeable('Report', 'code'), plain('Status', 'status'), codeables('Category', 'category'), which('Date', 'effective'), date('Issued', 'issued'), ref('Performer', 'performer'),
|
|
895
|
+
col('Conclusion', (r) => escape(str(r.conclusion)))
|
|
896
|
+
],
|
|
897
|
+
follow: [
|
|
898
|
+
{ label: 'Results', items: async (r, p) => {
|
|
899
|
+
const out = [];
|
|
900
|
+
for (const x of list(r.result)) {
|
|
901
|
+
out.push(await p.referencedText(x));
|
|
902
|
+
}
|
|
903
|
+
return unique(out.filter(Boolean));
|
|
904
|
+
} },
|
|
905
|
+
{ label: 'Conclusion codes', items: async (r, p) => { const t = await p.codeablesText(r.conclusionCode); return t ? [t] : []; } },
|
|
906
|
+
{ label: 'Imaging', items: (r, p) => list(r.imagingStudy).map(x => p.referenceText(x)).filter(Boolean) }
|
|
907
|
+
]
|
|
908
|
+
},
|
|
909
|
+
DeviceUseStatement: {
|
|
910
|
+
columns: [
|
|
911
|
+
col('Device', (r, p) => { const d = resolve(p.bundle, r.device); return d ? describeResource(d) || p.referenceText(r.device) : p.referenceText(r.device); }),
|
|
912
|
+
plain('Status', 'status'), which('Period', 'timing'), codeable('Body site', 'bodySite'), codeables('Reason', 'reasonCode'), date('Recorded', 'recordedOn'), ref('Source', 'source')
|
|
913
|
+
],
|
|
914
|
+
follow: [notesFollow]
|
|
915
|
+
},
|
|
916
|
+
Device: {
|
|
917
|
+
columns: [
|
|
918
|
+
col('Device', (r) => escape(str(list(r.deviceName)[0]?.name))), codeable('Type', 'type'), plain('Manufacturer', 'manufacturer'), plain('Model', 'modelNumber'), plain('Serial', 'serialNumber'),
|
|
919
|
+
plain('Status', 'status'), date('Expiry', 'expirationDate'), col('UDI', (r) => escape(str(list(r.udiCarrier)[0]?.deviceIdentifier)))
|
|
920
|
+
],
|
|
921
|
+
follow: [notesFollow]
|
|
922
|
+
},
|
|
923
|
+
Flag: {
|
|
924
|
+
columns: [codeable('Flag', 'code'), plain('Status', 'status'), codeables('Category', 'category'), col('Period', (r) => periodText(r.period)), ref('Author', 'author')]
|
|
925
|
+
},
|
|
926
|
+
Consent: {
|
|
927
|
+
columns: [codeable('Scope', 'scope'), codeables('Category', 'category'), plain('Status', 'status'), date('Date', 'dateTime'), col('Provision', (r) => escape(str(r.provision?.type))), ref('Performer', 'performer')]
|
|
928
|
+
},
|
|
929
|
+
CarePlan: {
|
|
930
|
+
columns: [plain('Title', 'title'), plain('Status', 'status'), plain('Intent', 'intent'), codeables('Category', 'category'), col('Period', (r) => periodText(r.period)), plain('Description', 'description'), ref('Author', 'author')],
|
|
931
|
+
follow: [
|
|
932
|
+
{ label: 'Activities', items: async (r, p) => {
|
|
933
|
+
const out = [];
|
|
934
|
+
for (const a of list(r.activity).filter(isObject)) {
|
|
935
|
+
if (isObject(a.detail)) {
|
|
936
|
+
const t = [await p.codeableText(a.detail.code), str(a.detail.description) ? escape(a.detail.description) : '', str(a.detail.status) ? `(${escape(a.detail.status)})` : ''].filter(x => x && x !== NONE).join(' ');
|
|
937
|
+
out.push(t || NONE);
|
|
938
|
+
} else if (isObject(a.reference)) {
|
|
939
|
+
out.push(p.referenceText(a.reference));
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return out.filter(Boolean);
|
|
943
|
+
} },
|
|
944
|
+
notesFollow
|
|
945
|
+
]
|
|
946
|
+
},
|
|
947
|
+
ClinicalImpression: {
|
|
948
|
+
columns: [plain('Status', 'status'), codeable('Code', 'code'), date('Date', 'date'), which('Effective', 'effective'), plain('Summary', 'summary'), plain('Description', 'description'), ref('Assessor', 'assessor')],
|
|
949
|
+
follow: [
|
|
950
|
+
{ label: 'Findings', items: async (r, p) => { const out = []; for (const f of list(r.finding).filter(isObject)) { out.push([await p.valueText(f, 'item'), str(f.basis) ? escape(f.basis) : ''].filter(Boolean).join(' - ')); } return out.filter(Boolean); } },
|
|
951
|
+
{ label: 'Problems', items: (r, p) => list(r.problem).map(x => p.referenceText(x)).filter(Boolean) },
|
|
952
|
+
notesFollow
|
|
953
|
+
]
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
// anything else: category, name, type, code, value - if they have them
|
|
958
|
+
const GENERIC = {
|
|
959
|
+
columns: [
|
|
960
|
+
col('Resource', (r) => escape(`${str(r.resourceType)}${str(r.id) ? '/' + r.id : ''}`)),
|
|
961
|
+
codeables('Category', 'category'),
|
|
962
|
+
col('Name', (r) => Array.isArray(r.name) ? list(r.name).map(n => isObject(n) ? humanNameText(n) : escape(str(n))).filter(Boolean).join('; ') : (isObject(r.name) ? humanNameText(r.name) : escape(str(r.name)))),
|
|
963
|
+
col('Type', (r, p) => Array.isArray(r.type) ? p.codeablesText(r.type) : (isObject(r.type) ? p.codeableText(r.type) : escape(str(r.type)))),
|
|
964
|
+
col('Code', (r, p) => isObject(r.code) ? p.codeableText(r.code) : escape(str(r.code))),
|
|
965
|
+
which('Value', 'value'),
|
|
966
|
+
plain('Status', 'status')
|
|
967
|
+
],
|
|
968
|
+
follow: [notesFollow]
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
module.exports = {
|
|
972
|
+
Presenter, COLUMNS, GENERIC, describeResource, NONE,
|
|
973
|
+
dateText, periodText, quantityText, rangeText, humanNameText, addressText, identifierText, contactPointText, annotationText
|
|
974
|
+
};
|