fhirsmith 0.18.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +149 -0
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +148 -14
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +185 -66
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +608 -64
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +57 -58
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +166 -13
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/ips/analysis.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
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 analysis step: a look at each resource in the document (and at the Composition
|
|
9
|
+
* section by section) for the things a reader should know before trusting it - extensions
|
|
10
|
+
* (with their definitions, where they can be found), modifier extensions and implicitRules,
|
|
11
|
+
* records that are inactive, entered in error or of unknown status, narrative or the lack of
|
|
12
|
+
* it, a subject other than the document's or a focus, dates in the future, and for a few
|
|
13
|
+
* resources, things particular to them (a Patient's links, a Composition that isn't final,
|
|
14
|
+
* a section's emptyReason or author). And for the document as a whole, on the Composition: how
|
|
15
|
+
* it says there's nothing to report (negation) - with a section's emptyReason (the IPS
|
|
16
|
+
* pattern), with entries whose codes say so, like No known allergy (the US Core pattern), or
|
|
17
|
+
* not at all - and a warning when it uses both; and an error when it has SNOMED CT codes that
|
|
18
|
+
* don't say which edition they are from, and its nationality is unknown (so the edition can't
|
|
19
|
+
* be worked out: the International Edition is assumed).
|
|
20
|
+
*
|
|
21
|
+
* It walks the same tree as the structure view (structure.js), and keeps a list of findings
|
|
22
|
+
* for each resource and section, by the node's key; the analysis view shows the tree with
|
|
23
|
+
* the findings under each node.
|
|
24
|
+
*
|
|
25
|
+
* A finding is { kind, text } - or for an extension { kind, url, path, found, title?,
|
|
26
|
+
* description? } - where kind is one of
|
|
27
|
+
* error SNOMED CT codes with no edition in a document of unknown nationality (red)
|
|
28
|
+
* ok nothing to comment on
|
|
29
|
+
* narrative whether there's narrative (always there; doesn't count against ok)
|
|
30
|
+
* extension an extension (but not textLink)
|
|
31
|
+
* modifier a modifier extension, or implicitRules (maroon)
|
|
32
|
+
* status inactive, entered in error, unknown, or missing status (navy)
|
|
33
|
+
* subject a subject other than the document's, or a focus (dark green)
|
|
34
|
+
* attention a date in the future where one isn't expected, a Patient's link, a
|
|
35
|
+
* Composition that isn't final, a document that uses more than one approach
|
|
36
|
+
* to negation
|
|
37
|
+
* negation how the document does negation (on the Composition; like narrative, always
|
|
38
|
+
* there, and doesn't count against ok)
|
|
39
|
+
* note anything else (a section's emptyReason, a section's author)
|
|
40
|
+
*
|
|
41
|
+
* @module ips/analysis
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const { structureTree, flatten, isObject, text } = require('./structure');
|
|
45
|
+
const { typeOf } = require('./terminology');
|
|
46
|
+
|
|
47
|
+
// the resources whose status (or lifecycleStatus) is required, as the element that holds it
|
|
48
|
+
const STATUS_ELEMENTS = {
|
|
49
|
+
Observation: 'status', DiagnosticReport: 'status', MedicationStatement: 'status', MedicationUsage: 'status', MedicationRequest: 'status',
|
|
50
|
+
MedicationAdministration: 'status', MedicationDispense: 'status', Immunization: 'status', Procedure: 'status', CarePlan: 'status',
|
|
51
|
+
ClinicalImpression: 'status', Flag: 'status', Consent: 'status', Encounter: 'status', DeviceUseStatement: 'status', DeviceUsage: 'status',
|
|
52
|
+
Composition: 'status', Goal: 'lifecycleStatus', ServiceRequest: 'status', ImagingStudy: 'status', FamilyMemberHistory: 'status',
|
|
53
|
+
RiskAssessment: 'status', DocumentReference: 'status', QuestionnaireResponse: 'status', NutritionOrder: 'status', ImmunizationRecommendation: null
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// extensions not worth mentioning
|
|
57
|
+
const IGNORED_EXTENSIONS = new Set(['http://hl7.org/fhir/StructureDefinition/textLink']);
|
|
58
|
+
|
|
59
|
+
// ---- dates in the future ----
|
|
60
|
+
//
|
|
61
|
+
// Only elements the (R4) type model says are dates (date, dateTime, instant) are looked at,
|
|
62
|
+
// so a string or an identifier that happens to look like a date isn't one. Some dates are
|
|
63
|
+
// about the future by their nature, wherever they are; some resources are about the future
|
|
64
|
+
// - always, or while their status says the thing hasn't happened yet.
|
|
65
|
+
|
|
66
|
+
// the statuses of a request (or plan) that is over: its dates should be past
|
|
67
|
+
const CLOSED = new Set(['completed', 'revoked', 'cancelled', 'entered-in-error', 'stopped', 'failed', 'rejected']);
|
|
68
|
+
const open = (r) => !CLOSED.has(r.status);
|
|
69
|
+
|
|
70
|
+
// resources whose dates may be in the future, by type: a function of the resource that says
|
|
71
|
+
// whether they may (for that resource as it is)
|
|
72
|
+
const FUTURE_INTENT = {
|
|
73
|
+
// about the future, whatever their status
|
|
74
|
+
Appointment: () => true,
|
|
75
|
+
AppointmentResponse: () => true,
|
|
76
|
+
ImmunizationRecommendation: () => true,
|
|
77
|
+
Schedule: () => true,
|
|
78
|
+
Slot: () => true,
|
|
79
|
+
// requests and plans, until they are over
|
|
80
|
+
ServiceRequest: open,
|
|
81
|
+
MedicationRequest: open,
|
|
82
|
+
DeviceRequest: open,
|
|
83
|
+
NutritionOrder: open,
|
|
84
|
+
CommunicationRequest: open,
|
|
85
|
+
SupplyRequest: open,
|
|
86
|
+
VisionPrescription: open,
|
|
87
|
+
CarePlan: open,
|
|
88
|
+
Task: open,
|
|
89
|
+
Goal: (r) => !CLOSED.has(r.lifecycleStatus),
|
|
90
|
+
// events, while they are still to come
|
|
91
|
+
Procedure: (r) => r.status === 'preparation',
|
|
92
|
+
Encounter: (r) => r.status === 'planned',
|
|
93
|
+
MedicationStatement: (r) => r.status === 'intended',
|
|
94
|
+
MedicationUsage: (r) => r.status === 'intended',
|
|
95
|
+
DeviceUseStatement: (r) => r.status === 'intended',
|
|
96
|
+
DeviceUsage: (r) => r.status === 'intended'
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Whether a resource's dates may be in the future. */
|
|
100
|
+
function mayBeFuture(r) {
|
|
101
|
+
const f = FUTURE_INTENT[r.resourceType];
|
|
102
|
+
return !!f && f(r);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// dates that are about the future by their nature, wherever they are: the end of a period
|
|
106
|
+
// (a name, an address, an identifier valid until; a prescription's validity), an expiry,
|
|
107
|
+
// a due date. By element name, or by the type they are in (a Timing is a schedule)
|
|
108
|
+
const FUTURE_ELEMENTS = new Set(['end', 'expirationDate', 'dueDate']);
|
|
109
|
+
const FUTURE_TYPES = new Set(['Timing']);
|
|
110
|
+
const DATE_TYPES = new Set(['date', 'dateTime', 'instant']);
|
|
111
|
+
|
|
112
|
+
const DATE_VALUE = /^(\d{4})(?:-(\d\d)(?:-(\d\d)(?:T(\d\d):(\d\d)(?::(\d\d)(?:\.\d+)?)?(Z|[+-]\d\d:\d\d)?)?)?)?$/;
|
|
113
|
+
|
|
114
|
+
/** The earliest moment a date/dateTime/instant can mean, as ms, or null if it isn't one. */
|
|
115
|
+
function earliest(v) {
|
|
116
|
+
const m = DATE_VALUE.exec(v);
|
|
117
|
+
if (!m) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const [, y, mo, d, h, mi, sec, tz] = m;
|
|
121
|
+
if (h && tz) {
|
|
122
|
+
const t = Date.parse(v);
|
|
123
|
+
return Number.isNaN(t) ? null : t;
|
|
124
|
+
}
|
|
125
|
+
// no time zone: the earliest it could be anywhere (UTC+14)
|
|
126
|
+
return Date.UTC(+y, mo ? +mo - 1 : 0, d ? +d : 1, h ? +h : 0, mi ? +mi : 0, sec ? +sec : 0) - 14 * 3600000;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Walk a resource (or a section), calling visit(key, value, path) for every property, at
|
|
131
|
+
* any depth, but not into contained resources (they are nodes of their own) nor into the
|
|
132
|
+
* keys skipped. path is FHIRPath-like, from prefix; a primitive's extensions (_birthDate)
|
|
133
|
+
* are at the primitive's path.
|
|
134
|
+
*/
|
|
135
|
+
function walk(node, prefix, skip, visit) {
|
|
136
|
+
const go = (v, path) => {
|
|
137
|
+
if (Array.isArray(v)) {
|
|
138
|
+
v.forEach((x, i) => go(x, `${path}[${i}]`));
|
|
139
|
+
} else if (isObject(v)) {
|
|
140
|
+
for (const [k, x] of Object.entries(v)) {
|
|
141
|
+
if (k === 'contained' || (path === prefix && skip.includes(k))) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const name = k.startsWith('_') ? k.substring(1) : k;
|
|
145
|
+
visit(k, x, path, `${path}.${name}`);
|
|
146
|
+
go(x, `${path}.${name}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
go(node, prefix);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The extensions and modifier extensions on a resource or section, as [kind, url, path]. */
|
|
154
|
+
function extensionsIn(node, prefix, skip) {
|
|
155
|
+
const out = [];
|
|
156
|
+
walk(node, prefix, skip, (k, v, at) => {
|
|
157
|
+
if ((k === 'extension' || k === 'modifierExtension') && Array.isArray(v)) {
|
|
158
|
+
for (const ext of v) {
|
|
159
|
+
// an extension's own parts (relative urls) are part of it, not extensions of their own
|
|
160
|
+
if (isObject(ext) && text(ext.url) && /^[a-z][a-z0-9+.-]*:/i.test(ext.url) && !IGNORED_EXTENSIONS.has(ext.url)) {
|
|
161
|
+
out.push([k === 'modifierExtension' ? 'modifier' : 'extension', ext.url, at]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The dates after now in a resource or section, as [path, value]: the elements whose type is
|
|
171
|
+
* date, dateTime or instant, walking the resource with the type model - but not those about
|
|
172
|
+
* the future by their nature (FUTURE_ELEMENTS, FUTURE_TYPES), nor inside extensions (what
|
|
173
|
+
* their dates mean isn't known), nor elements the model doesn't know.
|
|
174
|
+
* @param {Object} node - the resource, or a section
|
|
175
|
+
* @param {string} modelPath - the model's path for node: the resource type, or Composition.section
|
|
176
|
+
* @param {string} prefix - the path shown for node
|
|
177
|
+
* @param {string[]} skip - keys of node not to look in (a Composition's sections)
|
|
178
|
+
* @param {number} now - in ms
|
|
179
|
+
*/
|
|
180
|
+
function futureDates(node, modelPath, prefix, skip, now) {
|
|
181
|
+
const out = [];
|
|
182
|
+
const go = (obj, model, shown, top) => {
|
|
183
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
184
|
+
if (v === null || k === 'resourceType' || k === 'contained' || k === 'extension' || k === 'modifierExtension' ||
|
|
185
|
+
k.startsWith('_') || (top && skip.includes(k)) || FUTURE_ELEMENTS.has(k)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const resolved = typeOf(`${model}.${k}`);
|
|
189
|
+
if (!resolved || FUTURE_TYPES.has(resolved.type)) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const items = Array.isArray(v) ? v : [v];
|
|
193
|
+
items.forEach((item, i) => {
|
|
194
|
+
const path = `${shown}.${k}${Array.isArray(v) ? `[${i}]` : ''}`;
|
|
195
|
+
if (DATE_TYPES.has(resolved.type)) {
|
|
196
|
+
const t = typeof item === 'string' ? earliest(item) : null;
|
|
197
|
+
if (t !== null && t > now) {
|
|
198
|
+
out.push([path, item]);
|
|
199
|
+
}
|
|
200
|
+
} else if (isObject(item)) {
|
|
201
|
+
if (resolved.type === 'BackboneElement' || resolved.type === 'Element') {
|
|
202
|
+
go(item, resolved.path, path, false);
|
|
203
|
+
} else if (/^[A-Z]/.test(resolved.type) && !['Resource', 'Narrative', 'Extension'].includes(resolved.type)) {
|
|
204
|
+
// a datatype: its own elements are defined on the type
|
|
205
|
+
go(item, resolved.type, path, false);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
go(node, modelPath, prefix, true);
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A CodeableConcept or Coding's code is one of codes. */
|
|
216
|
+
const hasCode = (cc, codes) => (Array.isArray(cc?.coding) ? cc.coding : []).some(c => isObject(c) && codes.includes(c.code));
|
|
217
|
+
|
|
218
|
+
/** A CodeableConcept, as words. */
|
|
219
|
+
function conceptWords(cc) {
|
|
220
|
+
if (!isObject(cc)) {
|
|
221
|
+
return '';
|
|
222
|
+
}
|
|
223
|
+
const codings = Array.isArray(cc.coding) ? cc.coding.filter(isObject) : [];
|
|
224
|
+
return text(cc.text) || text(codings.find(c => text(c.display))?.display) || text(codings[0]?.code) || '';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A Reference, as words. */
|
|
228
|
+
function referenceWords(ref) {
|
|
229
|
+
if (!isObject(ref)) {
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
const id = isObject(ref.identifier) ? [text(ref.identifier.system), text(ref.identifier.value)].filter(Boolean).join('|') : '';
|
|
233
|
+
return [text(ref.reference) || id, text(ref.display) ? `"${ref.display}"` : ''].filter(Boolean).join(' ') || '(empty reference)';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether two references are to the same thing: they resolve to the same resource in the
|
|
238
|
+
* document, or are the same string, or have the same identifier (system and value).
|
|
239
|
+
*/
|
|
240
|
+
function sameReference(a, fromA, b, fromB, resolve) {
|
|
241
|
+
if (text(a.reference) && text(b.reference)) {
|
|
242
|
+
const ta = resolve(fromA, a.reference);
|
|
243
|
+
const tb = resolve(fromB, b.reference);
|
|
244
|
+
if (ta && tb) {
|
|
245
|
+
return ta.key === tb.key;
|
|
246
|
+
}
|
|
247
|
+
if (a.reference === b.reference) {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const ia = a.identifier;
|
|
252
|
+
const ib = b.identifier;
|
|
253
|
+
return isObject(ia) && isObject(ib) && text(ia.value) !== '' && ia.system === ib.system && ia.value === ib.value;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The findings common to resources and sections: extensions, narrative, focus, future dates. */
|
|
257
|
+
async function common(findings, thing, modelPath, prefix, skip, options, cache) {
|
|
258
|
+
for (const [kind, url, path] of extensionsIn(thing, prefix, skip)) {
|
|
259
|
+
if (!cache.has(url)) {
|
|
260
|
+
let def = null;
|
|
261
|
+
if (options.lookupExtension) {
|
|
262
|
+
try {
|
|
263
|
+
def = await options.lookupExtension(url);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
def = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
cache.set(url, def);
|
|
269
|
+
}
|
|
270
|
+
const def = cache.get(url);
|
|
271
|
+
findings.push({ kind, url, path, found: !!def, ...(def ? { title: text(def.title) || text(def.name), description: text(def.description) } : {}) });
|
|
272
|
+
}
|
|
273
|
+
const focus = Array.isArray(thing.focus) ? thing.focus.filter(isObject) : isObject(thing.focus) ? [thing.focus] : [];
|
|
274
|
+
if (focus.length) {
|
|
275
|
+
findings.push({ kind: 'subject', text: `Has a focus: ${focus.map(referenceWords).join(', ')}` });
|
|
276
|
+
}
|
|
277
|
+
if (!mayBeFuture(thing)) {
|
|
278
|
+
for (const [path, v] of futureDates(thing, modelPath, prefix, skip, options.now)) {
|
|
279
|
+
findings.push({ kind: 'attention', text: `A date in the future: ${path} = ${v}` });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The narrative finding. */
|
|
285
|
+
function narrativeOf(thing) {
|
|
286
|
+
const div = text(thing.text?.div);
|
|
287
|
+
return div ? { kind: 'narrative', text: `Has narrative${text(thing.text.status) ? ` (${thing.text.status})` : ''}` } : { kind: 'narrative', text: 'No narrative' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** What to say about a resource (the Composition without its sections). */
|
|
291
|
+
async function analyseResource(node, ctx) {
|
|
292
|
+
const r = node.resource;
|
|
293
|
+
const type = r.resourceType;
|
|
294
|
+
const prefix = type || 'Resource';
|
|
295
|
+
const skip = node.kind === 'root' ? ['section'] : [];
|
|
296
|
+
const findings = [];
|
|
297
|
+
if (text(r.implicitRules)) {
|
|
298
|
+
findings.push({ kind: 'modifier', text: `implicitRules: ${r.implicitRules}` });
|
|
299
|
+
}
|
|
300
|
+
// status: inactive, entered in error, unknown, or missing
|
|
301
|
+
if (r.active === false) {
|
|
302
|
+
findings.push({ kind: 'status', text: 'Not active (active = false)' });
|
|
303
|
+
}
|
|
304
|
+
const statusElement = Object.prototype.hasOwnProperty.call(STATUS_ELEMENTS, type) ? STATUS_ELEMENTS[type] : 'status';
|
|
305
|
+
const status = statusElement ? r[statusElement] : undefined;
|
|
306
|
+
if (status === 'entered-in-error') {
|
|
307
|
+
findings.push({ kind: 'status', text: `Entered in error (${statusElement} = entered-in-error)` });
|
|
308
|
+
} else if (status === 'unknown') {
|
|
309
|
+
findings.push({ kind: 'status', text: `Status unknown (${statusElement} = unknown)` });
|
|
310
|
+
} else if (status === undefined && statusElement && STATUS_ELEMENTS[type]) {
|
|
311
|
+
findings.push({ kind: 'status', text: `No ${statusElement} (a ${type} should have one)` });
|
|
312
|
+
}
|
|
313
|
+
if ((type === 'Condition' || type === 'AllergyIntolerance') && hasCode(r.verificationStatus, ['entered-in-error'])) {
|
|
314
|
+
findings.push({ kind: 'status', text: 'Entered in error (verificationStatus = entered-in-error)' });
|
|
315
|
+
}
|
|
316
|
+
if (type === 'Composition' && text(status) && status !== 'final' && status !== 'entered-in-error' && status !== 'unknown') {
|
|
317
|
+
findings.push({ kind: 'attention', text: `The Composition's status is ${status}, not final` });
|
|
318
|
+
}
|
|
319
|
+
// the subject: is it the document's?
|
|
320
|
+
if (node.kind !== 'root') {
|
|
321
|
+
const subject = isObject(r.subject) ? r.subject : isObject(r.patient) ? r.patient : null;
|
|
322
|
+
const docSubject = ctx.comp.subject;
|
|
323
|
+
if (subject && isObject(docSubject) && !sameReference(subject, node.entry, docSubject, ctx.rootEntry, ctx.resolve)) {
|
|
324
|
+
findings.push({ kind: 'subject', text: `The subject (${referenceWords(subject)}) is not the document's subject (${referenceWords(docSubject)})` });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (type === 'Patient') {
|
|
328
|
+
for (const link of Array.isArray(r.link) ? r.link.filter(isObject) : []) {
|
|
329
|
+
const target = isObject(link.other) && text(link.other.reference) ? ctx.resolve(node.entry, link.other.reference) : null;
|
|
330
|
+
findings.push({ kind: 'attention', text: `Linked${text(link.type) ? ` (${link.type})` : ''} to ${referenceWords(link.other)}${target ? '' : ' - not in the document'}` });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
await common(findings, r, type || 'Resource', prefix, skip, ctx.options, ctx.cache);
|
|
334
|
+
return finish(findings, narrativeOf(r));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** What to say about a section. */
|
|
338
|
+
async function analyseSection(node, ctx) {
|
|
339
|
+
const sec = node.section;
|
|
340
|
+
const findings = [];
|
|
341
|
+
if (isObject(sec.emptyReason)) {
|
|
342
|
+
findings.push({ kind: 'note', text: `Empty: ${conceptWords(sec.emptyReason) || '(no reason given)'}` });
|
|
343
|
+
}
|
|
344
|
+
const own = Array.isArray(sec.author) ? sec.author.filter(isObject) : [];
|
|
345
|
+
if (own.length) {
|
|
346
|
+
const parent = node.parentAuthors || [];
|
|
347
|
+
const same = own.length === parent.length && own.every(a => parent.some(b => sameReference(a, ctx.rootEntry, b, ctx.rootEntry, ctx.resolve)));
|
|
348
|
+
if (!same) {
|
|
349
|
+
findings.push({ kind: 'note', text: `The author (${own.map(referenceWords).join(', ')}) is not ${node.nested ? 'the containing section\'s' : 'the Composition\'s'}` +
|
|
350
|
+
(parent.length ? ` (${parent.map(referenceWords).join(', ')})` : ' (none)') });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
await common(findings, sec, 'Composition.section', 'section', ['section'], ctx.options, ctx.cache);
|
|
354
|
+
return finish(findings, narrativeOf(sec));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- saying there's nothing to report ----
|
|
358
|
+
//
|
|
359
|
+
// There are two ways: the section is empty, and its emptyReason says why (nilknown - nothing
|
|
360
|
+
// known - or notasked / unavailable - no information), which is the IPS pattern; or the
|
|
361
|
+
// section has an entry whose code says so (No known allergy; No information about allergies),
|
|
362
|
+
// which is the US Core pattern. Either is fine, but a document that uses both makes the
|
|
363
|
+
// reader (and a program) look for it in two places.
|
|
364
|
+
|
|
365
|
+
// the emptyReasons that say there's nothing to report (not withheld, closed or not started)
|
|
366
|
+
const NOTHING_REASONS = new Set(['nilknown', 'notasked', 'unavailable']);
|
|
367
|
+
// the IPS absent-unknown codes: no-known-* (a negation) and no-*-info (no information)
|
|
368
|
+
const IPS_ABSENT_UNKNOWN = 'http://hl7.org/fhir/uv/ips/CodeSystem/absent-unknown-uv-ips';
|
|
369
|
+
const SCT = 'http://snomed.info/sct';
|
|
370
|
+
// SNOMED CT codes that say there's nothing to report: the US Core refutation codes (No known
|
|
371
|
+
// allergy, ...drug, environmental, food, latex and insect allergy), No current problems or
|
|
372
|
+
// disability, and Patient not asked
|
|
373
|
+
const SCT_NOTHING = new Set(['716186003', '409137002', '428607008', '429625007', '716184000', '1003774007', '160245001', '1631000175102']);
|
|
374
|
+
// where a resource's code that would say so is
|
|
375
|
+
const CODE_ELEMENTS = ['code', 'medicationCodeableConcept', 'vaccineCode'];
|
|
376
|
+
|
|
377
|
+
/** The coding in a resource that says there's nothing to report, if there is one. */
|
|
378
|
+
function nothingCoding(r) {
|
|
379
|
+
for (const e of CODE_ELEMENTS) {
|
|
380
|
+
for (const c of Array.isArray(r[e]?.coding) ? r[e].coding.filter(isObject) : []) {
|
|
381
|
+
if ((c.system === IPS_ABSENT_UNKNOWN && text(c.code)) || (c.system === SCT && SCT_NOTHING.has(c.code))) {
|
|
382
|
+
return c;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A section node's title, as plain text. */
|
|
390
|
+
const sectionTitle = (node) => text(node.section.title) ? node.section.title : text(node.section.code?.text) ? node.section.code.text :
|
|
391
|
+
(Array.isArray(node.section.code?.coding) && node.section.code.coding.find(c => text(c?.display))?.display) || node.path;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* How the document does negation: a finding - kind negation for none, or one approach, and
|
|
395
|
+
* attention for more than one.
|
|
396
|
+
* @param {Object} top - the structure tree
|
|
397
|
+
*/
|
|
398
|
+
function negationOf(top) {
|
|
399
|
+
const reasons = [];
|
|
400
|
+
const codes = [];
|
|
401
|
+
const sectionOf = new Map();
|
|
402
|
+
for (const [node, , parent] of flatten(top)) {
|
|
403
|
+
const section = node.kind === 'section' ? node : parent ? sectionOf.get(parent) : null;
|
|
404
|
+
sectionOf.set(node, section);
|
|
405
|
+
if (node.kind === 'section') {
|
|
406
|
+
const reason = (Array.isArray(node.section.emptyReason?.coding) ? node.section.emptyReason.coding : []).find(c => isObject(c) && NOTHING_REASONS.has(c.code));
|
|
407
|
+
if (reason) {
|
|
408
|
+
reasons.push(`${sectionTitle(node)}: ${conceptWords(node.section.emptyReason)}`);
|
|
409
|
+
}
|
|
410
|
+
} else if (node.kind === 'resource' && section && isObject(node.resource)) {
|
|
411
|
+
const c = nothingCoding(node.resource);
|
|
412
|
+
if (c) {
|
|
413
|
+
codes.push(`${sectionTitle(section)}: ${text(c.display) || c.code}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const byReason = `section emptyReason (${reasons.join('; ')})`;
|
|
418
|
+
const byCode = `codes in entries (${[...new Set(codes)].join('; ')})`;
|
|
419
|
+
if (reasons.length && codes.length) {
|
|
420
|
+
return { kind: 'attention', text: `Negation: more than one approach - ${byReason}, and ${byCode}. A document should use one approach throughout` };
|
|
421
|
+
}
|
|
422
|
+
if (reasons.length || codes.length) {
|
|
423
|
+
return { kind: 'negation', text: `Negation: ${reasons.length ? byReason : byCode}` };
|
|
424
|
+
}
|
|
425
|
+
return { kind: 'negation', text: 'Negation: none observed' };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---- SNOMED CT codes with no edition ----
|
|
429
|
+
//
|
|
430
|
+
// A SNOMED CT code means what its edition says it means, and only the version says which edition
|
|
431
|
+
// that is (http://snomed.info/sct/[module](/version/[date])). With none, the edition comes from the
|
|
432
|
+
// nation the document is from (the nationality step, and the national profile's edition) - and
|
|
433
|
+
// when that isn't known either, there's no telling: the International Edition is assumed.
|
|
434
|
+
|
|
435
|
+
// a version that names an edition
|
|
436
|
+
const SCT_EDITION = /^http:\/\/snomed\.info\/x?sct\/\d+(\/version\/\d+)?$/;
|
|
437
|
+
// how many codes the finding names
|
|
438
|
+
const MAX_NAMED_CODES = 3;
|
|
439
|
+
|
|
440
|
+
/** The SNOMED CT codings (and Quantities) anywhere in a resource that don't name an edition. */
|
|
441
|
+
function noEdition(node, out) {
|
|
442
|
+
if (Array.isArray(node)) {
|
|
443
|
+
for (const n of node) {
|
|
444
|
+
noEdition(n, out);
|
|
445
|
+
}
|
|
446
|
+
} else if (isObject(node)) {
|
|
447
|
+
if (node.system === SCT && text(node.code) && !(typeof node.version === 'string' && SCT_EDITION.test(node.version))) {
|
|
448
|
+
out.push(node);
|
|
449
|
+
}
|
|
450
|
+
for (const v of Object.values(node)) {
|
|
451
|
+
if (typeof v === 'object') {
|
|
452
|
+
noEdition(v, out);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The error when the document has SNOMED CT codes with no edition and its nationality is unknown,
|
|
461
|
+
* or null.
|
|
462
|
+
* @param {Object} bundle - the document
|
|
463
|
+
*/
|
|
464
|
+
function unknownEdition(bundle) {
|
|
465
|
+
const codes = noEdition(Array.isArray(bundle.entry) ? bundle.entry.map(e => isObject(e) ? e.resource : null) : [], []);
|
|
466
|
+
if (!codes.length) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const named = [...new Set(codes.map(c => text(c.display) ? `${c.code} ${c.display}` : c.code))];
|
|
470
|
+
const list = named.slice(0, MAX_NAMED_CODES).join(', ') + (named.length > MAX_NAMED_CODES ? `, and ${named.length - MAX_NAMED_CODES} more` : '');
|
|
471
|
+
return { kind: 'error', text: `${codes.length} SNOMED CT code${codes.length === 1 ? ' does' : 's do'} not say which edition ${codes.length === 1 ? 'it is' : 'they are'} from (${list}), ` +
|
|
472
|
+
'and the nationality of the document is unknown, so the edition cannot be worked out. The International Edition of SNOMED CT is assumed' };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** All OK when there's nothing but the narrative to say; the narrative last. */
|
|
476
|
+
function finish(findings, narrative) {
|
|
477
|
+
return findings.length === 0 ? [{ kind: 'ok', text: 'All OK' }, narrative] : [...findings, narrative];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Analyse a document.
|
|
482
|
+
* @param {Object} bundle - the document
|
|
483
|
+
* @param {{lookupExtension?: Function, now?: number, jurisdiction?: string|null}} options -
|
|
484
|
+
* lookupExtension(url) -> a definition ({title, name, description}) or null, perhaps async;
|
|
485
|
+
* now in ms; jurisdiction: the nation the document is from (the nationality step) - null when
|
|
486
|
+
* it isn't known, and then SNOMED CT codes with no edition are an error. Left out, they aren't
|
|
487
|
+
* looked at
|
|
488
|
+
* @returns {Promise<{items: Object, counts: Object}|null>} the findings by node key, or
|
|
489
|
+
* null when the document has no Composition
|
|
490
|
+
*/
|
|
491
|
+
async function analyseDocument(bundle, options = {}) {
|
|
492
|
+
const top = structureTree(bundle);
|
|
493
|
+
if (!top) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const ctx = {
|
|
497
|
+
options: { ...options, now: typeof options.now === 'number' ? options.now : Date.now() },
|
|
498
|
+
cache: new Map(), comp: top.resource, rootEntry: top.entry, resolve: top.resolve
|
|
499
|
+
};
|
|
500
|
+
const items = {};
|
|
501
|
+
const counts = { nodes: 0, ok: 0, extensions: 0, notFound: 0 };
|
|
502
|
+
for (const [node] of flatten(top)) {
|
|
503
|
+
if (node.kind !== 'root' && node.kind !== 'resource' && node.kind !== 'section') {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const findings = node.kind === 'section' ? await analyseSection(node, ctx) : await analyseResource(node, ctx);
|
|
507
|
+
items[node.key] = findings;
|
|
508
|
+
counts.nodes++;
|
|
509
|
+
for (const f of findings) {
|
|
510
|
+
if (f.kind === 'ok') {
|
|
511
|
+
counts.ok++;
|
|
512
|
+
} else if (f.url) {
|
|
513
|
+
counts.extensions++;
|
|
514
|
+
if (!f.found) {
|
|
515
|
+
counts.notFound++;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// the document as a whole, on the Composition, before its narrative
|
|
521
|
+
const whole = [];
|
|
522
|
+
if (options.jurisdiction === null) {
|
|
523
|
+
const edition = unknownEdition(bundle);
|
|
524
|
+
if (edition) {
|
|
525
|
+
whole.push(edition);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
whole.push(negationOf(top));
|
|
529
|
+
const findings = items[top.key];
|
|
530
|
+
if (whole.some(f => f.kind !== 'negation') && findings[0].kind === 'ok') {
|
|
531
|
+
findings.shift();
|
|
532
|
+
counts.ok--;
|
|
533
|
+
}
|
|
534
|
+
findings.splice(findings.length - 1, 0, ...whole);
|
|
535
|
+
return { items, counts };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The analysis step: the document as submitted (the master - it runs first, before anything
|
|
540
|
+
* adorns the analysed copy) is analysed, and the findings kept as the step's result.
|
|
541
|
+
*/
|
|
542
|
+
const analysisStep = {
|
|
543
|
+
name: 'analysis',
|
|
544
|
+
async run(row, project, module) {
|
|
545
|
+
const started = Date.now();
|
|
546
|
+
const lookupExtension = typeof module.lookupExtension === 'function' ? (url) => module.lookupExtension(url) : null;
|
|
547
|
+
// the nation the document is from: the nationality step runs first (null when it isn't known)
|
|
548
|
+
const jurisdiction = module.store.readResult(row.id, 'nationality')?.result?.jurisdiction || null;
|
|
549
|
+
const out = await analyseDocument(row.bundle, { lookupExtension, jurisdiction });
|
|
550
|
+
if (!out) {
|
|
551
|
+
throw new Error('The document has no Composition');
|
|
552
|
+
}
|
|
553
|
+
module.store.saveResult(row.id, 'analysis', { ms: Date.now() - started, ...out });
|
|
554
|
+
const { nodes, ok, extensions } = out.counts;
|
|
555
|
+
return `analysis: ${nodes - ok} of ${nodes} resources and sections with something to note` +
|
|
556
|
+
(extensions ? `, ${extensions} extension${extensions === 1 ? '' : 's'}` : '');
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
module.exports = { analyseDocument, analysisStep, extensionsIn, futureDates, mayBeFuture, earliest, STATUS_ELEMENTS, FUTURE_INTENT };
|
package/ips/ips-template.html
CHANGED
|
@@ -54,8 +54,6 @@
|
|
|
54
54
|
<div class="container"> <!-- container -->
|
|
55
55
|
<div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
|
|
56
56
|
|
|
57
|
-
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org |
|
|
58
|
-
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] |
|
|
59
57
|
<a href="/" style="color: gold"> Server Home</a> |
|
|
60
58
|
<a href="/ips" style="color: gold">IPS</a>[%context-link%]
|
|
61
59
|
</div> <!-- /container -->
|
|
@@ -70,7 +68,7 @@
|
|
|
70
68
|
<div class="inner-wrapper">
|
|
71
69
|
<div class="col-12">
|
|
72
70
|
|
|
73
|
-
|
|
71
|
+
<h2><a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" title="FHIRsmith [%ver%]"><img border="0" src="/FHIRsmith32.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/></a> [%title%] </h2>
|
|
74
72
|
|
|
75
73
|
[%content%]
|
|
76
74
|
|
|
@@ -88,8 +86,7 @@
|
|
|
88
86
|
<div class="container"> <!-- container -->
|
|
89
87
|
<div class="inner-wrapper">
|
|
90
88
|
<p>
|
|
91
|
-
<a href="
|
|
92
|
-
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
89
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
93
90
|
([%ms%] ms)
|
|
94
91
|
[%sponsorMessage%]
|
|
95
92
|
</p>
|
|
@@ -112,10 +109,10 @@
|
|
|
112
109
|
================================================== -->
|
|
113
110
|
<!-- Placed at the end of the document so the pages load faster -->
|
|
114
111
|
<!-- <script src="/assets/js/jquery.js"/> -->
|
|
115
|
-
<script src="/assets/js/bootstrap.min.js"
|
|
116
|
-
<script src="/assets/js/respond.min.js"
|
|
112
|
+
<script src="/assets/js/bootstrap.min.js"></script>
|
|
113
|
+
<script src="/assets/js/respond.min.js"></script>
|
|
117
114
|
|
|
118
|
-
<script src="/assets/js/fhir.js"
|
|
115
|
+
<script src="/assets/js/fhir.js"></script>
|
|
119
116
|
|
|
120
117
|
<!-- Analytics Below
|
|
121
118
|
================================================== -->
|