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.
Files changed (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  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 +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. 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-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; }
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-steps .ips-actions { margin-left: auto; display: flex; gap: 6px; }
61
- .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; }
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: 110%; margin-top: 18px; border-bottom: 1px solid #eee; }
73
- .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; }
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: #fd7e14; }
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(' &nbsp;|&nbsp; ')}</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.</p>`;
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"><h4>Documentation</h4>' +
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 = level <= 4 ? `h${level}` : 'h5';
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 += `<h2>${escape(comp.title)}</h2>`;
783
+ h += `<h3>${escape(comp.title)}</h3>`;
660
784
  }
661
- h += narrativeOf(comp) || '';
662
- h += sectionsHtml(comp.section, 3);
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
- 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' };
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', '#d39e00']
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', '#fd7e14'],
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
- 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)}"` : '');
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
- 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>`;
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: '#d39e00', low: '#17a2b8' };
1004
- 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' };
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 += '<h4>Consistency and integrity</h4>';
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 += '<details class="ips-oo"><summary><button type="button" class="btn btn-default ips-btn ips-oo-toggle">Show the model\'s answer</button></summary>' +
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
- const v = r.result;
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(r.made)}` +
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
- 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');
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
- h += `<form method="get" action="${escape(options.docUrl || '')}" class="ips-filter mb-2"><input type="hidden" name="view" value="validation"/>` +
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 (a <details> for a browser without scripts)
1112
- h += '<details class="ips-oo"><summary><button type="button" class="btn btn-default ips-btn ips-oo-toggle">Show the OperationOutcome</button></summary>' +
1113
- `<pre class="ips-json">${escape(JSON.stringify(v.outcome, null, 2))}</pre></details>`;
1114
- h += `<script>
1115
- document.querySelectorAll('.ips-oo-toggle').forEach(function (b) {
1116
- var d = b.closest('details');
1117
- b.addEventListener('click', function (e) { e.preventDefault(); d.open = !d.open; });
1118
- 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'); });
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> &raquo; ${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&amp;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)}/editor.js"></script>`;
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> &raquo; 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
- /** One rule of a profile, for reading: the shape as words, or the expression. */
1205
- function ruleHtml(expression, titleOf) {
1206
- const rshape = profiles.parseResourceShape(expression);
1207
- if (rshape) {
1208
- const d = profiles.describeResource(rshape, titleOf);
1209
- const which = d.valueSet !== undefined
1210
- ? `with a code in the value set <b>${escape(d.title || d.valueSet)}</b>${d.title ? ` <span class="ips-small">(${escape(d.valueSet)})</span>` : ''}`
1211
- : `with the code <b>${escape(d.code)}</b>`;
1212
- return `${d.type ? `<b>${escape(d.type)}</b> resources` : 'Resources'} ${which}: <code>${escape(d.rest)}</code>` +
1213
- `<div class="ips-pf-expr">${escape(expression)}</div>`;
1214
- }
1215
- const shape = profiles.parseShape(expression);
1216
- if (!shape) {
1217
- return `<code>${escape(expression)}</code>`;
1218
- }
1219
- const part = (p, isSection) => {
1220
- const d = profiles.describePart(p, isSection, titleOf);
1221
- return `<b>${escape(d.what)}</b>${d.code ? ` <span class="ips-small">(${escape(d.code)})</span>` : ''}` +
1222
- (d.card ? ` <span class="ips-pf-card-text">[${escape(d.card)}]</span>` : '');
1223
- };
1224
- const entry = !shape.entry ? '' : (shape.entry.valueSet !== undefined ? ', with entries in the value set ' : ', with an entry ') + part(shape.entry, false);
1225
- return 'Section ' + part(shape.section, true) + entry + `<div class="ips-pf-expr">${escape(expression)}</div>`;
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
- /** A clinical profile, read-only (the administrator gets edit and delete). */
1229
- function renderProfile(sd, ctx) {
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 &nbsp;&middot;&nbsp; <code>${escape(sd.url)}</code> &nbsp;&middot;&nbsp; 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
- (ctx.user?.isAdmin ? ` <a class="btn btn-primary" href="${url}/edit">Edit</a> ` +
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
- if (constraints.length === 0) {
1242
- h += '<p>This profile has no constraints yet.</p>';
1243
- } else {
1244
- for (const [kind, heading] of PROFILE_PARTS) {
1245
- const these = constraints.filter(c => profiles.kindOf(c.expression || '') === kind);
1246
- if (!these.length) {
1247
- continue;
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
- h += `<h4>${escape(heading)}</h4><table class="table table-sm ips-table ips-pf-rule"><tr><th style="width: 35%">Expectation</th><th>Rule</th></tr>`;
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>${ruleHtml(c.expression || '', ctx.valueSetTitle)}</td></tr>`;
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.</p>';
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)}/profile-editor.js"></script>`;
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; more come later
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
- ['json', 'JSON']
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
- function stepsHtml(row, ctx) {
1404
- 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>`;
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
- 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>`;
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)} &nbsp;|&nbsp; ` : '') +
1427
2077
  `Received ${escape(row.received)} (${escape(row.source)}) &nbsp;|&nbsp; ${row.size.toLocaleString()} bytes` +
1428
2078
  ` &nbsp;|&nbsp; <a href="${durl}?_format=json">FHIR JSON</a> &nbsp;|&nbsp; <a href="${durl}?download=1">Download</a></div>`;
1429
- h += '</div>';
2079
+ h += '</div></div>';
1430
2080
 
1431
- // the steps, and what can be done
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 += '<span class="ips-actions">';
1435
- 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>`;
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 += '</span>';
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 === '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') {
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
- 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);
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
  };