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.
Files changed (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
package/ips/render.js CHANGED
@@ -27,7 +27,22 @@ const profiles = require('./profiles');
27
27
  const { terminologyOf, systemName, describeVersion, linkForSystem, linkForCode, isExternalSystem } = require('./terminology');
28
28
  const { issueSummary, checkOf, translationOf } = require('./pipeline');
29
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
+ }
30
44
  const { dateText } = require('./presenter');
45
+ const { structureTree, flatten } = require('./structure');
31
46
  const scripts = require('./scripts');
32
47
  const { LOINC } = require('./editor-schema');
33
48
 
@@ -45,6 +60,8 @@ const STYLE = `
45
60
  .ips-table td, .ips-table th { vertical-align: top; }
46
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; }
47
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; }
48
65
  .ips-banner .ips-patient { font-size: 120%; }
49
66
  .ips-banner .ips-project-name { font-size: 130%; }
50
67
  .ips-actions-line { margin-bottom: 10px; }
@@ -55,13 +72,27 @@ const STYLE = `
55
72
  .ips-banner .ips-facts { font-size: 90%; color: #444; margin-top: 4px; }
56
73
  .ips-banner .ips-facts a { margin-left: 4px; }
57
74
  .ips-steps { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-bottom: 10px; font-size: 90%; }
58
- .ips-step { display: inline-block; padding: 2px 10px; border-radius: 12px; color: white; white-space: nowrap; cursor: default; }
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; }
59
82
  .ips-step-done { background: #1e7e34; }
60
83
  .ips-step-failed { background: #c82333; }
61
84
  .ips-step-running { background: #0d6efd; }
62
85
  .ips-step-waiting { background: #6c757d; }
63
- .ips-steps .ips-actions { margin-left: auto; display: flex; gap: 6px; }
64
- .ips-steps .ips-actions .btn { font-size: 90%; padding: 2px 10px; }
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; }
65
96
  .ips-nav.nav-tabs { margin-bottom: 14px; }
66
97
  .ips-nav.nav-tabs > li > a { padding: 6px 14px; }
67
98
  .ips-explain { margin-top: 18px; padding-top: 8px; border-top: 1px solid #dee2e6; }
@@ -72,8 +103,9 @@ const STYLE = `
72
103
  .ips-filter .ips-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
73
104
  .ips-date { white-space: nowrap; }
74
105
  .ips-narrative { border: 1px solid #dee2e6; padding: 12px; background: #fff; }
75
- .ips-narrative h3 { font-size: 110%; margin-top: 18px; border-bottom: 1px solid #eee; }
76
- .ips-narrative h4 { font-size: 100%; margin-top: 12px; }
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; }
77
109
  .ips-narrative table { border-collapse: collapse; margin-bottom: 8px; }
78
110
  .ips-narrative td, .ips-narrative th { border: 1px solid #ddd; padding: 2px 6px; font-size: 90%; }
79
111
  .ips-meta { font-size: 85%; color: #555; margin-bottom: 10px; }
@@ -178,10 +210,39 @@ const STYLE = `
178
210
  .ips-translated { font-size: 80%; color: #fff; background: #6f42c1; border-radius: 3px; padding: 0 5px; cursor: help; }
179
211
  .ips-check { font-size: 130%; font-weight: bold; cursor: help; }
180
212
  .ips-check-in { color: #1e7e34; }
181
- .ips-check-international { color: #fd7e14; }
213
+ .ips-check-international { color: #a35200; }
182
214
  .ips-check-not-in { color: #c82333; }
183
215
  .ips-check-unknown { color: #6c757d; }
184
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; }
185
246
  </style>
186
247
  <script>
187
248
  // a form with data-confirm asks first: the message is an attribute (escaped as one), never script
@@ -416,7 +477,7 @@ function projectNav(project, ctx) {
416
477
  /** The documentation at the foot of a project's page: how to reach it as a FHIR endpoint. */
417
478
  function projectDocs(project, ctx) {
418
479
  const purl = projectUrl(ctx, project);
419
- return '<div class="ips-explain ips-docs"><h4>Documentation</h4>' +
480
+ return '<div class="ips-explain ips-docs"><h3>Documentation</h3>' +
420
481
  `<p>This project is a FHIR endpoint: <code>${purl}</code> (<a href="${purl}/metadata">CapabilityStatement</a>). ` +
421
482
  'Documents are IPS Bundles (<code>type: document</code>, a Composition first, with the Patient it refers to) in JSON.</p>' +
422
483
  '<table class="table table-sm ips-table ips-api"><tr><th>Request</th><th></th></tr>' +
@@ -473,7 +534,7 @@ function renderProject(project, query, results, search, ctx, problems) {
473
534
  const columns = [
474
535
  ['_id', 'Id'], ['composition.patient.name', 'Patient'], ['composition.patient.gender', 'Gender'],
475
536
  ['composition.patient.birthdate', 'Born'], ['composition.date', 'Date'], ['composition.title', 'Title'],
476
- ['processing-status', 'Status'], ['_lastUpdated', 'Received']
537
+ ['processing-status', 'Status'], ['_lastUpdated', 'Received'], [null, 'Nation']
477
538
  ];
478
539
  if (ctx.canManage) {
479
540
  columns.push([null, '']);
@@ -505,7 +566,8 @@ function renderProject(project, query, results, search, ctx, problems) {
505
566
  `<td class="ips-date" title="${escape(r.comp_date || '')}">${escape(dateOnly(r.comp_date))}</td>` +
506
567
  `<td>${escape(r.comp_title || '')}</td>` +
507
568
  `<td title="${escape(ctx.canManage ? r.status_message || '' : '')}">${badge(r.status)}</td>` +
508
- `<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>`;
509
571
  if (ctx.canManage) {
510
572
  h += `<td><form method="post" action="${durl}/delete" class="ips-inline" data-confirm="${escape(`Delete document ${r.id}?`)}">${csrfField(ctx.csrf)}` +
511
573
  '<button type="submit" class="btn btn-link" style="padding: 0; font-size: 85%">delete</button></form></td>';
@@ -641,7 +703,7 @@ function aiSettingsHtml(project, ctx, options) {
641
703
  * a live element. Serialising closes what was left open, and each narrative is wrapped in
642
704
  * its own div.
643
705
  */
644
- function narrativeOf(res) {
706
+ function narrativeOf(res, level) {
645
707
  const div = res?.text?.div;
646
708
  if (typeof div !== 'string' || div.trim() === '') {
647
709
  return null;
@@ -649,18 +711,54 @@ function narrativeOf(res) {
649
711
  if (checkNarrative(div)) {
650
712
  return '<p class="ips-small"><i>(narrative not shown: it contains active content)</i></p>';
651
713
  }
652
- 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>`;
653
715
  }
654
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;
749
+ }
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)
655
753
  function sectionsHtml(sections, level) {
656
754
  let h = '';
657
755
  for (const s of Array.isArray(sections) ? sections : []) {
658
756
  if (!isObject(s)) {
659
757
  continue;
660
758
  }
661
- const tag = level <= 4 ? `h${level}` : 'h5';
759
+ const tag = `h${Math.min(level, 6)}`;
662
760
  h += `<${tag}>${escape(text(s.title) || text(s.code?.text) || '')}</${tag}>`;
663
- const n = narrativeOf(s);
761
+ const n = narrativeOf(s, Math.min(level + 1, 6));
664
762
  if (n) {
665
763
  h += n;
666
764
  } else if (!s.section) {
@@ -682,13 +780,221 @@ function assembledNarrative(bundle) {
682
780
  }
683
781
  let h = '<div class="ips-narrative">';
684
782
  if (text(comp.title)) {
685
- h += `<h2>${escape(comp.title)}</h2>`;
783
+ h += `<h3>${escape(comp.title)}</h3>`;
686
784
  }
687
- h += narrativeOf(comp) || '';
688
- h += sectionsHtml(comp.section, 3);
785
+ h += narrativeOf(comp, 4) || '';
786
+ h += sectionsHtml(comp.section, 4);
689
787
  return h + '</div>';
690
788
  }
691
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
+
692
998
  // ---- the terminology view ----
693
999
 
694
1000
  /** Text, as a link when there is somewhere to go. */
@@ -862,7 +1168,8 @@ document.querySelectorAll('.ips-copy').forEach(function (b) {
862
1168
 
863
1169
  // ---- the validation view ----
864
1170
 
865
- const SEVERITY_COLOURS = { fatal: '#c82333', error: '#c82333', warning: '#d39e00', information: '#17a2b8' };
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' };
866
1173
 
867
1174
  /** Where an issue is: its expression (FHIRPath), else its location. */
868
1175
  function issueWhere(i) {
@@ -908,7 +1215,7 @@ function noResultNote(row, step, what, options = {}) {
908
1215
  const PROFILE_OUTCOMES = {
909
1216
  conforms: ['Conforms', '#1e7e34'],
910
1217
  fails: ['Does not conform', '#c82333'],
911
- unknown: ['Could not be fully checked', '#d39e00']
1218
+ unknown: ['Could not be fully checked', '#8a6100']
912
1219
  };
913
1220
 
914
1221
  /**
@@ -951,13 +1258,16 @@ const TRANSLATION_OUTCOMES = {
951
1258
  'no-translation': ['No translation', '#c82333'],
952
1259
  failed: ['Failed', '#c82333'],
953
1260
  'not-needed': ['Not needed', '#6c757d'],
954
- 'not-checked': ['Not checked', '#fd7e14'],
1261
+ 'not-checked': ['Not checked', '#a35200'],
955
1262
  'no-codings': ['No codings', '#6c757d']
956
1263
  };
957
1264
 
958
1265
  /** A coding, briefly: system|code "display". */
959
1266
  function briefCoding(c) {
960
- return `<code>${escape(`${systemName(c.system) || c.system || ''}|${c.code}`)}</code>` + (typeof c.display === 'string' && c.display ? ` "${escape(c.display)}"` : '');
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)}"` : '');
961
1271
  }
962
1272
 
963
1273
  /**
@@ -977,6 +1287,11 @@ function renderTranslation(row, options = {}) {
977
1287
  (typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') + '</p>';
978
1288
  h += `<p><b>${escape(`${counts.translated || 0} of ${counts.tried || 0} concepts translated (${counts.codings || 0} codings added)`)}` +
979
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
+ }
980
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>';
981
1296
  for (const e of v.entries) {
982
1297
  const [label, colour] = TRANSLATION_OUTCOMES[e.outcome] || [e.outcome || '?', '#6c757d'];
@@ -1016,7 +1331,8 @@ function renderGenerated(row, options = {}) {
1016
1331
  if (checkNarrative(v.html)) {
1017
1332
  return h + '<p><i>The generated narrative is not shown: it failed the active content check.</i></p>';
1018
1333
  }
1019
- h += `<div class="ips-narrative ips-generated">${parse5.serialize(parse5.parseFragment(v.html))}</div>`;
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>`;
1020
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, ' +
1021
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. ' +
1022
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>';
@@ -1026,8 +1342,8 @@ function renderGenerated(row, options = {}) {
1026
1342
  const tokens = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '?');
1027
1343
  const usd = (n) => (n < 0.01 && n > 0 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`);
1028
1344
 
1029
- const AI_SEVERITY_COLOURS = { high: '#c82333', medium: '#d39e00', low: '#17a2b8' };
1030
- const GRADE_COLOURS = { A: '#1e7e34', B: '#5cb85c', C: '#d39e00', D: '#fd7e14', F: '#c82333' };
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' };
1031
1347
 
1032
1348
  /**
1033
1349
  * The AI assessment view: the grade, the summary, and the findings.
@@ -1058,7 +1374,7 @@ function renderAi(row, options = {}) {
1058
1374
  }
1059
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>`;
1060
1376
  const sev = (x) => `<span class="ips-badge" style="background-color: ${AI_SEVERITY_COLOURS[x] || '#6c757d'}">${escape(x)}</span>`;
1061
- h += '<h4>Consistency and integrity</h4>';
1377
+ h += '<h3>Consistency and integrity</h3>';
1062
1378
  if (a.consistency.length === 0) {
1063
1379
  h += '<p><i>No findings</i></p>';
1064
1380
  } else {
@@ -1074,15 +1390,7 @@ function renderAi(row, options = {}) {
1074
1390
  h += '<table class="table table-sm table-striped ips-table"><tr><th>Severity</th><th>Finding</th><th>Codes</th></tr>' +
1075
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>';
1076
1392
  }
1077
- h += '<details class="ips-oo"><summary><button type="button" class="btn btn-default ips-btn ips-oo-toggle">Show the model\'s answer</button></summary>' +
1078
- `<pre class="ips-json">${escape(String(v.raw || ''))}</pre></details>`;
1079
- h += `<script>
1080
- document.querySelectorAll('.ips-oo-toggle').forEach(function (b) {
1081
- var d = b.closest('details');
1082
- b.addEventListener('click', function (e) { e.preventDefault(); d.open = !d.open; });
1083
- d.addEventListener('toggle', function () { b.textContent = (d.open ? 'Hide' : 'Show') + " the model's answer"; });
1084
- });
1085
- </script>`;
1393
+ h += showHide("the model's answer", `<pre class="ips-json">${escape(String(v.raw || ''))}</pre>`);
1086
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 - ' +
1087
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>';
1088
1396
  return h;
@@ -1109,8 +1417,9 @@ function renderValidation(row, options = {}) {
1109
1417
  * each national profile's page show it.
1110
1418
  * @param {string} made - when the validation was done
1111
1419
  * @param {{counts?: Object, outcome?: Object, profiles?: string[], validatorVersion?: string, ms?: number}} v
1112
- * @param {{level?: string, docUrl?: string, hidden?: Object}} options - hidden: the fields the
1113
- * level form keeps (the view, and which national profile)
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)
1114
1423
  */
1115
1424
  function validationOutcome(made, v, options = {}) {
1116
1425
  const counts = v.counts || {};
@@ -1123,7 +1432,8 @@ function validationOutcome(made, v, options = {}) {
1123
1432
  if (all.length === 0) {
1124
1433
  return h;
1125
1434
  }
1126
- const level = LEVELS.some(([l]) => l === options.level) ? options.level : 'information';
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');
1127
1437
  const min = SEVERITY_RANK[level];
1128
1438
  const issues = all.filter(i => (SEVERITY_RANK[text(i.severity)] ?? 3) >= min);
1129
1439
  // the minimum level: a form that submits itself on change (and has a button without script)
@@ -1147,24 +1457,34 @@ function validationOutcome(made, v, options = {}) {
1147
1457
  `<td>${escape(issueMessage(i))}</td></tr>`;
1148
1458
  }
1149
1459
  h += '</table>';
1150
- // the raw OperationOutcome, behind a button (a <details> for a browser without scripts)
1151
- h += '<details class="ips-oo"><summary><button type="button" class="btn btn-default ips-btn ips-oo-toggle">Show the OperationOutcome</button></summary>' +
1152
- `<pre class="ips-json">${escape(JSON.stringify(v.outcome, null, 2))}</pre></details>`;
1153
- h += `<script>
1154
- document.querySelectorAll('.ips-oo-toggle').forEach(function (b) {
1155
- var d = b.closest('details');
1156
- b.addEventListener('click', function (e) { e.preventDefault(); d.open = !d.open; });
1157
- d.addEventListener('toggle', function () { b.textContent = (d.open ? 'Hide' : 'Show') + ' the OperationOutcome'; });
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'); });
1158
1479
  });
1159
1480
  </script>`;
1160
- return h;
1161
1481
  }
1162
1482
 
1163
1483
  // a national profile's outcome on the page: [label, colour]
1164
1484
  const NATIONAL_OUTCOMES = {
1165
1485
  pass: ['Passed', '#1e7e34'],
1166
1486
  fail: ['Failed', '#c82333'],
1167
- unknown: ['Not checked', '#d39e00']
1487
+ unknown: ['Not checked', '#8a6100']
1168
1488
  };
1169
1489
 
1170
1490
  /**
@@ -1196,7 +1516,8 @@ function renderNational(row, options = {}) {
1196
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>`;
1197
1517
  }
1198
1518
  return h + validationOutcome(r.made, { counts: p.counts, outcome: p.outcome, profiles: [p.url], validatorVersion: v.validatorVersion, ms: p.ms },
1199
- { ...options, hidden: { view: 'national', np: String(index) } });
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) } });
1200
1521
  }
1201
1522
  let h = '<table class="table table-sm ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Outcome</th><th>Issues</th></tr>';
1202
1523
  v.profiles.forEach((np, i) => {
@@ -1245,9 +1566,27 @@ function jurisdictionHtml(code) {
1245
1566
  return `<code>${escape(code)}</code>` + (j ? ` ${escape(j.display)}` : '');
1246
1567
  }
1247
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
+
1248
1587
  /**
1249
- * The national profiles: for each, the national IG's package (id and version) and the url of
1250
- * its profile on Bundle. Everyone sees the list; the administrator adds, edits and deletes.
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.
1251
1590
  * @param {Array} list - from store.nationalProfiles()
1252
1591
  */
1253
1592
  function renderNationalProfiles(list, ctx, message) {
@@ -1256,7 +1595,7 @@ function renderNationalProfiles(list, ctx, message) {
1256
1595
  let h = STYLE + userBar(ctx) + `<p class="ips-small"><a href="${base}">IPS projects</a> &raquo; National profiles</p>` + alert(message);
1257
1596
  h += '<div class="ips-banner"><b class="ips-project-name">National profiles</b></div>';
1258
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), ' +
1259
- 'and the canonical URL of its profile on Bundle. They apply across all projects.' + (isAdmin ? '' : ' Only the administrator can change them.') + '</p>';
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>';
1260
1599
  if (list.length === 0) {
1261
1600
  h += '<p>There are no national profiles yet.</p>';
1262
1601
  }
@@ -1264,9 +1603,9 @@ function renderNationalProfiles(list, ctx, message) {
1264
1603
  if (list.length === 0) {
1265
1604
  return h;
1266
1605
  }
1267
- 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></tr>';
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>';
1268
1607
  for (const np of list) {
1269
- 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></tr>`;
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>`;
1270
1609
  }
1271
1610
  return h + '</table>';
1272
1611
  }
@@ -1276,9 +1615,10 @@ function renderNationalProfiles(list, ctx, message) {
1276
1615
  (np && np.jurisdiction && jurisdiction(np.jurisdiction) ? `<span class="ips-small">${escape(jurisdiction(np.jurisdiction).display)}</span>` : '') + '</td>' +
1277
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>` +
1278
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>` +
1279
- `<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>`;
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>`;
1280
1620
  // the package id and version are short; the url takes the rest of the row
1281
- 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></th></tr>';
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>';
1282
1622
  for (const np of list) {
1283
1623
  const form = `np-${np.id}`;
1284
1624
  const action = `${base}/national-profiles/${np.id}`;
@@ -1624,19 +1964,40 @@ function sharePanelScript() {
1624
1964
  </script>`;
1625
1965
  }
1626
1966
 
1627
- // the views of a document; more come later
1967
+ // the views of a document, as tabs
1628
1968
  const VIEWS = [
1629
1969
  ['narrative', 'Narrative'],
1970
+ ['structure', 'Structure'],
1971
+ ['analysis', 'Analysis'],
1630
1972
  ['translation', 'Translation'],
1631
- ['generated', 'Generated Narrative'],
1973
+ ['terminology', 'Terminology'],
1632
1974
  ['validation', 'Validation'],
1633
1975
  ['national', 'National Profiles'],
1634
- ['terminology', 'Terminology'],
1635
1976
  ['profiles', 'Clinical Profiles'],
1636
1977
  ['ai', 'AI Assessment'],
1637
- ['json', 'JSON']
1978
+ ['source', 'Source']
1638
1979
  ];
1639
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
+
1640
2001
  /**
1641
2002
  * A document.
1642
2003
  * @param {Object} project
@@ -1669,16 +2030,36 @@ function stepState(row, step) {
1669
2030
  return { state: 'waiting', message: null };
1670
2031
  }
1671
2032
 
1672
- function stepsHtml(row, ctx) {
1673
- let h = '';
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>`;
1674
2052
  for (const step of ctx.steps || []) {
1675
2053
  const { state, message } = stepState(row, step);
1676
2054
  const [, symbol, word] = STEP_STATES[state];
1677
2055
  // a failure's reason can name the inside of the server: for managers
1678
2056
  const detail = message && (state !== 'failed' || ctx.canManage) ? `: ${message}` : '';
1679
- h += `<span class="ips-step ips-step-${state}" title="${escape(`${step} - ${word}${detail}`)}">${symbol} ${escape(step)}</span>`;
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>`;
1680
2061
  }
1681
- return h;
2062
+ return `<div class="ips-pipeline">${h}</div>`;
1682
2063
  }
1683
2064
 
1684
2065
  function renderDocument(project, row, view, ctx) {
@@ -1686,8 +2067,8 @@ function renderDocument(project, row, view, ctx) {
1686
2067
  const durl = `${purl}/Bundle/${row.id}`;
1687
2068
  let h = STYLE + userBar(ctx) + crumbs(ctx, project, `Document ${row.id}`);
1688
2069
 
1689
- // the banner: who and what
1690
- 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>';
1691
2072
  h += `<div class="ips-patient"><b>${escape(row.patient_name || '(no name)')}</b>` +
1692
2073
  (row.patient_gender ? `, ${escape(row.patient_gender)}` : '') +
1693
2074
  (row.patient_birth ? `, born ${escape(row.patient_birth)}` : '') + '</div>';
@@ -1695,13 +2076,14 @@ function renderDocument(project, row, view, ctx) {
1695
2076
  (row.comp_date ? `Document date ${escape(row.comp_date)} &nbsp;|&nbsp; ` : '') +
1696
2077
  `Received ${escape(row.received)} (${escape(row.source)}) &nbsp;|&nbsp; ${row.size.toLocaleString()} bytes` +
1697
2078
  ` &nbsp;|&nbsp; <a href="${durl}?_format=json">FHIR JSON</a> &nbsp;|&nbsp; <a href="${durl}?download=1">Download</a></div>`;
1698
- h += '</div>';
2079
+ h += '</div></div>';
1699
2080
 
1700
- // the steps, and what can be done
1701
- 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>';
1702
2083
  {
1703
- h += '<span class="ips-actions">';
1704
- h += `<a class="btn btn-default" href="${purl}/compose?from=${row.id}" title="Open this document in the editor; submitting makes a new document">Edit</a>`;
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>`;
1705
2087
  if (ctx.canShare) {
1706
2088
  // sharing as a QR code: a form each (they work without scripts); with scripts, the
1707
2089
  // page asks for JSON and shows the code in a panel over the page
@@ -1712,9 +2094,8 @@ function renderDocument(project, row, view, ctx) {
1712
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>` +
1713
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>`;
1714
2096
  }
1715
- h += '</span>';
2097
+ h += '</div>';
1716
2098
  }
1717
- h += '</div>';
1718
2099
  if (ctx.canShare) {
1719
2100
  h += sharePanelScript();
1720
2101
  }
@@ -1723,15 +2104,17 @@ function renderDocument(project, row, view, ctx) {
1723
2104
  h += '<ul class="nav nav-tabs ips-nav">' + VIEWS.map(([v, label]) =>
1724
2105
  `<li${v === view ? ' class="active"' : ''}><a href="${durl}?view=${v}">${label}</a></li>`).join('') + '</ul>';
1725
2106
 
1726
- if (view === 'json') {
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') {
1727
2112
  h += `<pre class="ips-json">${escape(JSON.stringify(row.bundle, null, 2))}</pre>`;
1728
2113
  } else if (view === 'terminology') {
1729
2114
  // the analysed copy carries the checks; the concepts are the same
1730
2115
  h += renderTerminology(row.analysed || row.bundle, { ...ctx, analysed: !!row.analysed, docUrl: durl });
1731
2116
  } else if (view === 'translation') {
1732
2117
  h += renderTranslation(row, { canManage: ctx.canManage });
1733
- } else if (view === 'generated') {
1734
- h += renderGenerated(row, { canManage: ctx.canManage });
1735
2118
  } else if (view === 'profiles') {
1736
2119
  h += renderProfileChecks(row, { base: ctx.base, canManage: ctx.canManage });
1737
2120
  } else if (view === 'ai') {
@@ -1741,13 +2124,19 @@ function renderDocument(project, row, view, ctx) {
1741
2124
  } else if (view === 'national') {
1742
2125
  h += renderNational(row, { np: ctx.np, level: ctx.level, docUrl: durl, base: ctx.base, canManage: ctx.canManage });
1743
2126
  } else {
1744
- h += assembledNarrative(row.bundle);
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&amp;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);
1745
2133
  }
1746
2134
  return h;
1747
2135
  }
1748
2136
 
1749
2137
  module.exports = {
2138
+ analysisOk,
1750
2139
  renderHome, renderLogin, renderProjects, renderUsers, renderProject, renderSubmit, renderSettings, renderDocument,
1751
- assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderNational, renderShare, renderCompose, renderProfileChecks,
1752
- renderProfiles, renderProfile, renderProfileEditor, renderNationalProfiles, VIEWS
2140
+ assembledNarrative, renderStructure, renderAnalysis, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderNational, renderShare, renderCompose, renderProfileChecks,
2141
+ renderProfiles, renderProfile, renderProfileEditor, renderNationalProfiles, VIEWS, viewOf
1753
2142
  };