fhirsmith 0.18.1 → 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 +190 -9
- 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 +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- 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/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- 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 +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- 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 +199 -24
- 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 +193 -67
- 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 +861 -95
- 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 +125 -61
- 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/tho-precedence.js +40 -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 +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- 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 +175 -15
- 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 +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- 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/render.js
CHANGED
|
@@ -26,7 +26,25 @@ const { STATUSES } = require('./search');
|
|
|
26
26
|
const profiles = require('./profiles');
|
|
27
27
|
const { terminologyOf, systemName, describeVersion, linkForSystem, linkForCode, isExternalSystem } = require('./terminology');
|
|
28
28
|
const { issueSummary, checkOf, translationOf } = require('./pipeline');
|
|
29
|
+
const { jurisdiction } = require('./jurisdictions');
|
|
30
|
+
const { SNOMED_LINKABLE_EDITIONS, editionName } = require('../tx/sct/editions');
|
|
31
|
+
const { nationalityText } = require('./nationality');
|
|
32
|
+
|
|
33
|
+
// the nations there is a flag for (static/assets/images/flags/{code}.svg); any other is the globe
|
|
34
|
+
const FLAGS = new Set(['US', 'AU']);
|
|
35
|
+
|
|
36
|
+
/** The nation a document is from, as a flag (a black and white globe when it isn't known, or has no flag). */
|
|
37
|
+
function nationalityFlag(result) {
|
|
38
|
+
const code = result?.jurisdiction || null;
|
|
39
|
+
const title = result ? nationalityText(result) : 'The nationality has not been determined yet';
|
|
40
|
+
const src = code && FLAGS.has(code) ? `/assets/images/flags/${code.toLowerCase()}.svg` : '/assets/images/flags/unknown.svg';
|
|
41
|
+
const alt = code ? (jurisdiction(code)?.display || code) : 'Nationality unknown';
|
|
42
|
+
return `<img class="ips-flag" src="${src}" alt="${escape(alt)}" title="${escape(title)}"/>`;
|
|
43
|
+
}
|
|
29
44
|
const { dateText } = require('./presenter');
|
|
45
|
+
const { structureTree, flatten } = require('./structure');
|
|
46
|
+
const scripts = require('./scripts');
|
|
47
|
+
const { LOINC } = require('./editor-schema');
|
|
30
48
|
|
|
31
49
|
const STATUS_COLOURS = {
|
|
32
50
|
submitted: '#6c757d',
|
|
@@ -42,6 +60,8 @@ const STYLE = `
|
|
|
42
60
|
.ips-table td, .ips-table th { vertical-align: top; }
|
|
43
61
|
.ips-json { white-space: pre-wrap; word-break: break-all; font-size: 85%; background: #f8f9fa; padding: 8px; border: 1px solid #dee2e6; font-family: monospace; }
|
|
44
62
|
.ips-banner { background: #ede7f6; border: 1px solid #d1c4e9; border-radius: 4px; padding: 10px 14px; margin-bottom: 10px; }
|
|
63
|
+
.ips-banner-flagged { display: flex; align-items: center; gap: 14px; }
|
|
64
|
+
.ips-flag { height: 40px; width: auto; max-width: 80px; border: 1px solid #d1c4e9; flex: none; }
|
|
45
65
|
.ips-banner .ips-patient { font-size: 120%; }
|
|
46
66
|
.ips-banner .ips-project-name { font-size: 130%; }
|
|
47
67
|
.ips-actions-line { margin-bottom: 10px; }
|
|
@@ -52,13 +72,27 @@ const STYLE = `
|
|
|
52
72
|
.ips-banner .ips-facts { font-size: 90%; color: #444; margin-top: 4px; }
|
|
53
73
|
.ips-banner .ips-facts a { margin-left: 4px; }
|
|
54
74
|
.ips-steps { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-bottom: 10px; font-size: 90%; }
|
|
55
|
-
.ips-
|
|
75
|
+
.ips-pipeline { display: flex; flex-wrap: wrap; row-gap: 4px; align-items: stretch; }
|
|
76
|
+
.ips-step { display: inline-flex; align-items: center; gap: 5px; padding: 3px 14px 3px 20px; margin-right: -8px; color: white; white-space: nowrap; cursor: default;
|
|
77
|
+
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 50%, calc(100% - 10px) 100%, 0 100%, 10px 50%); }
|
|
78
|
+
.ips-step:first-child { padding-left: 12px; border-radius: 3px 0 0 3px; clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 50%, calc(100% - 10px) 100%, 0 100%); }
|
|
79
|
+
a.ips-step { cursor: pointer; color: white; text-decoration: none; }
|
|
80
|
+
a.ips-step:hover, a.ips-step:focus { color: white; text-decoration: none; filter: brightness(1.15); }
|
|
81
|
+
.ips-step-received { background: #5e35b1; }
|
|
56
82
|
.ips-step-done { background: #1e7e34; }
|
|
57
83
|
.ips-step-failed { background: #c82333; }
|
|
58
84
|
.ips-step-running { background: #0d6efd; }
|
|
59
85
|
.ips-step-waiting { background: #6c757d; }
|
|
60
|
-
.ips-
|
|
61
|
-
|
|
86
|
+
.ips-step-running .ips-step-icon { display: inline-block; animation: ips-spin 2s linear infinite; }
|
|
87
|
+
@keyframes ips-spin { to { transform: rotate(360deg); } }
|
|
88
|
+
.ips-switch { display: inline-flex; margin-bottom: 10px; font-size: 90%; }
|
|
89
|
+
.ips-switch a { padding: 3px 12px; border: 1px solid #ced4da; color: #444; text-decoration: none; }
|
|
90
|
+
.ips-switch a + a { border-left: none; }
|
|
91
|
+
.ips-switch a:first-child { border-radius: 3px 0 0 3px; }
|
|
92
|
+
.ips-switch a:last-child { border-radius: 0 3px 3px 0; }
|
|
93
|
+
.ips-switch a.active { background: #5e35b1; border-color: #5e35b1; color: white; }
|
|
94
|
+
.ips-doc-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 10px; }
|
|
95
|
+
.ips-doc-actions .btn { font-size: 90%; padding: 2px 10px; }
|
|
62
96
|
.ips-nav.nav-tabs { margin-bottom: 14px; }
|
|
63
97
|
.ips-nav.nav-tabs > li > a { padding: 6px 14px; }
|
|
64
98
|
.ips-explain { margin-top: 18px; padding-top: 8px; border-top: 1px solid #dee2e6; }
|
|
@@ -69,8 +103,9 @@ const STYLE = `
|
|
|
69
103
|
.ips-filter .ips-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
|
|
70
104
|
.ips-date { white-space: nowrap; }
|
|
71
105
|
.ips-narrative { border: 1px solid #dee2e6; padding: 12px; background: #fff; }
|
|
72
|
-
.ips-narrative h3 { font-size:
|
|
73
|
-
.ips-narrative h4 { font-size:
|
|
106
|
+
.ips-narrative h3 { font-size: 130%; margin-top: 8px; }
|
|
107
|
+
.ips-narrative h4 { font-size: 110%; margin-top: 18px; border-bottom: 1px solid #eee; }
|
|
108
|
+
.ips-narrative h5 { font-size: 100%; margin-top: 12px; }
|
|
74
109
|
.ips-narrative table { border-collapse: collapse; margin-bottom: 8px; }
|
|
75
110
|
.ips-narrative td, .ips-narrative th { border: 1px solid #ddd; padding: 2px 6px; font-size: 90%; }
|
|
76
111
|
.ips-meta { font-size: 85%; color: #555; margin-bottom: 10px; }
|
|
@@ -114,6 +149,27 @@ const STYLE = `
|
|
|
114
149
|
.ips-pf-bad { border-color: #c82333 !important; }
|
|
115
150
|
.ips-pf-rule { width: 100%; table-layout: fixed; }
|
|
116
151
|
.ips-pf-rule td { vertical-align: top; overflow-wrap: anywhere; }
|
|
152
|
+
.ips-pf-rule .ips-pf-actions { width: 7em; white-space: nowrap; text-align: right; }
|
|
153
|
+
.ips-pf-rule td.ips-pf-actions { padding-top: 1px; padding-bottom: 1px; }
|
|
154
|
+
.ips-pf-part-head { display: flex; align-items: baseline; gap: 12px; margin-top: 18px; }
|
|
155
|
+
.ips-pf-part-head h4 { margin: 0 0 6px; }
|
|
156
|
+
.ips-pf-part-head .ips-pf-add { font-size: 80%; padding: 0 8px; line-height: 1.6; }
|
|
157
|
+
.ips-pf-rule .ips-pf-actions .btn { font-size: 75%; padding: 0 5px; line-height: 1.5; border-radius: 3px; vertical-align: baseline; }
|
|
158
|
+
.ips-pf-how { align-items: center; gap: 8px; margin-top: 6px; } .ips-pf-how input { width: 7em; flex: none; } .ips-pf-how .ips-pf-hint { margin: 0; }
|
|
159
|
+
.ips-pf-dialog { width: min(760px, 95vw); max-height: 90vh; padding: 0; border: 1px solid #bbb; border-radius: 6px; box-shadow: 0 8px 30px rgba(0,0,0,0.3); }
|
|
160
|
+
.ips-pf-dialog::backdrop { background: rgba(0,0,0,0.35); }
|
|
161
|
+
.ips-pf-dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid #dee2e6; background: #f8f9fa; }
|
|
162
|
+
.ips-pf-dialog-head h4 { margin: 0; }
|
|
163
|
+
.ips-pf-dialog-x { border: none; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: #666; }
|
|
164
|
+
.ips-pf-dialog-body { padding: 12px 16px; overflow: visible; }
|
|
165
|
+
.ips-pf-dialog-foot { padding: 10px 16px; border-top: 1px solid #dee2e6; text-align: right; }
|
|
166
|
+
.ips-pf-field { margin-bottom: 14px; }
|
|
167
|
+
.ips-pf-field > .ips-pf-label { display: block; font-weight: bold; margin-bottom: 4px; }
|
|
168
|
+
.ips-pf-field .ips-ed-input, .ips-pf-field textarea { width: 100%; box-sizing: border-box; }
|
|
169
|
+
.ips-pf-field > * + * { margin-top: 4px; }
|
|
170
|
+
.ips-pf-field .ips-pf-pair .ips-ed-input { width: auto; flex: 1; }
|
|
171
|
+
.ips-pf-field .ips-pf-how .ips-ed-input { width: 7em; flex: none; }
|
|
172
|
+
.ips-pf-hint { font-size: 85%; color: #6c757d; }
|
|
117
173
|
.ips-pf-rule .ips-pf-card-text { white-space: nowrap; }
|
|
118
174
|
.ips-ed-section { border: 1px solid #dee2e6; border-radius: 4px; margin-bottom: 8px; }
|
|
119
175
|
.ips-ed-section-required { border-color: #b8c4e8; }
|
|
@@ -154,10 +210,39 @@ const STYLE = `
|
|
|
154
210
|
.ips-translated { font-size: 80%; color: #fff; background: #6f42c1; border-radius: 3px; padding: 0 5px; cursor: help; }
|
|
155
211
|
.ips-check { font-size: 130%; font-weight: bold; cursor: help; }
|
|
156
212
|
.ips-check-in { color: #1e7e34; }
|
|
157
|
-
.ips-check-international { color: #
|
|
213
|
+
.ips-check-international { color: #a35200; }
|
|
158
214
|
.ips-check-not-in { color: #c82333; }
|
|
159
215
|
.ips-check-unknown { color: #6c757d; }
|
|
160
216
|
.ips-inline { display: inline; }
|
|
217
|
+
.ips-tree-tools { margin-bottom: 8px; font-size: 90%; }
|
|
218
|
+
.ips-an { font-size: 90%; }
|
|
219
|
+
.ips-an > tbody > tr.ips-an-node > td { border-top: 2px solid #dee2e6; background: #f8f9fa; }
|
|
220
|
+
.ips-an > tbody > tr.ips-an-findings > td { border-top: none; padding: 2px 8px 8px 30px; }
|
|
221
|
+
.ips-an td.ips-an-path { word-break: break-all; }
|
|
222
|
+
.ips-an td.ips-an-path img { vertical-align: -3px; }
|
|
223
|
+
.ips-an tr.ips-tt-seen > td { background-color: #EFEFEF; }
|
|
224
|
+
.ips-an ul { margin: 0; padding-left: 18px; }
|
|
225
|
+
.ips-an-modifier { color: #800000; font-weight: bold; }
|
|
226
|
+
.ips-an-status { color: #000080; font-weight: bold; }
|
|
227
|
+
.ips-an-subject { color: #006400; font-weight: bold; }
|
|
228
|
+
.ips-an-attention { color: #b34700; font-weight: bold; }
|
|
229
|
+
.ips-an-ok { color: #1e7e34; }
|
|
230
|
+
.ips-an-narrative { color: #3a4d5e; }
|
|
231
|
+
.ips-an-negation { color: #3a4d5e; }
|
|
232
|
+
.ips-an-error { color: #c00000; font-weight: bold; }
|
|
233
|
+
.ips-tree-tools .btn { font-size: 90%; padding: 2px 10px; }
|
|
234
|
+
.ips-tt { font-size: 90%; width: auto; min-width: 60%; }
|
|
235
|
+
.ips-tt > thead > tr > th { background: #f0f0f0; font-weight: bold; white-space: nowrap; }
|
|
236
|
+
.ips-tt > tbody > tr > td { border-top: none; padding: 0 10px 0 4px; line-height: 24px; vertical-align: top; }
|
|
237
|
+
.ips-tt > tbody > tr > td.ips-tt-path { position: relative; white-space: nowrap; padding-right: 16px; }
|
|
238
|
+
.ips-tt td.ips-tt-path img { vertical-align: -3px; position: relative; z-index: 1; background: white; }
|
|
239
|
+
.ips-tt tr.ips-tt-parent:not(.ips-tt-closed) > td.ips-tt-path::after { content: ''; position: absolute; left: var(--ips-tt-down, 8px); top: 18px; bottom: 0; border-left: 1px solid #adb5bd; }
|
|
240
|
+
.ips-tt-toggle { position: absolute; top: 7px; width: 11px; height: 11px; padding: 0; z-index: 2; border: 1px solid #868e96; background: white; font-size: 9px; line-height: 9px; color: #495057; cursor: pointer; }
|
|
241
|
+
.ips-tt-toggle::before { content: '\\2212'; }
|
|
242
|
+
.ips-tt tr.ips-tt-closed .ips-tt-toggle::before { content: '+'; }
|
|
243
|
+
.ips-tt tr.ips-tt-seen > td { background-color: #EFEFEF; }
|
|
244
|
+
.ips-tt tr.ips-tt-missing > td:last-child { color: #c82333; }
|
|
245
|
+
.ips-tt .ips-tt-when { color: #3a4d5e; margin-left: 6px; }
|
|
161
246
|
</style>
|
|
162
247
|
<script>
|
|
163
248
|
// a form with data-confirm asks first: the message is an attribute (escaped as one), never script
|
|
@@ -241,6 +326,7 @@ function userBar(ctx) {
|
|
|
241
326
|
parts.push(`<a href="${base}/admin/projects">Projects</a>`);
|
|
242
327
|
parts.push(`<a href="${base}/admin/users">Users</a>`);
|
|
243
328
|
parts.push(`<a href="${base}/StructureDefinition">Clinical profiles</a>`);
|
|
329
|
+
parts.push(`<a href="${base}/national-profiles">National profiles</a>`);
|
|
244
330
|
}
|
|
245
331
|
parts.push(`<form method="post" action="${base}/logout" class="ips-inline">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-link" style="padding: 0; font-size: 100%; vertical-align: baseline">Logout</button></form>`);
|
|
246
332
|
return `<div style="float: right; font-size: 85%">${parts.join(' | ')}</div>`;
|
|
@@ -272,7 +358,8 @@ function renderHome(projects, ctx) {
|
|
|
272
358
|
let h = STYLE + userBar(ctx);
|
|
273
359
|
h += '<p>Each project is a collection of International Patient Summary documents, with its own FHIR endpoint ' +
|
|
274
360
|
'(<code>POST</code> / <code>GET {project}/Bundle</code>) and web pages. ' +
|
|
275
|
-
`<a href="${escape(ctx.base)}/StructureDefinition">Clinical profiles</a> say what kinds of clinical document are expected to contain
|
|
361
|
+
`<a href="${escape(ctx.base)}/StructureDefinition">Clinical profiles</a> say what kinds of clinical document are expected to contain, and ` +
|
|
362
|
+
`<a href="${escape(ctx.base)}/national-profiles">national profiles</a> are the IPS document profiles that national implementation guides define.</p>`;
|
|
276
363
|
if (projects.length === 0) {
|
|
277
364
|
return h + '<p>There are no projects yet' + (ctx.user?.isAdmin ? `: <a href="${escape(ctx.base)}/admin/projects">create one</a>` : '') + '.</p>';
|
|
278
365
|
}
|
|
@@ -390,7 +477,7 @@ function projectNav(project, ctx) {
|
|
|
390
477
|
/** The documentation at the foot of a project's page: how to reach it as a FHIR endpoint. */
|
|
391
478
|
function projectDocs(project, ctx) {
|
|
392
479
|
const purl = projectUrl(ctx, project);
|
|
393
|
-
return '<div class="ips-explain ips-docs"><
|
|
480
|
+
return '<div class="ips-explain ips-docs"><h3>Documentation</h3>' +
|
|
394
481
|
`<p>This project is a FHIR endpoint: <code>${purl}</code> (<a href="${purl}/metadata">CapabilityStatement</a>). ` +
|
|
395
482
|
'Documents are IPS Bundles (<code>type: document</code>, a Composition first, with the Patient it refers to) in JSON.</p>' +
|
|
396
483
|
'<table class="table table-sm ips-table ips-api"><tr><th>Request</th><th></th></tr>' +
|
|
@@ -447,7 +534,7 @@ function renderProject(project, query, results, search, ctx, problems) {
|
|
|
447
534
|
const columns = [
|
|
448
535
|
['_id', 'Id'], ['composition.patient.name', 'Patient'], ['composition.patient.gender', 'Gender'],
|
|
449
536
|
['composition.patient.birthdate', 'Born'], ['composition.date', 'Date'], ['composition.title', 'Title'],
|
|
450
|
-
['processing-status', 'Status'], ['_lastUpdated', 'Received']
|
|
537
|
+
['processing-status', 'Status'], ['_lastUpdated', 'Received'], [null, 'Nation']
|
|
451
538
|
];
|
|
452
539
|
if (ctx.canManage) {
|
|
453
540
|
columns.push([null, '']);
|
|
@@ -479,7 +566,8 @@ function renderProject(project, query, results, search, ctx, problems) {
|
|
|
479
566
|
`<td class="ips-date" title="${escape(r.comp_date || '')}">${escape(dateOnly(r.comp_date))}</td>` +
|
|
480
567
|
`<td>${escape(r.comp_title || '')}</td>` +
|
|
481
568
|
`<td title="${escape(ctx.canManage ? r.status_message || '' : '')}">${badge(r.status)}</td>` +
|
|
482
|
-
`<td class="ips-date" title="${escape(r.received)}">${escape(dateOnly(r.received))}</td
|
|
569
|
+
`<td class="ips-date" title="${escape(r.received)}">${escape(dateOnly(r.received))}</td>` +
|
|
570
|
+
`<td title="${escape(r.nationality ? (jurisdiction(r.nationality)?.display || r.nationality) : 'Not known')}">${escape(r.nationality || '??')}</td>`;
|
|
483
571
|
if (ctx.canManage) {
|
|
484
572
|
h += `<td><form method="post" action="${durl}/delete" class="ips-inline" data-confirm="${escape(`Delete document ${r.id}?`)}">${csrfField(ctx.csrf)}` +
|
|
485
573
|
'<button type="submit" class="btn btn-link" style="padding: 0; font-size: 85%">delete</button></form></td>';
|
|
@@ -615,7 +703,7 @@ function aiSettingsHtml(project, ctx, options) {
|
|
|
615
703
|
* a live element. Serialising closes what was left open, and each narrative is wrapped in
|
|
616
704
|
* its own div.
|
|
617
705
|
*/
|
|
618
|
-
function narrativeOf(res) {
|
|
706
|
+
function narrativeOf(res, level) {
|
|
619
707
|
const div = res?.text?.div;
|
|
620
708
|
if (typeof div !== 'string' || div.trim() === '') {
|
|
621
709
|
return null;
|
|
@@ -623,18 +711,54 @@ function narrativeOf(res) {
|
|
|
623
711
|
if (checkNarrative(div)) {
|
|
624
712
|
return '<p class="ips-small"><i>(narrative not shown: it contains active content)</i></p>';
|
|
625
713
|
}
|
|
626
|
-
return `<div class="ips-narrative-text">${parse5.serialize(parse5.parseFragment(div))}</div>`;
|
|
714
|
+
return `<div class="ips-narrative-text">${parse5.serialize(seatHeadings(parse5.parseFragment(div), level))}</div>`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Sit a narrative's headings at the depth the page shows it at: shift them all by the same
|
|
719
|
+
* amount, so that the narrative's top heading is at `level` and its structure is kept.
|
|
720
|
+
* Narrative arrives with whatever heading levels its author (or the presenter) chose, and
|
|
721
|
+
* none of them know that the page is titled with an h2 - an h1 or h2 in a narrative breaks
|
|
722
|
+
* the page's heading structure (WCAG 1.3.1). Levels that would go past h6 stay at h6.
|
|
723
|
+
*
|
|
724
|
+
* @param {Object} fragment - a parse5 fragment, changed in place
|
|
725
|
+
* @param {number} level - the level the narrative's top heading should be at
|
|
726
|
+
* @returns {Object} the fragment
|
|
727
|
+
*/
|
|
728
|
+
function seatHeadings(fragment, level) {
|
|
729
|
+
const headings = [];
|
|
730
|
+
const walk = (node) => {
|
|
731
|
+
for (const c of node.childNodes || []) {
|
|
732
|
+
if (/^h[1-6]$/.test(c.nodeName)) {
|
|
733
|
+
headings.push(c);
|
|
734
|
+
}
|
|
735
|
+
walk(c);
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
walk(fragment);
|
|
739
|
+
if (headings.length === 0) {
|
|
740
|
+
return fragment;
|
|
741
|
+
}
|
|
742
|
+
const shift = level - Math.min(...headings.map(h => Number(h.nodeName[1])));
|
|
743
|
+
for (const h of headings) {
|
|
744
|
+
const name = `h${Math.max(1, Math.min(6, Number(h.nodeName[1]) + shift))}`;
|
|
745
|
+
h.nodeName = name;
|
|
746
|
+
h.tagName = name;
|
|
747
|
+
}
|
|
748
|
+
return fragment;
|
|
627
749
|
}
|
|
628
750
|
|
|
751
|
+
// The page's title is the template's h2; within the narrative view the document title is an
|
|
752
|
+
// h3, its sections h4, their sections h5, and so on (down to h6)
|
|
629
753
|
function sectionsHtml(sections, level) {
|
|
630
754
|
let h = '';
|
|
631
755
|
for (const s of Array.isArray(sections) ? sections : []) {
|
|
632
756
|
if (!isObject(s)) {
|
|
633
757
|
continue;
|
|
634
758
|
}
|
|
635
|
-
const tag =
|
|
759
|
+
const tag = `h${Math.min(level, 6)}`;
|
|
636
760
|
h += `<${tag}>${escape(text(s.title) || text(s.code?.text) || '')}</${tag}>`;
|
|
637
|
-
const n = narrativeOf(s);
|
|
761
|
+
const n = narrativeOf(s, Math.min(level + 1, 6));
|
|
638
762
|
if (n) {
|
|
639
763
|
h += n;
|
|
640
764
|
} else if (!s.section) {
|
|
@@ -656,13 +780,221 @@ function assembledNarrative(bundle) {
|
|
|
656
780
|
}
|
|
657
781
|
let h = '<div class="ips-narrative">';
|
|
658
782
|
if (text(comp.title)) {
|
|
659
|
-
h += `<
|
|
783
|
+
h += `<h3>${escape(comp.title)}</h3>`;
|
|
660
784
|
}
|
|
661
|
-
h += narrativeOf(comp) || '';
|
|
662
|
-
h += sectionsHtml(comp.section,
|
|
785
|
+
h += narrativeOf(comp, 4) || '';
|
|
786
|
+
h += sectionsHtml(comp.section, 4);
|
|
663
787
|
return h + '</div>';
|
|
664
788
|
}
|
|
665
789
|
|
|
790
|
+
// ---- the structure view ----
|
|
791
|
+
|
|
792
|
+
/** A node's details: what it is, when, and a note (contained, shown at...). */
|
|
793
|
+
function nodeDetails(node) {
|
|
794
|
+
return [node.what, node.when ? `<span class="ips-tt-when">${node.when}</span>` : '', node.note ? `<i>(${escape(node.note)})</i>` : '']
|
|
795
|
+
.filter(Boolean).join(' ');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The logical structure of a document (structure.js), as a tree table like the FHIR
|
|
800
|
+
* specification's. Columns: the path (where the reference is), the type, the id, and what
|
|
801
|
+
* the resource is and when. A reference that doesn't resolve in the document is shown as
|
|
802
|
+
* external; a resource expanded elsewhere is shown again, greyed, saying where, and doesn't expand.
|
|
803
|
+
*/
|
|
804
|
+
function renderStructure(bundle) {
|
|
805
|
+
const top = structureTree(bundle);
|
|
806
|
+
if (!top) {
|
|
807
|
+
return '<p><i>The document has no Composition.</i></p>';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// the rows: the lines are drawn as the cell's background, so they carry on through rows
|
|
811
|
+
// that wrap; each level is UNIT px in, and the line to a node's children runs down from
|
|
812
|
+
// the middle of its icon
|
|
813
|
+
const UNIT = 20;
|
|
814
|
+
const LINE = '#adb5bd';
|
|
815
|
+
let rows = '';
|
|
816
|
+
let next = 0;
|
|
817
|
+
function walk(node, depth, parentId, lines, last, hidden) {
|
|
818
|
+
const id = next++;
|
|
819
|
+
const bg = [];
|
|
820
|
+
lines.forEach((on, k) => {
|
|
821
|
+
if (on) {
|
|
822
|
+
bg.push([UNIT * k + 10, 0, '1px', '100%']);
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
if (depth > 0) {
|
|
826
|
+
const x = UNIT * (depth - 1) + 10;
|
|
827
|
+
bg.push([x, 0, '1px', last ? '12px' : '100%']);
|
|
828
|
+
bg.push([x, 12, `${UNIT - 8}px`, '1px']);
|
|
829
|
+
}
|
|
830
|
+
const lineStyle = bg.length === 0 ? '' :
|
|
831
|
+
`background-image: ${bg.map(() => `linear-gradient(${LINE}, ${LINE})`).join(', ')}; ` +
|
|
832
|
+
`background-position: ${bg.map(b => `${b[0]}px ${b[1]}px`).join(', ')}; ` +
|
|
833
|
+
`background-size: ${bg.map(b => `${b[2]} ${b[3]}`).join(', ')}; background-repeat: no-repeat; `;
|
|
834
|
+
const style = ` style="${lineStyle}padding-left: ${UNIT * depth + 2}px; --ips-tt-down: ${UNIT * depth + 10}px"`;
|
|
835
|
+
const kids = node.children.length > 0;
|
|
836
|
+
const closed = kids && !node.open;
|
|
837
|
+
const cls = [node.kind === 'missing' ? 'ips-tt-missing' : node.kind === 'seen' ? 'ips-tt-seen' : '', kids ? 'ips-tt-parent' : '', closed ? 'ips-tt-closed' : ''].filter(Boolean).join(' ');
|
|
838
|
+
const toggle = kids && depth > 0 ?
|
|
839
|
+
`<button type="button" class="ips-tt-toggle" style="left: ${UNIT * (depth - 1) + 5}px" aria-label="Show or hide what ${escape(node.path)} refers to"></button>` : '';
|
|
840
|
+
const idCell = node.id ? escape(node.id) : (node.kind === 'missing' ? `<code>${escape(node.ref)}</code>` : '');
|
|
841
|
+
const idTitle = node.ref ? ` title="${escape(`Reference: ${node.ref}`)}"` : '';
|
|
842
|
+
const details = nodeDetails(node);
|
|
843
|
+
rows += `<tr data-tt-id="${id}"` + (parentId === null ? '' : ` data-tt-parent="${parentId}"`) + (cls ? ` class="${cls}"` : '') + (hidden ? ' hidden' : '') + '>' +
|
|
844
|
+
`<td class="ips-tt-path"${style}>${toggle}<img src="/assets/images/icon_${node.icon}.png" alt="" width="16" height="16"/> ${escape(node.path)}</td>` +
|
|
845
|
+
`<td>${escape(node.type)}</td><td${idTitle}>${idCell}</td><td>${details}</td></tr>`;
|
|
846
|
+
node.children.forEach((c, i) => {
|
|
847
|
+
const lastChild = i === node.children.length - 1;
|
|
848
|
+
walk(c, depth + 1, id, depth === 0 ? [] : [...lines, !last], lastChild, hidden || closed);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
walk(top, 0, null, [], true, false);
|
|
852
|
+
|
|
853
|
+
let h = '<div class="ips-tree-tools"><button type="button" class="btn btn-default ips-tt-all" data-open="1">Expand all</button> ' +
|
|
854
|
+
'<button type="button" class="btn btn-default ips-tt-all" data-open="">Collapse all</button></div>';
|
|
855
|
+
h += '<noscript><style>.ips-tt tr[hidden] { display: table-row; } .ips-tt-toggle, .ips-tree-tools { display: none; }</style></noscript>';
|
|
856
|
+
h += '<table class="table table-condensed ips-tt"><thead><tr><th>Path</th><th>Type</th><th>Id</th><th>Details</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
857
|
+
h += `<script>
|
|
858
|
+
(function () {
|
|
859
|
+
var rows = Array.prototype.slice.call(document.querySelectorAll('.ips-tt tbody tr'));
|
|
860
|
+
var byId = {};
|
|
861
|
+
rows.forEach(function (r) { byId[r.getAttribute('data-tt-id')] = r; });
|
|
862
|
+
// a row shows when its parent shows and is open (rows come parents first)
|
|
863
|
+
function refresh() {
|
|
864
|
+
rows.forEach(function (r) {
|
|
865
|
+
var p = byId[r.getAttribute('data-tt-parent')];
|
|
866
|
+
r.hidden = !!p && (p.hidden || p.classList.contains('ips-tt-closed'));
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
document.querySelectorAll('.ips-tt-toggle').forEach(function (b) {
|
|
870
|
+
b.addEventListener('click', function () { b.closest('tr').classList.toggle('ips-tt-closed'); refresh(); });
|
|
871
|
+
});
|
|
872
|
+
document.querySelectorAll('.ips-tt-all').forEach(function (b) {
|
|
873
|
+
b.addEventListener('click', function () {
|
|
874
|
+
var open = !!b.getAttribute('data-open');
|
|
875
|
+
rows.forEach(function (r) { if (r.getAttribute('data-tt-parent') !== null && r.classList.contains('ips-tt-parent')) { r.classList.toggle('ips-tt-closed', !open); } });
|
|
876
|
+
refresh();
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
})();
|
|
880
|
+
</script>`;
|
|
881
|
+
return h;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---- the analysis view ----
|
|
885
|
+
|
|
886
|
+
/** One finding, as a list item. */
|
|
887
|
+
function findingHtml(f) {
|
|
888
|
+
if (f.url) {
|
|
889
|
+
const what = f.kind === 'modifier' ? 'Modifier extension' : 'Extension';
|
|
890
|
+
// known or not; the definition is on hover
|
|
891
|
+
const def = f.found ? [text(f.title), text(f.description)].filter(Boolean).join(': ') || 'The definition says nothing about it' : '';
|
|
892
|
+
const known = f.found ? `<span class="ips-an-known" title="${escape(def)}">known</span>` : '<i>not known</i>';
|
|
893
|
+
return `<li class="ips-an-${escape(f.kind)}">${what} <code>${escape(f.url)}</code> on <code>${escape(f.path)}</code>: ${known}</li>`;
|
|
894
|
+
}
|
|
895
|
+
return `<li class="ips-an-${escape(f.kind)}">${escape(f.text)}</li>`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Whether the analysis found nothing to say about a node: All OK, and nothing else but the
|
|
900
|
+
* narrative - which doesn't matter for a resource, but a section without narrative is a problem -
|
|
901
|
+
* and the Composition's negation (unless there's more than one approach: that's attention).
|
|
902
|
+
*/
|
|
903
|
+
function analysisOk(findings, isSection) {
|
|
904
|
+
if (!Array.isArray(findings) || !findings.some(f => f && f.kind === 'ok')) {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
return findings.every(f => f && (f.kind === 'ok' || f.kind === 'negation' || (f.kind === 'narrative' && !(isSection && f.text === 'No narrative'))));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The analysis view: the document's structure (structure.js) as a flat table - each node's
|
|
912
|
+
* row as in the structure view, with its full path - and under each resource and section a
|
|
913
|
+
* row with what the analysis step found (analysis.js).
|
|
914
|
+
*/
|
|
915
|
+
function renderAnalysis(row, options = {}) {
|
|
916
|
+
const r = row.results?.analysis;
|
|
917
|
+
if (!r || (r.result.status && r.result.status !== 'done') || !isObject(r.result.items)) {
|
|
918
|
+
return noResultNote(row, 'analysis', 'There is no analysis of this document', options);
|
|
919
|
+
}
|
|
920
|
+
const top = structureTree(row.bundle);
|
|
921
|
+
if (!top) {
|
|
922
|
+
return '<p><i>The document has no Composition.</i></p>';
|
|
923
|
+
}
|
|
924
|
+
const items = r.result.items;
|
|
925
|
+
let rows = '';
|
|
926
|
+
let total = 0;
|
|
927
|
+
let quiet = 0;
|
|
928
|
+
const nationality = row.results?.nationality?.result || null;
|
|
929
|
+
let nationShown = false;
|
|
930
|
+
for (const [node] of flatten(top)) {
|
|
931
|
+
// a reference to something not in the document has no rows: there's nothing to analyse
|
|
932
|
+
if (node.kind === 'missing') {
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
const cls = node.kind === 'seen' ? ' ips-tt-seen' : '';
|
|
936
|
+
const idCell = node.id ? escape(node.id) : '';
|
|
937
|
+
const idTitle = node.ref ? ` title="${escape(`Reference: ${node.ref}`)}"` : '';
|
|
938
|
+
// nothing to say (or analysed elsewhere): hidden by "Only show problems"
|
|
939
|
+
const ok = node.kind === 'seen' || analysisOk(items[node.key], String(node.key || '').startsWith('s:'));
|
|
940
|
+
const okAttr = ok ? ' data-ok="1"' : '';
|
|
941
|
+
total++;
|
|
942
|
+
if (ok) {
|
|
943
|
+
quiet++;
|
|
944
|
+
}
|
|
945
|
+
rows += `<tr class="ips-an-node${cls}"${okAttr}><td class="ips-an-path"><img src="/assets/images/icon_${node.icon}.png" alt="" width="16" height="16"/> ${escape(node.fullPath)}</td>` +
|
|
946
|
+
`<td>${escape(node.type)}</td><td${idTitle}>${idCell}</td><td>${nodeDetails(node)}</td></tr>`;
|
|
947
|
+
let body;
|
|
948
|
+
if (node.kind === 'seen') {
|
|
949
|
+
body = `<i>Analysed at ${escape(node.shownAt)}</i>`;
|
|
950
|
+
} else if (Array.isArray(items[node.key])) {
|
|
951
|
+
// the Composition's first bullet: which nation the document is from, and why
|
|
952
|
+
const nation = node.type === 'Composition' && !nationShown && nationality ? `<li class="ips-an-nation">${escape(nationalityText(nationality))}</li>` : '';
|
|
953
|
+
if (node.type === 'Composition') {
|
|
954
|
+
nationShown = true;
|
|
955
|
+
}
|
|
956
|
+
body = `<ul>${nation}${items[node.key].map(findingHtml).join('')}</ul>`;
|
|
957
|
+
} else {
|
|
958
|
+
body = '<i>Not analysed</i>';
|
|
959
|
+
}
|
|
960
|
+
rows += `<tr class="ips-an-findings${cls}"${okAttr}><td colspan="4">${body}</td></tr>`;
|
|
961
|
+
}
|
|
962
|
+
let h = `<p class="ips-small">Analysed ${escape(r.made)}` + (typeof r.result.ms === 'number' ? ` in ${escape(String(r.result.ms))} ms` : '') + '</p>';
|
|
963
|
+
// the choice is remembered in the browser (localStorage); without script, everything shows
|
|
964
|
+
h += '<style>.ips-an.ips-an-problems tr[data-ok] { display: none; }</style>';
|
|
965
|
+
h += '<div class="ips-filter mb-2 ips-an-filter" hidden><label><input type="checkbox" id="ips-an-problems"/> Only show problems</label> ' +
|
|
966
|
+
`<span class="ips-small ips-an-count" hidden>Showing ${total - quiet} of ${total}</span></div>`;
|
|
967
|
+
h += '<table class="table table-condensed ips-an"><thead><tr><th>Path</th><th>Type</th><th>Id</th><th>Details</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
968
|
+
h += '<div class="ips-explain"><p class="ips-small">What the analysis step found in each resource of the document as submitted, in the order of the structure view, and in the Composition section by section (the Composition\'s first bullet is which nation the document is from, and why - the nationality step): ' +
|
|
969
|
+
'extensions (with their definitions, from the validator, where it knows them; textLink is left out), <span class="ips-an-modifier">modifier extensions and implicitRules</span>, ' +
|
|
970
|
+
'<span class="ips-an-status">records that are inactive, entered in error, of unknown status, or missing a status they should have</span>, ' +
|
|
971
|
+
'<span class="ips-an-subject">a subject that is not the document\'s, or a focus</span>, ' +
|
|
972
|
+
'<span class="ips-an-attention">dates in the future where none is expected (not period ends, expiry or due dates, schedules, nor appointments, requests and plans that are not over, or events still to come), a Patient\'s links, a Composition that is not final, and a document that uses more than one approach to negation</span>, ' +
|
|
973
|
+
'a section\'s emptyReason or an author other than its container\'s, whether there is narrative, and - on the Composition - how the document says there is nothing to report (negation): with a section\'s emptyReason (the IPS pattern), with entries whose code says so, like No known allergy (the US Core pattern), or not at all; and <span class="ips-an-error">SNOMED CT codes that do not say which edition they are from, when the nationality of the document is unknown (the International Edition is assumed)</span>. A resource with nothing else to say is All OK. References to resources that are not in the document are left out. ' +
|
|
974
|
+
'An extension is known when the validator has its definition (hover over "known" to see it). Only show problems hides what is All OK - and, for a resource, whether it has narrative doesn\'t matter, but a section without narrative is a problem.</p></div>';
|
|
975
|
+
h += `<script>
|
|
976
|
+
(function () {
|
|
977
|
+
var KEY = 'fhirsmith.ips.analysis.problemsOnly';
|
|
978
|
+
var box = document.getElementById('ips-an-problems');
|
|
979
|
+
var table = document.querySelector('.ips-an');
|
|
980
|
+
var count = document.querySelector('.ips-an-count');
|
|
981
|
+
if (!box || !table) { return; }
|
|
982
|
+
box.closest('.ips-an-filter').hidden = false;
|
|
983
|
+
function apply() {
|
|
984
|
+
table.classList.toggle('ips-an-problems', box.checked);
|
|
985
|
+
count.hidden = !box.checked;
|
|
986
|
+
}
|
|
987
|
+
try { box.checked = window.localStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: unchecked */ }
|
|
988
|
+
apply();
|
|
989
|
+
box.addEventListener('change', function () {
|
|
990
|
+
try { window.localStorage.setItem(KEY, box.checked ? '1' : '0'); } catch (e) { /* not remembered */ }
|
|
991
|
+
apply();
|
|
992
|
+
});
|
|
993
|
+
})();
|
|
994
|
+
</script>`;
|
|
995
|
+
return h;
|
|
996
|
+
}
|
|
997
|
+
|
|
666
998
|
// ---- the terminology view ----
|
|
667
999
|
|
|
668
1000
|
/** Text, as a link when there is somewhere to go. */
|
|
@@ -836,7 +1168,8 @@ document.querySelectorAll('.ips-copy').forEach(function (b) {
|
|
|
836
1168
|
|
|
837
1169
|
// ---- the validation view ----
|
|
838
1170
|
|
|
839
|
-
|
|
1171
|
+
// badge colours carry white text: each is at least 4.5:1 against it (WCAG 1.4.3)
|
|
1172
|
+
const SEVERITY_COLOURS = { fatal: '#c82333', error: '#c82333', warning: '#8a6100', information: '#117a8b' };
|
|
840
1173
|
|
|
841
1174
|
/** Where an issue is: its expression (FHIRPath), else its location. */
|
|
842
1175
|
function issueWhere(i) {
|
|
@@ -882,7 +1215,7 @@ function noResultNote(row, step, what, options = {}) {
|
|
|
882
1215
|
const PROFILE_OUTCOMES = {
|
|
883
1216
|
conforms: ['Conforms', '#1e7e34'],
|
|
884
1217
|
fails: ['Does not conform', '#c82333'],
|
|
885
|
-
unknown: ['Could not be fully checked', '#
|
|
1218
|
+
unknown: ['Could not be fully checked', '#8a6100']
|
|
886
1219
|
};
|
|
887
1220
|
|
|
888
1221
|
/**
|
|
@@ -925,13 +1258,16 @@ const TRANSLATION_OUTCOMES = {
|
|
|
925
1258
|
'no-translation': ['No translation', '#c82333'],
|
|
926
1259
|
failed: ['Failed', '#c82333'],
|
|
927
1260
|
'not-needed': ['Not needed', '#6c757d'],
|
|
928
|
-
'not-checked': ['Not checked', '#
|
|
1261
|
+
'not-checked': ['Not checked', '#a35200'],
|
|
929
1262
|
'no-codings': ['No codings', '#6c757d']
|
|
930
1263
|
};
|
|
931
1264
|
|
|
932
1265
|
/** A coding, briefly: system|code "display". */
|
|
933
1266
|
function briefCoding(c) {
|
|
934
|
-
|
|
1267
|
+
// the version too (for SNOMED CT, the edition): the same code in two editions is two codings
|
|
1268
|
+
const v = describeVersion(c.version);
|
|
1269
|
+
const ver = v.text ? ` <small title="${escape(v.title ? `${v.title} (${c.version})` : c.version)}">[${escape(v.text)}]</small>` : '';
|
|
1270
|
+
return `<code>${escape(`${systemName(c.system) || c.system || ''}|${c.code}`)}</code>${ver}` + (typeof c.display === 'string' && c.display ? ` "${escape(c.display)}"` : '');
|
|
935
1271
|
}
|
|
936
1272
|
|
|
937
1273
|
/**
|
|
@@ -951,6 +1287,11 @@ function renderTranslation(row, options = {}) {
|
|
|
951
1287
|
(typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') + '</p>';
|
|
952
1288
|
h += `<p><b>${escape(`${counts.translated || 0} of ${counts.tried || 0} concepts translated (${counts.codings || 0} codings added)`)}` +
|
|
953
1289
|
(counts.failed ? escape(`, ${counts.failed} failed`) : '') + '</b></p>';
|
|
1290
|
+
if (v.defaultEdition && v.defaultEdition.edition) {
|
|
1291
|
+
const d = v.defaultEdition;
|
|
1292
|
+
h += `<p class="ips-small">SNOMED CT codes with no version were translated as codes of the ${editionHtml(d.edition)} - the SNOMED CT edition of the national profile ` +
|
|
1293
|
+
`<code>${escape(`${d.packageId}#${d.version}`)}</code>, for ${escape(d.jurisdiction)}, the nation the document is from.</p>`;
|
|
1294
|
+
}
|
|
954
1295
|
h += '<table class="table table-sm table-striped ips-table"><tr><th>Element</th><th>Codings</th><th>Value set</th><th>Outcome</th><th>Detail</th></tr>';
|
|
955
1296
|
for (const e of v.entries) {
|
|
956
1297
|
const [label, colour] = TRANSLATION_OUTCOMES[e.outcome] || [e.outcome || '?', '#6c757d'];
|
|
@@ -990,7 +1331,8 @@ function renderGenerated(row, options = {}) {
|
|
|
990
1331
|
if (checkNarrative(v.html)) {
|
|
991
1332
|
return h + '<p><i>The generated narrative is not shown: it failed the active content check.</i></p>';
|
|
992
1333
|
}
|
|
993
|
-
|
|
1334
|
+
// the presenter's narrative is a document of its own (h1 title, h2 sections): seated under the page's h2
|
|
1335
|
+
h += `<div class="ips-narrative ips-generated">${parse5.serialize(seatHeadings(parse5.parseFragment(v.html), 3))}</div>`;
|
|
994
1336
|
h += '<div class="ips-explain"><p class="ips-small">The narrative this server generates for the document (the IPS presenter): the Composition\'s header and the patient, ' +
|
|
995
1337
|
'then the sections in the IPS order, each a table of its entries with the columns that suit the resource type (the ones no entry fills are left out) and the nested parts as bullets beneath. ' +
|
|
996
1338
|
'Coded values show their text, else a display, else what the terminology server knows, else --. It is also written into the analysed copy as the Composition\'s and the sections\' narrative, which is what the validator sees.</p></div>';
|
|
@@ -1000,8 +1342,8 @@ function renderGenerated(row, options = {}) {
|
|
|
1000
1342
|
const tokens = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '?');
|
|
1001
1343
|
const usd = (n) => (n < 0.01 && n > 0 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`);
|
|
1002
1344
|
|
|
1003
|
-
const AI_SEVERITY_COLOURS = { high: '#c82333', medium: '#
|
|
1004
|
-
const GRADE_COLOURS = { A: '#1e7e34', B: '#
|
|
1345
|
+
const AI_SEVERITY_COLOURS = { high: '#c82333', medium: '#8a6100', low: '#117a8b' };
|
|
1346
|
+
const GRADE_COLOURS = { A: '#1e7e34', B: '#4e7d0e', C: '#8a6100', D: '#a35200', F: '#c82333' };
|
|
1005
1347
|
|
|
1006
1348
|
/**
|
|
1007
1349
|
* The AI assessment view: the grade, the summary, and the findings.
|
|
@@ -1032,7 +1374,7 @@ function renderAi(row, options = {}) {
|
|
|
1032
1374
|
}
|
|
1033
1375
|
h += `<p><span class="ips-badge ips-grade" style="background-color: ${GRADE_COLOURS[a.grade] || '#6c757d'}">Grade ${escape(a.grade || '?')}</span> ${escape(a.summary || '')}</p>`;
|
|
1034
1376
|
const sev = (x) => `<span class="ips-badge" style="background-color: ${AI_SEVERITY_COLOURS[x] || '#6c757d'}">${escape(x)}</span>`;
|
|
1035
|
-
h += '<
|
|
1377
|
+
h += '<h3>Consistency and integrity</h3>';
|
|
1036
1378
|
if (a.consistency.length === 0) {
|
|
1037
1379
|
h += '<p><i>No findings</i></p>';
|
|
1038
1380
|
} else {
|
|
@@ -1048,15 +1390,7 @@ function renderAi(row, options = {}) {
|
|
|
1048
1390
|
h += '<table class="table table-sm table-striped ips-table"><tr><th>Severity</th><th>Finding</th><th>Codes</th></tr>' +
|
|
1049
1391
|
a.terminology.map(x => `<tr><td>${sev(x.severity)}</td><td>${escape(x.finding)}</td><td>${x.codes.map(c => `<code>${escape(c)}</code>`).join('<br/>')}</td></tr>`).join('') + '</table>';
|
|
1050
1392
|
}
|
|
1051
|
-
h += '
|
|
1052
|
-
`<pre class="ips-json">${escape(String(v.raw || ''))}</pre></details>`;
|
|
1053
|
-
h += `<script>
|
|
1054
|
-
document.querySelectorAll('.ips-oo-toggle').forEach(function (b) {
|
|
1055
|
-
var d = b.closest('details');
|
|
1056
|
-
b.addEventListener('click', function (e) { e.preventDefault(); d.open = !d.open; });
|
|
1057
|
-
d.addEventListener('toggle', function () { b.textContent = (d.open ? 'Hide' : 'Show') + " the model's answer"; });
|
|
1058
|
-
});
|
|
1059
|
-
</script>`;
|
|
1393
|
+
h += showHide("the model's answer", `<pre class="ips-json">${escape(String(v.raw || ''))}</pre>`);
|
|
1060
1394
|
h += '<div class="ips-explain"><p class="ips-small">An AI service\'s assessment of the document (the analysed copy, with the narrative as submitted or without any, as the project\'s settings say): its consistency and integrity as a whole - do its details agree with each other and the overall picture, and is anything missing; with the narrative, what it says that the data does not back up - ' +
|
|
1061
1395
|
'and its terminology - are the codes consistent with each other and with their text. The grade runs A (consistent and complete) to F (not usable). It is the model\'s opinion, to be read as such.</p></div>';
|
|
1062
1396
|
return h;
|
|
@@ -1074,9 +1408,22 @@ function renderValidation(row, options = {}) {
|
|
|
1074
1408
|
if (!r || (r.result.status && r.result.status !== 'done')) {
|
|
1075
1409
|
return noResultNote(row, 'validation', 'There is no validation result for this document', options);
|
|
1076
1410
|
}
|
|
1077
|
-
|
|
1411
|
+
return validationOutcome(r.made, r.result, { ...options, hidden: { view: 'validation' } });
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* What the validator made of the document, once: when, with what, the issue counts, the
|
|
1416
|
+
* issues (as filtered by options.level) and the OperationOutcome. The validation view and
|
|
1417
|
+
* each national profile's page show it.
|
|
1418
|
+
* @param {string} made - when the validation was done
|
|
1419
|
+
* @param {{counts?: Object, outcome?: Object, profiles?: string[], validatorVersion?: string, ms?: number}} v
|
|
1420
|
+
* @param {{level?: string, defaultLevel?: string, docUrl?: string, hidden?: Object}} options - hidden: the
|
|
1421
|
+
* fields the level form keeps (the view, and which national profile); defaultLevel: the level when
|
|
1422
|
+
* none is asked for ('information' - everything - unless given)
|
|
1423
|
+
*/
|
|
1424
|
+
function validationOutcome(made, v, options = {}) {
|
|
1078
1425
|
const counts = v.counts || {};
|
|
1079
|
-
let h = `<p class="ips-small">Validated ${escape(
|
|
1426
|
+
let h = `<p class="ips-small">Validated ${escape(made)}` +
|
|
1080
1427
|
(v.validatorVersion ? ` with validator ${escape(String(v.validatorVersion))}` : '') +
|
|
1081
1428
|
(Array.isArray(v.profiles) && v.profiles.length ? ` against ${v.profiles.map(p => `<code>${escape(p)}</code>`).join(', ')}` : '') +
|
|
1082
1429
|
(typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') + '</p>';
|
|
@@ -1085,11 +1432,13 @@ function renderValidation(row, options = {}) {
|
|
|
1085
1432
|
if (all.length === 0) {
|
|
1086
1433
|
return h;
|
|
1087
1434
|
}
|
|
1088
|
-
|
|
1435
|
+
// as asked for; else the view's own default (everything, unless it says otherwise)
|
|
1436
|
+
const level = LEVELS.some(([l]) => l === options.level) ? options.level : (options.defaultLevel || 'information');
|
|
1089
1437
|
const min = SEVERITY_RANK[level];
|
|
1090
1438
|
const issues = all.filter(i => (SEVERITY_RANK[text(i.severity)] ?? 3) >= min);
|
|
1091
1439
|
// the minimum level: a form that submits itself on change (and has a button without script)
|
|
1092
|
-
|
|
1440
|
+
const hidden = Object.entries(options.hidden || { view: 'validation' }).map(([k, val]) => `<input type="hidden" name="${escape(k)}" value="${escape(String(val))}"/>`).join('');
|
|
1441
|
+
h += `<form method="get" action="${escape(options.docUrl || '')}" class="ips-filter mb-2">${hidden}` +
|
|
1093
1442
|
'<label for="f-level">Show</label> <select id="f-level" name="level" onchange="this.form.submit()">' +
|
|
1094
1443
|
LEVELS.map(([l, label]) => `<option value="${l}"${l === level ? ' selected' : ''}>${escape(label)}</option>`).join('') +
|
|
1095
1444
|
'</select><noscript><button type="submit" class="btn btn-default ips-btn">Apply</button></noscript>';
|
|
@@ -1108,16 +1457,79 @@ function renderValidation(row, options = {}) {
|
|
|
1108
1457
|
`<td>${escape(issueMessage(i))}</td></tr>`;
|
|
1109
1458
|
}
|
|
1110
1459
|
h += '</table>';
|
|
1111
|
-
// the raw OperationOutcome, behind a button
|
|
1112
|
-
h += '
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1460
|
+
// the raw OperationOutcome, behind a button
|
|
1461
|
+
h += showHide('the OperationOutcome', `<pre class="ips-json">${escape(JSON.stringify(v.outcome, null, 2))}</pre>`);
|
|
1462
|
+
return h;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Content behind a Show / Hide button: a <details> whose summary is styled as the button.
|
|
1467
|
+
* The summary itself is the control - a <button> inside it would be a control nested in a
|
|
1468
|
+
* control, which assistive technology can't make sense of (WCAG 4.1.2) - and it works from
|
|
1469
|
+
* the keyboard and without scripts. The script only changes its label to Hide when it's open.
|
|
1470
|
+
* @param {string} what - what is shown ('the OperationOutcome'), plain text
|
|
1471
|
+
* @param {string} body - the content, HTML
|
|
1472
|
+
*/
|
|
1473
|
+
function showHide(what, body) {
|
|
1474
|
+
return `<details class="ips-oo" data-what="${escape(what)}"><summary class="btn btn-default ips-btn">Show ${escape(what)}</summary>${body}</details>` +
|
|
1475
|
+
`<script>
|
|
1476
|
+
document.querySelectorAll('details.ips-oo:not([data-bound])').forEach(function (d) {
|
|
1477
|
+
d.setAttribute('data-bound', 'true');
|
|
1478
|
+
d.addEventListener('toggle', function () { d.querySelector('summary').textContent = (d.open ? 'Hide ' : 'Show ') + d.getAttribute('data-what'); });
|
|
1119
1479
|
});
|
|
1120
1480
|
</script>`;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// a national profile's outcome on the page: [label, colour]
|
|
1484
|
+
const NATIONAL_OUTCOMES = {
|
|
1485
|
+
pass: ['Passed', '#1e7e34'],
|
|
1486
|
+
fail: ['Failed', '#c82333'],
|
|
1487
|
+
unknown: ['Not checked', '#8a6100']
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* The national profiles step's result: the list of national profiles, and whether the
|
|
1492
|
+
* document passed each (no errors); with options.np (an index into the list), that
|
|
1493
|
+
* profile's validation, shown as the validation view shows the base validation.
|
|
1494
|
+
* @param {Object} row - from store.read(), with .results.national or not
|
|
1495
|
+
* @param {{np?: string, level?: string, docUrl?: string, base?: string, canManage?: boolean}} [options]
|
|
1496
|
+
*/
|
|
1497
|
+
function renderNational(row, options = {}) {
|
|
1498
|
+
const r = row.results?.national;
|
|
1499
|
+
if (!r || (r.result.status && r.result.status !== 'done') || !Array.isArray(r.result.profiles)) {
|
|
1500
|
+
return noResultNote(row, 'national', 'The document has not been validated against the national profiles', options);
|
|
1501
|
+
}
|
|
1502
|
+
const v = r.result;
|
|
1503
|
+
const base = escape(options.base || '');
|
|
1504
|
+
const docUrl = escape(options.docUrl || '');
|
|
1505
|
+
if (v.profiles.length === 0) {
|
|
1506
|
+
return `<p>There were no <a href="${base}/national-profiles">national profiles</a> to validate the document against when it was processed.</p>`;
|
|
1507
|
+
}
|
|
1508
|
+
const outcome = (p) => NATIONAL_OUTCOMES[p.pass === true ? 'pass' : p.pass === false ? 'fail' : 'unknown'];
|
|
1509
|
+
const index = typeof options.np === 'string' && /^\d{1,4}$/.test(options.np) ? parseInt(options.np, 10) : -1;
|
|
1510
|
+
const p = index >= 0 ? v.profiles[index] : null;
|
|
1511
|
+
if (p) {
|
|
1512
|
+
const [label, colour] = outcome(p);
|
|
1513
|
+
let h = `<p class="ips-small"><a href="${docUrl}?view=national">National profiles</a> » ${escape(`${p.packageId}#${p.version}`)}</p>`;
|
|
1514
|
+
h += `<p>${jurisdictionHtml(p.jurisdiction)}: <code>${escape(p.url)}</code> (${escape(`${p.packageId}#${p.version}`)}): <span class="ips-badge" style="background: ${colour}">${escape(label)}</span></p>`;
|
|
1515
|
+
if (p.pass === null || p.pass === undefined) {
|
|
1516
|
+
return h + `<p><i>The document could not be validated against this profile${options.canManage && p.error ? `: ${escape(p.error)}` : ' (the reason is shown to the project\'s managers)'}.</i></p>`;
|
|
1517
|
+
}
|
|
1518
|
+
return h + validationOutcome(r.made, { counts: p.counts, outcome: p.outcome, profiles: [p.url], validatorVersion: v.validatorVersion, ms: p.ms },
|
|
1519
|
+
// a national profile's issues are mostly about the national IG's own rules: errors only, unless asked
|
|
1520
|
+
{ ...options, defaultLevel: 'error', hidden: { view: 'national', np: String(index) } });
|
|
1521
|
+
}
|
|
1522
|
+
let h = '<table class="table table-sm ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Outcome</th><th>Issues</th></tr>';
|
|
1523
|
+
v.profiles.forEach((np, i) => {
|
|
1524
|
+
const [label, colour] = outcome(np);
|
|
1525
|
+
const issues = np.pass === null || np.pass === undefined ? '<span class="ips-small">(not validated)</span>' : escape(issueSummary(np.counts || {}));
|
|
1526
|
+
h += `<tr><td>${jurisdictionHtml(np.jurisdiction)}</td><td><a href="${docUrl}?view=national&np=${i}"><code>${escape(`${np.packageId}#${np.version}`)}</code></a></td>` +
|
|
1527
|
+
`<td><span class="ips-badge" style="background: ${colour}">${escape(label)}</span></td><td>${issues}</td></tr>`;
|
|
1528
|
+
});
|
|
1529
|
+
h += '</table>';
|
|
1530
|
+
h += `<p class="ips-small">Validated ${escape(r.made)} against the <a href="${base}/national-profiles">national profiles</a> as they were when the document was processed ` +
|
|
1531
|
+
`(${v.profiles.length} profile${v.profiles.length === 1 ? '' : 's'}, ${escape(String(v.ms))} ms); a document passes a profile when the validator reports no errors. ` +
|
|
1532
|
+
'Reprocess the document to validate it against them as they are now.</p>';
|
|
1121
1533
|
return h;
|
|
1122
1534
|
}
|
|
1123
1535
|
|
|
@@ -1139,10 +1551,89 @@ function renderCompose(project, ctx, options) {
|
|
|
1139
1551
|
};
|
|
1140
1552
|
// JSON in a script: </script> and the like must not end the block
|
|
1141
1553
|
h += `<script>window.IPS_EDITOR = ${JSON.stringify(cfg).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')};</script>`;
|
|
1142
|
-
h += `<script src="${escape(ctx.base
|
|
1554
|
+
h += `<script src="${escape(scripts.src(ctx.base, 'editor.js'))}"></script>`;
|
|
1143
1555
|
return h;
|
|
1144
1556
|
}
|
|
1145
1557
|
|
|
1558
|
+
// ---- national profiles -----------------------------------------------------------------
|
|
1559
|
+
|
|
1560
|
+
/** A national profile's jurisdiction, for the page: its code, and the country's or region's name. */
|
|
1561
|
+
function jurisdictionHtml(code) {
|
|
1562
|
+
if (!code) {
|
|
1563
|
+
return '<span class="ips-small">(not given)</span>';
|
|
1564
|
+
}
|
|
1565
|
+
const j = jurisdiction(code);
|
|
1566
|
+
return `<code>${escape(code)}</code>` + (j ? ` ${escape(j.display)}` : '');
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** A SNOMED CT edition by name, with its module id on hover; '' for none. */
|
|
1570
|
+
function editionHtml(edition) {
|
|
1571
|
+
if (!edition) {
|
|
1572
|
+
return '';
|
|
1573
|
+
}
|
|
1574
|
+
return `<span title="${escape(`http://snomed.info/sct/${edition}`)}">${escape(editionName(edition) || edition)}</span>`;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** The SNOMED CT edition select for a national profile: none, or one of the editions. */
|
|
1578
|
+
function editionSelect(form, edition) {
|
|
1579
|
+
let h = `<select form="${form}" name="snomedEdition" class="form-control" style="font-size: 85%; width: 14em" title="The SNOMED CT edition the IG takes for granted, if any">` +
|
|
1580
|
+
`<option value=""${edition ? '' : ' selected'}>(none)</option>`;
|
|
1581
|
+
for (const ed of SNOMED_LINKABLE_EDITIONS) {
|
|
1582
|
+
h += `<option value="${escape(ed)}"${ed === edition ? ' selected' : ''}>${escape(editionName(ed) || ed)}</option>`;
|
|
1583
|
+
}
|
|
1584
|
+
return h + '</select>';
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* The national profiles: for each, the national IG's package (id and version), the url of
|
|
1589
|
+
* its profile on Bundle, and the SNOMED CT edition it takes for granted (if any). Everyone sees the list; the administrator adds, edits and deletes.
|
|
1590
|
+
* @param {Array} list - from store.nationalProfiles()
|
|
1591
|
+
*/
|
|
1592
|
+
function renderNationalProfiles(list, ctx, message) {
|
|
1593
|
+
const base = escape(ctx.base);
|
|
1594
|
+
const isAdmin = !!ctx.user?.isAdmin;
|
|
1595
|
+
let h = STYLE + userBar(ctx) + `<p class="ips-small"><a href="${base}">IPS projects</a> » National profiles</p>` + alert(message);
|
|
1596
|
+
h += '<div class="ips-banner"><b class="ips-project-name">National profiles</b></div>';
|
|
1597
|
+
h += '<p>A national profile is the profile a national implementation guide defines for an IPS document: the country or region it is for, the IG\'s package (its id and version), ' +
|
|
1598
|
+
'the canonical URL of its profile on Bundle, and the SNOMED CT edition the IG takes for granted (if it uses SNOMED CT). They apply across all projects.' + (isAdmin ? '' : ' Only the administrator can change them.') + '</p>';
|
|
1599
|
+
if (list.length === 0) {
|
|
1600
|
+
h += '<p>There are no national profiles yet.</p>';
|
|
1601
|
+
}
|
|
1602
|
+
if (!isAdmin) {
|
|
1603
|
+
if (list.length === 0) {
|
|
1604
|
+
return h;
|
|
1605
|
+
}
|
|
1606
|
+
h += '<table class="table table-sm table-striped ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Version</th><th>Profile on Bundle</th><th>SNOMED CT Edition</th></tr>';
|
|
1607
|
+
for (const np of list) {
|
|
1608
|
+
h += `<tr><td>${jurisdictionHtml(np.jurisdiction)}</td><td><code>${escape(np.packageId)}</code></td><td>${escape(np.version)}</td><td><code>${escape(np.url)}</code></td><td>${editionHtml(np.snomedEdition)}</td></tr>`;
|
|
1609
|
+
}
|
|
1610
|
+
return h + '</table>';
|
|
1611
|
+
}
|
|
1612
|
+
const fields = (form, np) =>
|
|
1613
|
+
`<td><input form="${form}" type="text" name="jurisdiction" required class="form-control" style="font-size: 85%; width: 6em" placeholder="AU, or 150" ` +
|
|
1614
|
+
`title="A country's ISO 3166 two-letter code, or a region's UN M49 three-digit code" value="${escape(np ? (np.jurisdiction || '') : '')}"/>` +
|
|
1615
|
+
(np && np.jurisdiction && jurisdiction(np.jurisdiction) ? `<span class="ips-small">${escape(jurisdiction(np.jurisdiction).display)}</span>` : '') + '</td>' +
|
|
1616
|
+
`<td><input form="${form}" type="text" name="packageId" required class="form-control" style="font-size: 85%; width: 15em" placeholder="e.g. hl7.fhir.au.ips" value="${escape(np ? np.packageId : '')}"/></td>` +
|
|
1617
|
+
`<td><input form="${form}" type="text" name="version" required class="form-control" style="font-size: 85%; width: 8em" placeholder="version" value="${escape(np ? np.version : '')}"/></td>` +
|
|
1618
|
+
`<td><input form="${form}" type="url" name="url" required class="form-control" style="font-size: 85%; width: 100%; min-width: 30em" placeholder="url of the profile on Bundle" value="${escape(np ? np.url : '')}"/></td>` +
|
|
1619
|
+
`<td>${editionSelect(form, np ? np.snomedEdition : null)}</td>`;
|
|
1620
|
+
// the package id and version are short; the url takes the rest of the row
|
|
1621
|
+
h += '<table class="table table-sm ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Version</th><th style="width: 100%">Profile on Bundle</th><th>SNOMED CT Edition</th><th></th></tr>';
|
|
1622
|
+
for (const np of list) {
|
|
1623
|
+
const form = `np-${np.id}`;
|
|
1624
|
+
const action = `${base}/national-profiles/${np.id}`;
|
|
1625
|
+
h += '<tr>' + fields(form, np) +
|
|
1626
|
+
`<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" class="ips-inline">${csrfField(ctx.csrf)}` +
|
|
1627
|
+
'<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
|
|
1628
|
+
`<form method="post" action="${action}/delete" class="ips-inline" data-confirm="${escape(`Delete the national profile for ${np.packageId}#${np.version}?`)}">${csrfField(ctx.csrf)}` +
|
|
1629
|
+
'<button type="submit" class="btn btn-default ips-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
|
|
1630
|
+
}
|
|
1631
|
+
h += '<tr>' + fields('np-new', null) +
|
|
1632
|
+
`<td><form id="np-new" method="post" action="${base}/national-profiles">${csrfField(ctx.csrf)}` +
|
|
1633
|
+
'<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Add</button></form></td></tr>';
|
|
1634
|
+
return h + '</table>';
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1146
1637
|
// ---- clinical profiles -----------------------------------------------------------------
|
|
1147
1638
|
|
|
1148
1639
|
function profileCrumbs(ctx, extra) {
|
|
@@ -1201,63 +1692,180 @@ function renderProfiles(list, ctx) {
|
|
|
1201
1692
|
// the parts of a profile, as the view and the editor show them
|
|
1202
1693
|
const PROFILE_PARTS = [['section', 'Required sections and entries'], ['resource', 'Rules about resources'], ['other', 'Other rules']];
|
|
1203
1694
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1695
|
+
// the code systems for the categories of the resources in an IPS, for a rule's category
|
|
1696
|
+
const CATEGORY_SYSTEMS = [
|
|
1697
|
+
{ url: 'http://terminology.hl7.org/CodeSystem/condition-category', name: 'Condition category' },
|
|
1698
|
+
{ url: 'http://terminology.hl7.org/CodeSystem/observation-category', name: 'Observation category' },
|
|
1699
|
+
{ url: 'http://terminology.hl7.org/CodeSystem/medicationrequest-category', name: 'MedicationRequest category' }
|
|
1700
|
+
];
|
|
1701
|
+
|
|
1702
|
+
/** A cardinality, for the profile's page: " [m..n]", unless it is unstated or 1..1. */
|
|
1703
|
+
function cardSuffix(p) {
|
|
1704
|
+
return p.min === null || p.min === undefined || (p.min === 1 && p.max === 1) ? '' : ` [${p.min}..${p.max}]`;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/** The name of a code system, when it has one we know, or its URL. */
|
|
1708
|
+
function profileSystemName(url) {
|
|
1709
|
+
const sys = profiles.SYSTEMS.find(s => s.url === url);
|
|
1710
|
+
return sys ? sys.name : (systemName(url) || url);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** The Section and Entry columns of a required section/entry row. */
|
|
1714
|
+
function sectionColumns(shape, titleOf) {
|
|
1715
|
+
const s = shape.section;
|
|
1716
|
+
const named = s.system === LOINC ? profiles.IPS_SECTIONS.find(x => x.code === s.code) : null;
|
|
1717
|
+
const section = (named
|
|
1718
|
+
? `${escape(named.title)} <span class="ips-small">(${escape(`${profileSystemName(s.system)}|${s.code}`)})</span>`
|
|
1719
|
+
: escape(`${profileSystemName(s.system)}|${s.code}`)) + escape(cardSuffix(s));
|
|
1720
|
+
const e = shape.entry;
|
|
1721
|
+
let entry = '';
|
|
1722
|
+
if (e && e.valueSet !== undefined) {
|
|
1723
|
+
const title = typeof titleOf === 'function' ? titleOf(e.valueSet) : null;
|
|
1724
|
+
entry = `Code is in <b>${escape(title || e.valueSet)}</b>` + (title ? ` <span class="ips-small">(${escape(e.valueSet)})</span>` : '') + escape(cardSuffix(e));
|
|
1725
|
+
} else if (e) {
|
|
1726
|
+
entry = `Code is <b>${escape(`${profileSystemName(e.system)}|${e.code}`)}</b>` + escape(cardSuffix(e));
|
|
1727
|
+
}
|
|
1728
|
+
return [section, entry];
|
|
1226
1729
|
}
|
|
1227
1730
|
|
|
1228
|
-
/**
|
|
1229
|
-
function
|
|
1731
|
+
/** The resources a rule is about, as a plural: MedicationAdministrations, or Any resource. */
|
|
1732
|
+
function resourceTypePlural(type) {
|
|
1733
|
+
if (!type) {
|
|
1734
|
+
return 'Any resource';
|
|
1735
|
+
}
|
|
1736
|
+
return /[^aeiou]y$/.test(type) ? `${type.slice(0, -1)}ies` : `${type}s`;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// the tails of a rule about resources that are said in words: each of the patterns, as it
|
|
1740
|
+
// is, or (where a resource at a time is what is meant) inside .all(...)
|
|
1741
|
+
const STR_ARG = "\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*";
|
|
1742
|
+
const NAME = '[A-Za-z][A-Za-z0-9_]*';
|
|
1743
|
+
const allOf = (body) => new RegExp(`^\\.\\s*(?:${body}|all\\s*\\(\\s*${body}\\s*\\))$`);
|
|
1744
|
+
const TAILS = [
|
|
1745
|
+
[/^\.\s*exists\s*\(\s*\)$/, () => 'Must be present somewhere'],
|
|
1746
|
+
[/^\.\s*all\s*\(\s*sameSubject\s*\(\s*\)\s*\)$/, () => 'Patient must match the Document'],
|
|
1747
|
+
[allOf(`conformsTo\\s*\\(${STR_ARG}\\)`), (m, ctx) => {
|
|
1748
|
+
const url = unescapeArg(m[1] !== undefined ? m[1] : m[2]);
|
|
1749
|
+
const title = typeof ctx.profileTitle === 'function' ? ctx.profileTitle(url) : null;
|
|
1750
|
+
return `Conform to <b>${escape(title || url)}</b>` + (title ? ` <span class="ips-small">(${escape(url)})</span>` : '');
|
|
1751
|
+
}],
|
|
1752
|
+
[allOf(`category\\s*\\.\\s*hasCoding\\s*\\(${STR_ARG},${STR_ARG}(?:,${STR_ARG})?\\)`), (m) => {
|
|
1753
|
+
const [system, code, version] = (m[1] !== undefined ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]]).map(x => x === undefined ? undefined : unescapeArg(x));
|
|
1754
|
+
return `Has category <b>${escape(`${profileSystemName(system)}|${code}`)}</b>` + (version !== undefined ? ` <span class="ips-small">(version ${escape(version)})</span>` : '');
|
|
1755
|
+
}],
|
|
1756
|
+
// an element, or an element's element (no deeper): Must have an x, Must have an x.y
|
|
1757
|
+
[new RegExp(`^\\.\\s*(${NAME})\\s*(?:\\.\\s*(${NAME})\\s*)?\\.\\s*exists\\s*\\(\\s*\\)$`), (m) => {
|
|
1758
|
+
const path = m[2] ? `${m[1]}.${m[2]}` : m[1];
|
|
1759
|
+
return `Must have ${/^[aeiou]/i.test(path) ? 'an' : 'a'} <code>${escape(path)}</code>`;
|
|
1760
|
+
}]
|
|
1761
|
+
];
|
|
1762
|
+
|
|
1763
|
+
/** A FHIRPath string's content, unescaped. */
|
|
1764
|
+
function unescapeArg(s) {
|
|
1765
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (x, c) => c.length === 5 ? String.fromCharCode(parseInt(c.substring(1), 16)) : ({ n: '\n', r: '\r', t: '\t', f: '\f' }[c] || c));
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** The Resource Set and Rule columns of a rule about resources. */
|
|
1769
|
+
function resourceColumns(rshape, ctx) {
|
|
1770
|
+
const titleOf = ctx.valueSetTitle;
|
|
1771
|
+
let which;
|
|
1772
|
+
if (rshape.valueSet !== undefined) {
|
|
1773
|
+
const title = typeof titleOf === 'function' ? titleOf(rshape.valueSet) : null;
|
|
1774
|
+
which = `Code is in <b>${escape(title || rshape.valueSet)}</b>` + (title ? ` <span class="ips-small">(${escape(rshape.valueSet)})</span>` : '');
|
|
1775
|
+
} else {
|
|
1776
|
+
which = `Code is <b>${escape(`${profileSystemName(rshape.system)}|${rshape.code}`)}</b>`;
|
|
1777
|
+
}
|
|
1778
|
+
const set = `${escape(resourceTypePlural(rshape.type))} where ${which}`;
|
|
1779
|
+
const rest = rshape.rest.trim();
|
|
1780
|
+
for (const [re, words] of TAILS) {
|
|
1781
|
+
const m = re.exec(rest);
|
|
1782
|
+
if (m) {
|
|
1783
|
+
return [set, words(m, ctx)];
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return [set, `<code>${escape(rest)}</code>`];
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* A clinical profile: the same page for everyone. The administrator also gets add, edit and
|
|
1791
|
+
* delete on each row (a row at a time, the script edits it in place), and the full editor.
|
|
1792
|
+
*/
|
|
1793
|
+
function renderProfile(sd, ctx, editor = null) {
|
|
1230
1794
|
const base = escape(ctx.base);
|
|
1231
1795
|
const url = `${base}/StructureDefinition/${escape(sd.id)}`;
|
|
1796
|
+
const admin = !!(editor && ctx.user?.isAdmin);
|
|
1232
1797
|
let h = STYLE + userBar(ctx) + profileCrumbs(ctx, sd.title);
|
|
1233
1798
|
h += `<div class="ips-banner"><b class="ips-project-name">${escape(sd.title)}</b>` +
|
|
1234
1799
|
`<div class="ips-facts">Clinical profile on Bundle · <code>${escape(sd.url)}</code> · updated ${escape(dateOnly(sd.date))}</div></div>`;
|
|
1235
1800
|
h += '<div class="ips-actions-line">' +
|
|
1236
1801
|
`<a class="btn btn-default" href="${url}?download=1">Download JSON</a>` +
|
|
1237
|
-
(
|
|
1802
|
+
(admin ? ` <a class="btn btn-default" href="${url}/edit">Edit all</a> ` +
|
|
1238
1803
|
`<form method="post" action="${url}/delete" class="ips-inline" data-confirm="${escape(`Delete the clinical profile ${sd.title}?`)}">${csrfField(ctx.csrf)}` +
|
|
1239
|
-
'<button type="submit" class="btn btn-default">Delete</button></form>' : '') + '</div>';
|
|
1240
|
-
const constraints = sd.differential?.element?.[0]?.constraint || [];
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1804
|
+
'<button type="submit" class="btn btn-default">Delete profile</button></form>' : '') + '</div>';
|
|
1805
|
+
const constraints = (sd.differential?.element?.[0]?.constraint || []).filter(c => c && typeof c === 'object');
|
|
1806
|
+
// the actions on a row, for the administrator
|
|
1807
|
+
const actions = (c) => !admin ? '' : '<td class="ips-pf-actions">' +
|
|
1808
|
+
'<button type="button" class="btn btn-default ips-btn ips-pf-edit">Edit</button> ' +
|
|
1809
|
+
`<form method="post" action="${url}/constraint/${escape(c.key || '')}/delete" class="ips-inline" data-confirm="${escape(`Delete the rule '${c.human || c.key}'?`)}">${csrfField(ctx.csrf)}` +
|
|
1810
|
+
'<button type="submit" class="btn btn-default ips-btn">Delete</button></form></td>';
|
|
1811
|
+
const rowAttrs = (c) => (admin ? ` data-key="${escape(c.key || '')}"` : '') + ` title="${escape(c.expression || '')}"`;
|
|
1812
|
+
const actionsHead = admin ? '<th class="ips-pf-actions"></th>' : '';
|
|
1813
|
+
const ADD = { section: 'Add a required section/entry', resource: 'Add a rule about resources', other: 'Add another rule' };
|
|
1814
|
+
for (const [kind, heading] of PROFILE_PARTS) {
|
|
1815
|
+
const these = constraints.filter(c => profiles.kindOf(c.expression || '') === kind);
|
|
1816
|
+
if (!these.length && !admin) {
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
// the heading, and for the administrator the part's Add button, to its right
|
|
1820
|
+
h += `<div class="ips-pf-part-head"><h4>${escape(heading)}</h4>` +
|
|
1821
|
+
(admin ? `<button type="button" class="btn btn-default ips-pf-add" data-part="${kind}">${escape(ADD[kind])}</button>` : '') + '</div>';
|
|
1822
|
+
if (kind === 'section') {
|
|
1823
|
+
h += `<table class="table table-sm ips-table ips-pf-rule" data-part="${kind}"><tr><th style="width: 40%">Expectation</th><th>Section</th><th>Entry</th>${actionsHead}</tr>`;
|
|
1824
|
+
for (const c of these) {
|
|
1825
|
+
const [section, entry] = sectionColumns(profiles.parseShape(c.expression || ''), ctx.valueSetTitle);
|
|
1826
|
+
h += `<tr${rowAttrs(c)}><td>${escape(c.human || '')}</td><td>${section}</td><td>${entry}</td>${actions(c)}</tr>`;
|
|
1827
|
+
}
|
|
1828
|
+
} else if (kind === 'resource') {
|
|
1829
|
+
h += `<table class="table table-sm ips-table ips-pf-rule" data-part="${kind}"><tr><th style="width: 40%">Expectation</th><th>Resource Set</th><th>Rule</th>${actionsHead}</tr>`;
|
|
1830
|
+
for (const c of these) {
|
|
1831
|
+
const [set, rule] = resourceColumns(profiles.parseResourceShape(c.expression || ''), ctx);
|
|
1832
|
+
h += `<tr${rowAttrs(c)}><td>${escape(c.human || '')}</td><td>${set}</td><td>${rule}</td>${actions(c)}</tr>`;
|
|
1248
1833
|
}
|
|
1249
|
-
|
|
1834
|
+
} else {
|
|
1835
|
+
h += `<table class="table table-sm ips-table ips-pf-rule" data-part="${kind}"><tr><th style="width: 35%">Expectation</th><th>Rule</th>${actionsHead}</tr>`;
|
|
1250
1836
|
for (const c of these) {
|
|
1251
|
-
h += `<tr><td>${escape(c.human || '')}</td><td>${
|
|
1837
|
+
h += `<tr${rowAttrs(c)}><td>${escape(c.human || '')}</td><td><code>${escape(c.expression || '')}</code></td>${actions(c)}</tr>`;
|
|
1252
1838
|
}
|
|
1253
|
-
h += '</table>';
|
|
1254
1839
|
}
|
|
1840
|
+
h += '</table>';
|
|
1841
|
+
}
|
|
1842
|
+
if (constraints.length === 0 && !admin) {
|
|
1843
|
+
h += '<p>This profile has no constraints yet.</p>';
|
|
1255
1844
|
}
|
|
1256
1845
|
h += '<p class="ips-small">Each rule is checked against the document (the Bundle). <code>section(system, code, min, max)</code> finds the sections of the document\'s Composition ' +
|
|
1257
1846
|
'(nested ones too) with that code, when there are min..max of them; <code>entry(system, code, min, max)</code> finds a section\'s entries with that code ' +
|
|
1258
1847
|
'(their <code>code</code>, <code>medicationCodeableConcept</code> or <code>vaccineCode</code>), when there are min..max of them; ' +
|
|
1259
1848
|
'<code>resources(system, code, type)</code> finds the resources in the document with that code (of that type, when one is given); ' +
|
|
1260
|
-
'<code>entryIn(valueSet, min, max)</code> and <code>resourcesIn(valueSet, type)</code> are the same with a code in a value set, asked of the terminology server
|
|
1849
|
+
'<code>entryIn(valueSet, min, max)</code> and <code>resourcesIn(valueSet, type)</code> are the same with a code in a value set, asked of the terminology server. ' +
|
|
1850
|
+
'A cardinality <code>[m..n]</code> after a section or an entry is how many there may be; with none, there must be at least one.</p>';
|
|
1851
|
+
if (admin) {
|
|
1852
|
+
const cfg = {
|
|
1853
|
+
mode: 'rows', profile: { id: sd.id, title: sd.title, constraints: editor.constraints },
|
|
1854
|
+
sections: editor.sections, systems: editor.systems, resourceTypes: editor.resourceTypes, sectionSystem: editor.sectionSystem, csrf: ctx.csrf || '',
|
|
1855
|
+
parts: PROFILE_PARTS,
|
|
1856
|
+
urls: {
|
|
1857
|
+
constraint: `${ctx.base}/StructureDefinition/${sd.id}/constraint`,
|
|
1858
|
+
lookup: editor.canLookup ? `${ctx.base}/lookup` : null,
|
|
1859
|
+
lookupVs: `${ctx.base}/lookup-vs`
|
|
1860
|
+
},
|
|
1861
|
+
// the code systems offered for a resource's category, first the default
|
|
1862
|
+
categorySystem: CATEGORY_SYSTEMS[0].url,
|
|
1863
|
+
categorySystems: CATEGORY_SYSTEMS
|
|
1864
|
+
};
|
|
1865
|
+
h += '<div id="ips-profile-editor"></div>';
|
|
1866
|
+
h += `<script>window.IPS_PROFILE = ${JSON.stringify(cfg).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')};</script>`;
|
|
1867
|
+
h += `<script src="${escape(scripts.src(ctx.base, 'profile-editor.js'))}"></script>`;
|
|
1868
|
+
}
|
|
1261
1869
|
return h;
|
|
1262
1870
|
}
|
|
1263
1871
|
|
|
@@ -1286,7 +1894,7 @@ function renderProfileEditor(sd, ctx, options) {
|
|
|
1286
1894
|
}
|
|
1287
1895
|
};
|
|
1288
1896
|
h += `<script>window.IPS_PROFILE = ${JSON.stringify(cfg).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')};</script>`;
|
|
1289
|
-
h += `<script src="${escape(base
|
|
1897
|
+
h += `<script src="${escape(scripts.src(base, 'profile-editor.js'))}"></script>`;
|
|
1290
1898
|
return h;
|
|
1291
1899
|
}
|
|
1292
1900
|
|
|
@@ -1356,18 +1964,40 @@ function sharePanelScript() {
|
|
|
1356
1964
|
</script>`;
|
|
1357
1965
|
}
|
|
1358
1966
|
|
|
1359
|
-
// the views of a document
|
|
1967
|
+
// the views of a document, as tabs
|
|
1360
1968
|
const VIEWS = [
|
|
1361
1969
|
['narrative', 'Narrative'],
|
|
1970
|
+
['structure', 'Structure'],
|
|
1971
|
+
['analysis', 'Analysis'],
|
|
1362
1972
|
['translation', 'Translation'],
|
|
1363
|
-
['generated', 'Generated Narrative'],
|
|
1364
|
-
['validation', 'Validation'],
|
|
1365
1973
|
['terminology', 'Terminology'],
|
|
1974
|
+
['validation', 'Validation'],
|
|
1975
|
+
['national', 'National Profiles'],
|
|
1366
1976
|
['profiles', 'Clinical Profiles'],
|
|
1367
1977
|
['ai', 'AI Assessment'],
|
|
1368
|
-
['
|
|
1978
|
+
['source', 'Source']
|
|
1369
1979
|
];
|
|
1370
1980
|
|
|
1981
|
+
// views that were tabs once, so old links still land: generated is now the narrative
|
|
1982
|
+
// tab's switch, json is now source
|
|
1983
|
+
const VIEW_ALIASES = {
|
|
1984
|
+
generated: { view: 'narrative', narrative: 'generated' },
|
|
1985
|
+
json: { view: 'source' }
|
|
1986
|
+
};
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* The view a request asks for (?view=, and ?narrative= for the narrative tab's switch):
|
|
1990
|
+
* the default when it names none we know.
|
|
1991
|
+
* @returns {{view: string, narrative: string}} narrative is 'provided' or 'generated'
|
|
1992
|
+
*/
|
|
1993
|
+
function viewOf(query = {}) {
|
|
1994
|
+
const v = typeof query.view === 'string' ? query.view : '';
|
|
1995
|
+
const alias = VIEW_ALIASES[v];
|
|
1996
|
+
const view = alias ? alias.view : VIEWS.some(([k]) => k === v) ? v : VIEWS[0][0];
|
|
1997
|
+
const narrative = alias?.narrative || (query.narrative === 'generated' ? 'generated' : 'provided');
|
|
1998
|
+
return { view, narrative };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
1371
2001
|
/**
|
|
1372
2002
|
* A document.
|
|
1373
2003
|
* @param {Object} project
|
|
@@ -1400,16 +2030,36 @@ function stepState(row, step) {
|
|
|
1400
2030
|
return { state: 'waiting', message: null };
|
|
1401
2031
|
}
|
|
1402
2032
|
|
|
1403
|
-
|
|
1404
|
-
|
|
2033
|
+
// each step as the pipeline shows it, and the view that shows its work (as a query)
|
|
2034
|
+
const STEP_LABELS = {
|
|
2035
|
+
nationality: ['Nationality', 'view=analysis'],
|
|
2036
|
+
analysis: ['Analysis', 'view=analysis'],
|
|
2037
|
+
translate: ['Translate', 'view=translation'],
|
|
2038
|
+
narrative: ['Narrative', 'view=narrative&narrative=generated'],
|
|
2039
|
+
validation: ['Validation', 'view=validation'],
|
|
2040
|
+
national: ['National Profiles', 'view=national'],
|
|
2041
|
+
terminology: ['Terminology', 'view=terminology'],
|
|
2042
|
+
profiles: ['Clinical Profiles', 'view=profiles'],
|
|
2043
|
+
ai: ['AI Assessment', 'view=ai']
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* The processing pipeline as one connected line: the document's receipt, then each step
|
|
2048
|
+
* in the order they run, each coloured by where it is and linked to the view of its work.
|
|
2049
|
+
*/
|
|
2050
|
+
function stepsHtml(row, ctx, durl) {
|
|
2051
|
+
let h = `<span class="ips-step ips-step-received" title="${escape(`received ${row.received} (${row.source})`)}">Received</span>`;
|
|
1405
2052
|
for (const step of ctx.steps || []) {
|
|
1406
2053
|
const { state, message } = stepState(row, step);
|
|
1407
2054
|
const [, symbol, word] = STEP_STATES[state];
|
|
1408
2055
|
// a failure's reason can name the inside of the server: for managers
|
|
1409
2056
|
const detail = message && (state !== 'failed' || ctx.canManage) ? `: ${message}` : '';
|
|
1410
|
-
|
|
2057
|
+
const [label, query] = STEP_LABELS[step] || [step, null];
|
|
2058
|
+
const attrs = `class="ips-step ips-step-${state}" title="${escape(`${step} - ${word}${detail}`)}"`;
|
|
2059
|
+
const inner = `<span class="ips-step-icon">${symbol}</span> ${escape(label)}`;
|
|
2060
|
+
h += query && durl ? `<a ${attrs} href="${escape(`${durl}?${query}`)}">${inner}</a>` : `<span ${attrs}>${inner}</span>`;
|
|
1411
2061
|
}
|
|
1412
|
-
return h
|
|
2062
|
+
return `<div class="ips-pipeline">${h}</div>`;
|
|
1413
2063
|
}
|
|
1414
2064
|
|
|
1415
2065
|
function renderDocument(project, row, view, ctx) {
|
|
@@ -1417,8 +2067,8 @@ function renderDocument(project, row, view, ctx) {
|
|
|
1417
2067
|
const durl = `${purl}/Bundle/${row.id}`;
|
|
1418
2068
|
let h = STYLE + userBar(ctx) + crumbs(ctx, project, `Document ${row.id}`);
|
|
1419
2069
|
|
|
1420
|
-
// the banner: who and what
|
|
1421
|
-
h += '<div class="ips-banner">';
|
|
2070
|
+
// the banner: where from (a flag), who and what
|
|
2071
|
+
h += '<div class="ips-banner ips-banner-flagged">' + nationalityFlag(row.results?.nationality?.result) + '<div>';
|
|
1422
2072
|
h += `<div class="ips-patient"><b>${escape(row.patient_name || '(no name)')}</b>` +
|
|
1423
2073
|
(row.patient_gender ? `, ${escape(row.patient_gender)}` : '') +
|
|
1424
2074
|
(row.patient_birth ? `, born ${escape(row.patient_birth)}` : '') + '</div>';
|
|
@@ -1426,13 +2076,14 @@ function renderDocument(project, row, view, ctx) {
|
|
|
1426
2076
|
(row.comp_date ? `Document date ${escape(row.comp_date)} | ` : '') +
|
|
1427
2077
|
`Received ${escape(row.received)} (${escape(row.source)}) | ${row.size.toLocaleString()} bytes` +
|
|
1428
2078
|
` | <a href="${durl}?_format=json">FHIR JSON</a> | <a href="${durl}?download=1">Download</a></div>`;
|
|
1429
|
-
h += '</div>';
|
|
2079
|
+
h += '</div></div>';
|
|
1430
2080
|
|
|
1431
|
-
// the steps,
|
|
1432
|
-
h += '<div class="ips-steps">' + stepsHtml(row, ctx);
|
|
2081
|
+
// the steps, then (on a line of their own) what can be done
|
|
2082
|
+
h += '<div class="ips-steps">' + stepsHtml(row, ctx, durl) + '</div>';
|
|
1433
2083
|
{
|
|
1434
|
-
h += '<
|
|
1435
|
-
|
|
2084
|
+
h += '<div class="ips-doc-actions">';
|
|
2085
|
+
// a form like the others, so it looks like them
|
|
2086
|
+
h += `<form method="get" action="${purl}/compose" class="ips-inline"><input type="hidden" name="from" value="${row.id}"/><button type="submit" class="btn btn-default" title="Open this document in the editor; submitting makes a new document">Edit</button></form>`;
|
|
1436
2087
|
if (ctx.canShare) {
|
|
1437
2088
|
// sharing as a QR code: a form each (they work without scripts); with scripts, the
|
|
1438
2089
|
// page asks for JSON and shows the code in a panel over the page
|
|
@@ -1443,9 +2094,8 @@ function renderDocument(project, row, view, ctx) {
|
|
|
1443
2094
|
h += `<form method="post" action="${durl}/requeue" class="ips-inline">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-default">Process again</button></form>` +
|
|
1444
2095
|
`<form method="post" action="${durl}/delete" class="ips-inline" data-confirm="${escape(`Delete document ${row.id}?`)}">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-danger">Delete</button></form>`;
|
|
1445
2096
|
}
|
|
1446
|
-
h += '</
|
|
2097
|
+
h += '</div>';
|
|
1447
2098
|
}
|
|
1448
|
-
h += '</div>';
|
|
1449
2099
|
if (ctx.canShare) {
|
|
1450
2100
|
h += sharePanelScript();
|
|
1451
2101
|
}
|
|
@@ -1454,29 +2104,39 @@ function renderDocument(project, row, view, ctx) {
|
|
|
1454
2104
|
h += '<ul class="nav nav-tabs ips-nav">' + VIEWS.map(([v, label]) =>
|
|
1455
2105
|
`<li${v === view ? ' class="active"' : ''}><a href="${durl}?view=${v}">${label}</a></li>`).join('') + '</ul>';
|
|
1456
2106
|
|
|
1457
|
-
if (view === '
|
|
2107
|
+
if (view === 'structure') {
|
|
2108
|
+
h += renderStructure(row.bundle);
|
|
2109
|
+
} else if (view === 'analysis') {
|
|
2110
|
+
h += renderAnalysis(row, { canManage: ctx.canManage });
|
|
2111
|
+
} else if (view === 'source') {
|
|
1458
2112
|
h += `<pre class="ips-json">${escape(JSON.stringify(row.bundle, null, 2))}</pre>`;
|
|
1459
2113
|
} else if (view === 'terminology') {
|
|
1460
2114
|
// the analysed copy carries the checks; the concepts are the same
|
|
1461
2115
|
h += renderTerminology(row.analysed || row.bundle, { ...ctx, analysed: !!row.analysed, docUrl: durl });
|
|
1462
2116
|
} else if (view === 'translation') {
|
|
1463
2117
|
h += renderTranslation(row, { canManage: ctx.canManage });
|
|
1464
|
-
} else if (view === 'generated') {
|
|
1465
|
-
h += renderGenerated(row, { canManage: ctx.canManage });
|
|
1466
2118
|
} else if (view === 'profiles') {
|
|
1467
2119
|
h += renderProfileChecks(row, { base: ctx.base, canManage: ctx.canManage });
|
|
1468
2120
|
} else if (view === 'ai') {
|
|
1469
2121
|
h += renderAi(row, { canManage: ctx.canManage });
|
|
1470
2122
|
} else if (view === 'validation') {
|
|
1471
2123
|
h += renderValidation(row, { level: ctx.level, docUrl: durl, canManage: ctx.canManage });
|
|
2124
|
+
} else if (view === 'national') {
|
|
2125
|
+
h += renderNational(row, { np: ctx.np, level: ctx.level, docUrl: durl, base: ctx.base, canManage: ctx.canManage });
|
|
1472
2126
|
} else {
|
|
1473
|
-
|
|
2127
|
+
// the narrative: as provided in the document, or as this server generates it
|
|
2128
|
+
const generated = ctx.narrative === 'generated';
|
|
2129
|
+
h += '<div class="ips-switch">' +
|
|
2130
|
+
`<a href="${durl}?view=narrative"${generated ? '' : ' class="active"'} title="The narrative in the document as it was submitted">Provided</a>` +
|
|
2131
|
+
`<a href="${durl}?view=narrative&narrative=generated"${generated ? ' class="active"' : ''} title="The narrative this server generates for the document">Generated</a></div>`;
|
|
2132
|
+
h += generated ? renderGenerated(row, { canManage: ctx.canManage }) : assembledNarrative(row.bundle);
|
|
1474
2133
|
}
|
|
1475
2134
|
return h;
|
|
1476
2135
|
}
|
|
1477
2136
|
|
|
1478
2137
|
module.exports = {
|
|
2138
|
+
analysisOk,
|
|
1479
2139
|
renderHome, renderLogin, renderProjects, renderUsers, renderProject, renderSubmit, renderSettings, renderDocument,
|
|
1480
|
-
assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderShare, renderCompose, renderProfileChecks,
|
|
1481
|
-
renderProfiles, renderProfile, renderProfileEditor, VIEWS
|
|
2140
|
+
assembledNarrative, renderStructure, renderAnalysis, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderNational, renderShare, renderCompose, renderProfileChecks,
|
|
2141
|
+
renderProfiles, renderProfile, renderProfileEditor, renderNationalProfiles, VIEWS, viewOf
|
|
1482
2142
|
};
|