fhirsmith 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
package/ips/render.js ADDED
@@ -0,0 +1,1482 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * HTML for the /ips module: the list of projects, a project's filterable list of
9
+ * documents, the submission form, the views of a document, and the administration
10
+ * pages (login, projects, users, a project's settings).
11
+ *
12
+ * Everything in a document came from a stranger, so every value goes through escape().
13
+ * The one exception is the narrative view, which shows the document's own XHTML
14
+ * (Composition.text.div and the sections' text.div): that is the point of the view, and
15
+ * it is safe to show because every narrative in a document is checked for active content
16
+ * (library/narrative-check) before the document is accepted.
17
+ *
18
+ * @module ips/render
19
+ */
20
+
21
+ const escape = require('escape-html');
22
+ const parse5 = require('parse5');
23
+ const { checkNarrative } = require('../library/narrative-check');
24
+ const { tokenConfigured } = require('../library/request-token');
25
+ const { STATUSES } = require('./search');
26
+ const profiles = require('./profiles');
27
+ const { terminologyOf, systemName, describeVersion, linkForSystem, linkForCode, isExternalSystem } = require('./terminology');
28
+ const { issueSummary, checkOf, translationOf } = require('./pipeline');
29
+ const { dateText } = require('./presenter');
30
+
31
+ const STATUS_COLOURS = {
32
+ submitted: '#6c757d',
33
+ processing: '#0d6efd',
34
+ processed: '#1e7e34',
35
+ error: '#c82333'
36
+ };
37
+
38
+ const STYLE = `
39
+ <style>
40
+ .ips-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; color: white; font-size: 85%; white-space: nowrap; }
41
+ .ips-table { font-size: 90%; }
42
+ .ips-table td, .ips-table th { vertical-align: top; }
43
+ .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
+ .ips-banner { background: #ede7f6; border: 1px solid #d1c4e9; border-radius: 4px; padding: 10px 14px; margin-bottom: 10px; }
45
+ .ips-banner .ips-patient { font-size: 120%; }
46
+ .ips-banner .ips-project-name { font-size: 130%; }
47
+ .ips-actions-line { margin-bottom: 10px; }
48
+ .ips-actions-line .btn { font-size: 90%; padding: 3px 12px; }
49
+ .ips-search { background: #fffde7; border: 1px solid #fff59d; border-radius: 4px; padding: 8px 10px; }
50
+ .ips-docs h4 { margin-top: 0; }
51
+ .ips-api td:first-child { white-space: nowrap; }
52
+ .ips-banner .ips-facts { font-size: 90%; color: #444; margin-top: 4px; }
53
+ .ips-banner .ips-facts a { margin-left: 4px; }
54
+ .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; }
56
+ .ips-step-done { background: #1e7e34; }
57
+ .ips-step-failed { background: #c82333; }
58
+ .ips-step-running { background: #0d6efd; }
59
+ .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; }
62
+ .ips-nav.nav-tabs { margin-bottom: 14px; }
63
+ .ips-nav.nav-tabs > li > a { padding: 6px 14px; }
64
+ .ips-explain { margin-top: 18px; padding-top: 8px; border-top: 1px solid #dee2e6; }
65
+ .ips-filter { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; font-size: 85%; }
66
+ .ips-filter input, .ips-filter select { font-size: 100%; padding: 1px 4px; height: auto; }
67
+ .ips-filter input[type=text] { width: 10em; }
68
+ .ips-filter input[type=date] { width: 9.5em; }
69
+ .ips-filter .ips-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
70
+ .ips-date { white-space: nowrap; }
71
+ .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; }
74
+ .ips-narrative table { border-collapse: collapse; margin-bottom: 8px; }
75
+ .ips-narrative td, .ips-narrative th { border: 1px solid #ddd; padding: 2px 6px; font-size: 90%; }
76
+ .ips-meta { font-size: 85%; color: #555; margin-bottom: 10px; }
77
+ .ips-form label { display: block; font-weight: bold; margin-top: 10px; }
78
+ .ips-form textarea { width: 100%; height: 20em; font-family: monospace; font-size: 85%; }
79
+ .ips-form input[type=text], .ips-form input[type=password] { width: 100%; max-width: 480px; }
80
+ .ips-small { font-size: 85%; color: #555; }
81
+ .ips-term th.ips-section { background: #e9ecef; font-size: 105%; }
82
+ .ips-term th.ips-resource { background: #f8f9fa; font-weight: normal; font-style: italic; }
83
+ .ips-term ul { margin: 0; padding-left: 1.2em; }
84
+ .ips-term code { font-size: 95%; }
85
+ .ips-copy { font-size: 85%; padding: 0 6px; }
86
+ .ips-grade { font-size: 110%; padding: 2px 10px; }
87
+ .ips-ed-panel { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 8px 14px; margin-bottom: 12px; }
88
+ .ips-ed-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 6px 14px; }
89
+ .ips-ed-field label { display: block; font-size: 85%; margin: 4px 0 2px; color: #444; }
90
+ .ips-ed-input { font-size: 90%; padding: 3px 6px; height: auto; }
91
+ .ips-ed-hint { color: #6c757d; }
92
+ .ips-ed-quantity { display: flex; gap: 4px; } .ips-ed-qty { width: 8em; } .ips-ed-unit { width: 7em; }
93
+ .ips-ed-code { position: relative; }
94
+ .ips-ed-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 3px; }
95
+ .ips-ed-chip { display: inline-block; background: #e9ecef; border-radius: 12px; padding: 1px 8px; font-size: 85%; }
96
+ .ips-ed-chip-coded { background: #d1e7dd; }
97
+ .ips-ed-chip-x { margin-left: 3px; color: #666; text-decoration: none; }
98
+ .ips-ed-suggestions { position: absolute; z-index: 20; background: #fff; border: 1px solid #ccc; box-shadow: 0 2px 8px rgba(0,0,0,0.2); max-height: 260px; overflow: auto; width: 100%; }
99
+ .ips-ed-suggestion { padding: 4px 8px; cursor: pointer; font-size: 90%; }
100
+ .ips-ed-suggestion:hover { background: #f0f4ff; }
101
+ .ips-ed-suggestion small { color: #888; } .ips-ed-suggestion-text { color: #555; border-top: 1px solid #eee; }
102
+ .ips-pf-row { border: 1px solid #dee2e6; border-radius: 4px; padding: 8px 10px; margin-bottom: 8px; background: #fcfcfd; }
103
+ .ips-pf-row .ips-pf-head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
104
+ .ips-pf-row .ips-pf-head input { flex: 1; }
105
+ .ips-pf-grid { width: 100%; font-size: 90%; }
106
+ .ips-pf-grid th { font-weight: normal; color: #666; font-size: 90%; padding: 0 4px; }
107
+ .ips-pf-grid td { vertical-align: top; padding: 2px 4px; }
108
+ .ips-pf-grid td.ips-pf-card { width: 7em; }
109
+ .ips-pf-grid .ips-ed-input { width: 100%; box-sizing: border-box; }
110
+ .ips-pf-pair { display: flex; gap: 4px; }
111
+ .ips-pf-pair select { max-width: 13em; }
112
+ .ips-pf-display { font-size: 85%; color: #555; margin-top: 2px; min-height: 1em; }
113
+ .ips-pf-expr { font-family: monospace; font-size: 85%; color: #666; margin-top: 4px; word-break: break-all; }
114
+ .ips-pf-bad { border-color: #c82333 !important; }
115
+ .ips-pf-rule { width: 100%; table-layout: fixed; }
116
+ .ips-pf-rule td { vertical-align: top; overflow-wrap: anywhere; }
117
+ .ips-pf-rule .ips-pf-card-text { white-space: nowrap; }
118
+ .ips-ed-section { border: 1px solid #dee2e6; border-radius: 4px; margin-bottom: 8px; }
119
+ .ips-ed-section-required { border-color: #b8c4e8; }
120
+ .ips-ed-section-head { padding: 8px 12px; cursor: pointer; background: #f1f3f5; user-select: none; }
121
+ .ips-ed-section-required .ips-ed-section-head { background: #e8edf8; }
122
+ .ips-ed-caret { display: inline-block; width: 1em; color: #666; }
123
+ .ips-ed-section-body { padding: 8px 12px; }
124
+ .ips-ed-section-body[hidden] { display: none; }
125
+ .ips-ed-modes { margin-bottom: 8px; font-size: 90%; } .ips-ed-mode { margin-right: 16px; font-weight: normal; }
126
+ .ips-ed-row { border: 1px solid #e3e6ea; border-radius: 4px; padding: 6px 10px; margin-bottom: 8px; background: #fff; }
127
+ .ips-ed-row-head { display: flex; justify-content: space-between; align-items: center; }
128
+ .ips-ed-remove { padding: 0; font-size: 85%; }
129
+ .ips-ed-sub { margin: 6px 0 0 12px; padding-left: 10px; border-left: 3px solid #e3e6ea; }
130
+ .ips-ed-sub-head { font-size: 85%; font-weight: bold; color: #444; margin-bottom: 2px; }
131
+ .ips-ed-sub-row { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 4px; } .ips-ed-sub-row .ips-ed-grid { flex: 1; }
132
+ .ips-ed-actions { margin-top: 16px; padding-top: 10px; border-top: 1px solid #dee2e6; }
133
+ .ips-ed-messages ul { margin-bottom: 0; }
134
+ .ips-share-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 1050; display: flex; align-items: center; justify-content: center; }
135
+ .ips-share-overlay[hidden] { display: none; }
136
+ .ips-share-panel { background: #fff; border-radius: 6px; padding: 18px 22px; max-width: 560px; width: 92%; max-height: 92vh; overflow: auto; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); position: relative; }
137
+ .ips-share-panel .ips-share-close { position: absolute; top: 8px; right: 12px; font-size: 24px; }
138
+ .ips-share-code { text-align: center; margin: 10px 0; }
139
+ .ips-share-code svg { width: 320px; max-width: 100%; height: auto; }
140
+ .ips-share-text { width: 100%; font-family: monospace; font-size: 80%; }
141
+ .ips-issues td.ips-path { max-width: 28em; overflow: hidden; }
142
+ .ips-issues td.ips-path code { white-space: normal; word-break: keep-all; overflow-wrap: normal; }
143
+ .ips-oo { margin-top: 10px; }
144
+ .ips-oo summary { list-style: none; display: inline-block; }
145
+ .ips-oo summary::-webkit-details-marker { display: none; }
146
+ .ips-oo pre { margin-top: 8px; }
147
+ .ips-generated table.ips-section, .ips-generated table.ips-facts { border-collapse: collapse; margin-bottom: 10px; font-size: 90%; }
148
+ .ips-generated table.ips-section th, .ips-generated table.ips-section td, .ips-generated table.ips-facts td { border: 1px solid #dee2e6; padding: 3px 6px; vertical-align: top; }
149
+ .ips-generated table.ips-section th { background: #f8f9fa; }
150
+ .ips-generated tr.ips-follow td { background: #fcfcfc; }
151
+ .ips-generated tr.ips-follow ul { margin: 0; padding-left: 18px; }
152
+ .ips-generated .ips-as-submitted { font-size: 85%; color: #6c757d; margin-bottom: 4px; }
153
+ .ips-generated h1 { font-size: 150%; } .ips-generated h2 { font-size: 125%; margin-top: 16px; } .ips-generated h3 { font-size: 110%; }
154
+ .ips-translated { font-size: 80%; color: #fff; background: #6f42c1; border-radius: 3px; padding: 0 5px; cursor: help; }
155
+ .ips-check { font-size: 130%; font-weight: bold; cursor: help; }
156
+ .ips-check-in { color: #1e7e34; }
157
+ .ips-check-international { color: #fd7e14; }
158
+ .ips-check-not-in { color: #c82333; }
159
+ .ips-check-unknown { color: #6c757d; }
160
+ .ips-inline { display: inline; }
161
+ </style>
162
+ <script>
163
+ // a form with data-confirm asks first: the message is an attribute (escaped as one), never script
164
+ if (!window.ipsConfirm) {
165
+ window.ipsConfirm = true;
166
+ document.addEventListener('submit', function (e) {
167
+ var f = e.target;
168
+ if (f && f.getAttribute && f.getAttribute('data-confirm') !== null && !window.confirm(f.getAttribute('data-confirm'))) {
169
+ e.preventDefault();
170
+ }
171
+ });
172
+ }
173
+ </script>`;
174
+
175
+ function text(v) {
176
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
177
+ return String(v);
178
+ }
179
+ return '';
180
+ }
181
+
182
+ function isObject(v) {
183
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
184
+ }
185
+
186
+ function badge(status) {
187
+ const s = text(status);
188
+ if (!s) {
189
+ return '';
190
+ }
191
+ return `<span class="ips-badge" style="background-color: ${STATUS_COLOURS[s] || '#495057'}">${escape(s)}</span>`;
192
+ }
193
+
194
+ /** Just the date part of a FHIR date/dateTime/instant, as sent. */
195
+ function dateOnly(v) {
196
+ const s = text(v);
197
+ const m = /^\d{4}(-\d{2}(-\d{2})?)?/.exec(s);
198
+ return m ? m[0] : s;
199
+ }
200
+
201
+ /** Build a query string from a plain object, dropping empty values. */
202
+ function qs(params) {
203
+ const u = new URLSearchParams();
204
+ for (const [k, v] of Object.entries(params)) {
205
+ for (const x of (Array.isArray(v) ? v : [v])) {
206
+ if (typeof x === 'string' && x !== '') {
207
+ u.append(k, x);
208
+ }
209
+ }
210
+ }
211
+ const s = u.toString();
212
+ return s ? '?' + s : '';
213
+ }
214
+
215
+ function field(query, name) {
216
+ const v = query[name];
217
+ return typeof v === 'string' ? v : (Array.isArray(v) && typeof v[0] === 'string' ? v[0] : '');
218
+ }
219
+
220
+ /** The hidden field every POST form carries: the CSRF token (lusca checks it). */
221
+ function csrfField(token) {
222
+ return token ? `<input type="hidden" name="_csrf" value="${escape(token)}"/>` : '';
223
+ }
224
+
225
+ function alert(message, kind = 'warning') {
226
+ return message ? `<div class="alert alert-${kind}">${escape(message)}</div>` : '';
227
+ }
228
+
229
+ /**
230
+ * The line at the top of every page: who is logged in, and what they can get to.
231
+ * @param {{base: string, user?: Object, csrf?: string}} ctx
232
+ */
233
+ function userBar(ctx) {
234
+ const base = escape(ctx.base);
235
+ const u = ctx.user;
236
+ if (!u) {
237
+ return `<div style="float: right; font-size: 85%"><a href="${base}/login">Login</a></div>`;
238
+ }
239
+ const parts = [`Logged in as <b>${escape(u.name)}</b>`];
240
+ if (u.isAdmin) {
241
+ parts.push(`<a href="${base}/admin/projects">Projects</a>`);
242
+ parts.push(`<a href="${base}/admin/users">Users</a>`);
243
+ parts.push(`<a href="${base}/StructureDefinition">Clinical profiles</a>`);
244
+ }
245
+ 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
+ return `<div style="float: right; font-size: 85%">${parts.join(' &nbsp;|&nbsp; ')}</div>`;
247
+ }
248
+
249
+ function projectUrl(ctx, project) {
250
+ return `${escape(ctx.base)}/${encodeURIComponent(project.code)}`;
251
+ }
252
+
253
+ function crumbs(ctx, project, extra) {
254
+ let h = `<p class="ips-small"><a href="${escape(ctx.base)}">IPS projects</a>`;
255
+ if (project) {
256
+ h += ` &raquo; <a href="${projectUrl(ctx, project)}">${escape(project.name)}</a>`;
257
+ }
258
+ if (extra) {
259
+ h += ` &raquo; ${escape(extra)}`;
260
+ }
261
+ return h + '</p>';
262
+ }
263
+
264
+ // ---- the root: projects ----------------------------------------------------------------
265
+
266
+ /**
267
+ * The list of projects.
268
+ * @param {Array} projects - from store.projects()
269
+ * @param {{base: string, user?: Object, csrf?: string}} ctx
270
+ */
271
+ function renderHome(projects, ctx) {
272
+ let h = STYLE + userBar(ctx);
273
+ h += '<p>Each project is a collection of International Patient Summary documents, with its own FHIR endpoint ' +
274
+ '(<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>`;
276
+ if (projects.length === 0) {
277
+ return h + '<p>There are no projects yet' + (ctx.user?.isAdmin ? `: <a href="${escape(ctx.base)}/admin/projects">create one</a>` : '') + '.</p>';
278
+ }
279
+ h += '<table class="table table-sm table-striped ips-table"><tr><th>Project</th><th>Code</th><th>Description</th><th>Documents</th></tr>';
280
+ for (const p of projects) {
281
+ h += `<tr><td><a href="${projectUrl(ctx, p)}">${escape(p.name)}</a></td><td><code>${escape(p.code)}</code></td>` +
282
+ `<td>${escape(p.description || '')}</td><td>${p.documents}</td></tr>`;
283
+ }
284
+ return h + '</table>';
285
+ }
286
+
287
+ function renderLogin(ctx, error) {
288
+ const base = escape(ctx.base);
289
+ let h = STYLE + crumbs(ctx) + alert(error, 'danger');
290
+ h += `<form method="post" action="${base}/login" style="max-width: 360px">${csrfField(ctx.csrf)}` +
291
+ '<div class="mb-3"><label for="login" class="form-label">Username</label>' +
292
+ '<input type="text" class="form-control" id="login" name="login" required autocomplete="username"/></div>' +
293
+ '<div class="mb-3"><label for="password" class="form-label">Password</label>' +
294
+ '<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password"/></div>' +
295
+ '<button type="submit" class="btn btn-primary">Login</button></form>';
296
+ return h;
297
+ }
298
+
299
+ /**
300
+ * The administrator's projects page: create, edit, delete, and who manages each.
301
+ * @param {Array} projects - from store.projects()
302
+ * @param {Array} users - from store.users()
303
+ */
304
+ function renderProjects(projects, users, ctx, message) {
305
+ const base = escape(ctx.base);
306
+ let h = STYLE + userBar(ctx) + crumbs(ctx, null, 'Projects') + alert(message);
307
+ h += '<p>A project\'s code is its address: <code>' + base + '/{code}</code>. Codes are 2-32 lower case letters, digits and hyphens, and can\'t be changed once a project exists.</p>';
308
+ const managerBoxes = (p, form) => users.map(u => {
309
+ const on = p && p.managers.some(m => m.id === u.id);
310
+ return `<label class="ips-inline" style="margin-right: 8px; font-weight: normal"><input form="${form}" type="checkbox" name="manager" value="${u.id}"${on ? ' checked' : ''}/> ${escape(u.login)}</label>`;
311
+ }).join('') || '<span class="ips-small">(no users yet)</span>';
312
+ h += '<table class="table table-sm ips-table"><tr><th>Code</th><th>Name</th><th>Description</th><th>Managers</th><th>Documents</th><th></th></tr>';
313
+ for (const p of projects) {
314
+ const form = `project-${p.id}`;
315
+ const action = `${base}/admin/projects/${p.id}`;
316
+ h += `<tr><td><a href="${projectUrl(ctx, p)}"><code>${escape(p.code)}</code></a></td>` +
317
+ `<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(p.name)}"/></td>` +
318
+ `<td><input form="${form}" type="text" name="description" class="form-control" style="font-size: 85%" value="${escape(p.description || '')}"/></td>` +
319
+ `<td>${managerBoxes(p, form)}</td><td>${p.documents}</td>` +
320
+ `<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" class="ips-inline">${csrfField(ctx.csrf)}` +
321
+ '<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
322
+ `<form method="post" action="${action}/delete" class="ips-inline" data-confirm="${escape(`Delete project ${p.code} and its ${p.documents} documents?`)}">${csrfField(ctx.csrf)}` +
323
+ '<button type="submit" class="btn btn-default ips-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
324
+ }
325
+ h += `<tr><td><input form="project-new" type="text" name="code" required class="form-control" style="font-size: 85%" placeholder="code" pattern="[a-z0-9][a-z0-9-]{1,31}"/></td>` +
326
+ '<td><input form="project-new" type="text" name="name" required class="form-control" style="font-size: 85%" placeholder="Name"/></td>' +
327
+ '<td><input form="project-new" type="text" name="description" class="form-control" style="font-size: 85%" placeholder="Description"/></td>' +
328
+ `<td>${managerBoxes(null, 'project-new')}</td><td></td>` +
329
+ `<td><form id="project-new" method="post" action="${base}/admin/projects">${csrfField(ctx.csrf)}` +
330
+ '<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Create</button></form></td></tr>';
331
+ return h + '</table>';
332
+ }
333
+
334
+ /**
335
+ * The administrator's users page.
336
+ * @param {Array} users - from store.users()
337
+ */
338
+ function renderUsers(users, ctx, message) {
339
+ const base = escape(ctx.base);
340
+ let h = STYLE + userBar(ctx) + crumbs(ctx, null, 'Users') + alert(message);
341
+ h += `<p>Users manage projects: which ones is set on the <a href="${base}/admin/projects">projects</a> page. ` +
342
+ 'Leave the password blank to keep it.</p>';
343
+ h += '<table class="table table-sm ips-table"><tr><th>Username</th><th>Name</th><th>Password</th><th>Manages</th><th></th></tr>';
344
+ for (const u of users) {
345
+ const form = `user-${u.id}`;
346
+ const action = `${base}/admin/users/${u.id}`;
347
+ h += `<tr><td>${escape(u.login)}</td>` +
348
+ `<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(u.name)}"/></td>` +
349
+ `<td><input form="${form}" type="password" name="password" class="form-control" style="font-size: 85%" autocomplete="new-password" placeholder="(unchanged)"/></td>` +
350
+ `<td>${u.projects.map(c => `<code>${escape(c)}</code>`).join(' ')}</td>` +
351
+ `<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" class="ips-inline">${csrfField(ctx.csrf)}` +
352
+ '<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
353
+ `<form method="post" action="${action}/delete" class="ips-inline" data-confirm="${escape(`Delete user ${u.login}?`)}">${csrfField(ctx.csrf)}` +
354
+ '<button type="submit" class="btn btn-default ips-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
355
+ }
356
+ h += '<tr><td><input form="user-new" type="text" name="login" required class="form-control" style="font-size: 85%" placeholder="username" autocomplete="off"/></td>' +
357
+ '<td><input form="user-new" type="text" name="name" required class="form-control" style="font-size: 85%" placeholder="Name"/></td>' +
358
+ '<td><input form="user-new" type="password" name="password" required class="form-control" style="font-size: 85%" autocomplete="new-password" placeholder="at least 8 characters"/></td>' +
359
+ `<td></td><td><form id="user-new" method="post" action="${base}/admin/users">${csrfField(ctx.csrf)}` +
360
+ '<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Add</button></form></td></tr>';
361
+ return h + '</table>';
362
+ }
363
+
364
+ // ---- a project -------------------------------------------------------------------------
365
+
366
+ function input(query, name, label, type = 'text', placeholder = label) {
367
+ return `<input type="${type}" id="f-${escape(name)}" name="${escape(name)}" placeholder="${escape(placeholder)}" ` +
368
+ `title="${escape(label)}" aria-label="${escape(label)}" value="${escape(field(query, name))}"/>`;
369
+ }
370
+
371
+ function select(query, name, values, label, anyLabel) {
372
+ const cur = field(query, name);
373
+ let h = `<select id="f-${escape(name)}" name="${escape(name)}" title="${escape(label)}"><option value="">${escape(anyLabel || label + ': any')}</option>`;
374
+ for (const v of values) {
375
+ h += `<option value="${escape(v)}"${v === cur ? ' selected' : ''}>${escape(v)}</option>`;
376
+ }
377
+ return h + '</select>';
378
+ }
379
+
380
+ /** The links under a project's title. */
381
+ function projectNav(project, ctx) {
382
+ const purl = projectUrl(ctx, project);
383
+ let h = `<div class="ips-actions-line"><a class="btn btn-primary" href="${purl}/submit">Submit a document</a> <a class="btn btn-default" href="${purl}/compose">Compose a document</a>`;
384
+ if (ctx.canManage) {
385
+ h += ` <a class="btn btn-default" href="${purl}/settings">Settings</a>`;
386
+ }
387
+ return h + '</div>';
388
+ }
389
+
390
+ /** The documentation at the foot of a project's page: how to reach it as a FHIR endpoint. */
391
+ function projectDocs(project, ctx) {
392
+ const purl = projectUrl(ctx, project);
393
+ return '<div class="ips-explain ips-docs"><h4>Documentation</h4>' +
394
+ `<p>This project is a FHIR endpoint: <code>${purl}</code> (<a href="${purl}/metadata">CapabilityStatement</a>). ` +
395
+ 'Documents are IPS Bundles (<code>type: document</code>, a Composition first, with the Patient it refers to) in JSON.</p>' +
396
+ '<table class="table table-sm ips-table ips-api"><tr><th>Request</th><th></th></tr>' +
397
+ `<tr><td><code>POST ${purl}/Bundle</code></td><td>submit a document${tokenConfigured(project.token) ? ' (this project requires a token: <code>Authorization: Bearer {token}</code>)' : ''}; the response is the stored document, with its id</td></tr>` +
398
+ `<tr><td><code>GET ${purl}/Bundle/{id}</code></td><td>read a document - JSON for a FHIR client, this web page for a browser (<code>?_format=json</code> forces JSON, <code>?download=1</code> downloads it)</td></tr>` +
399
+ `<tr><td><code>GET ${purl}/Bundle?...</code></td><td>search: Bundle's search parameters (<code>_id</code>, <code>identifier</code>, <code>_lastUpdated</code>, <code>composition</code>), chained to the Composition and Patient as ` +
400
+ '<code>composition.date</code>, <code>composition.title</code>, <code>composition.patient.name</code>, <code>composition.patient.gender</code>, <code>composition.patient.birthdate</code>, <code>composition.patient.identifier</code>, ...; ' +
401
+ 'with <code>_sort</code>, <code>_count</code>, <code>_offset</code>, <code>_summary=count</code>, and <code>processing-status</code> for where a document is in the pipeline (the CapabilityStatement lists them all)</td></tr>' +
402
+ `<tr><td><code>GET ${purl}/metadata</code></td><td>the CapabilityStatement</td></tr></table>` +
403
+ '<p class="ips-small">The filters above are the same searches, as the page sends them. See the ' +
404
+ '<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/ips/readme.md">IPS module documentation</a> for the processing pipeline and the document views.</p></div>';
405
+ }
406
+
407
+ /**
408
+ * A project's page: the filter form and the list of documents.
409
+ *
410
+ * @param {Object} project
411
+ * @param {Object} query - the page's own query (form fields included)
412
+ * @param {{total: number, rows: Object[]}} results
413
+ * @param {Object} search - the parsed search (for count/offset/sort)
414
+ * @param {{base: string, user?: Object, csrf?: string, canManage?: boolean}} ctx
415
+ */
416
+ function renderProject(project, query, results, search, ctx, problems) {
417
+ const purl = projectUrl(ctx, project);
418
+ let h = STYLE + userBar(ctx) + crumbs(ctx);
419
+ h += `<div class="ips-banner"><div class="ips-project-name"><b>${escape(project.name)}</b></div>`;
420
+ if (project.description) {
421
+ h += `<div class="ips-facts">${escape(project.description)}</div>`;
422
+ }
423
+ h += '</div>';
424
+ h += projectNav(project, ctx);
425
+ if (problems && problems.length > 0) {
426
+ h += '<div class="alert alert-warning">' + problems.map(escape).join('<br/>') + '</div>';
427
+ }
428
+
429
+ h += `<form method="get" action="${purl}" class="ips-filter ips-search mb-3">`;
430
+ h += input(query, 'name', 'Patient name (any part)', 'text', 'Patient name');
431
+ h += select(query, 'gender', ['male', 'female', 'other', 'unknown'], 'Gender');
432
+ h += input(query, 'date', 'Document date', 'date');
433
+ h += input(query, 'identifier', 'Document id or patient identifier', 'text', 'Identifier');
434
+ h += select(query, 'status', STATUSES, 'Status');
435
+ h += select(query, '_count', ['20', '50', '100', '200', '500'], 'Page size', 'Page: 50');
436
+ if (field(query, '_sort')) {
437
+ h += `<input type="hidden" name="_sort" value="${escape(field(query, '_sort'))}"/>`;
438
+ }
439
+ h += '<button type="submit" class="btn btn-primary ips-btn">Filter</button>' +
440
+ `<a class="btn btn-default ips-btn" href="${purl}">Clear</a></form>`;
441
+
442
+ const from = results.total === 0 ? 0 : search.offset + 1;
443
+ const to = search.offset + results.rows.length;
444
+ h += `<p>${from}-${to} of ${results.total} document${results.total === 1 ? '' : 's'}</p>`;
445
+
446
+ // [sort parameter, heading]; null parameter = not sortable
447
+ const columns = [
448
+ ['_id', 'Id'], ['composition.patient.name', 'Patient'], ['composition.patient.gender', 'Gender'],
449
+ ['composition.patient.birthdate', 'Born'], ['composition.date', 'Date'], ['composition.title', 'Title'],
450
+ ['processing-status', 'Status'], ['_lastUpdated', 'Received']
451
+ ];
452
+ if (ctx.canManage) {
453
+ columns.push([null, '']);
454
+ }
455
+ const curSort = field(query, '_sort');
456
+ h += '<table class="table table-sm table-striped ips-table"><tr>';
457
+ for (const [param, label] of columns) {
458
+ if (!param) {
459
+ h += `<th>${escape(label)}</th>`;
460
+ continue;
461
+ }
462
+ let next = param;
463
+ let arrow = '';
464
+ if (curSort === param) {
465
+ next = '-' + param;
466
+ arrow = ' ▲';
467
+ } else if (curSort === '-' + param) {
468
+ arrow = ' ▼';
469
+ }
470
+ h += `<th><a href="${escape(qs({ ...query, _sort: next, _offset: '' }) || '?')}">${escape(label)}</a>${arrow}</th>`;
471
+ }
472
+ h += '</tr>';
473
+ for (const r of results.rows) {
474
+ const durl = `${purl}/Bundle/${r.id}`;
475
+ h += `<tr><td><a href="${durl}">${r.id}</a></td>` +
476
+ `<td><a href="${durl}">${escape(r.patient_name || '(no name)')}</a></td>` +
477
+ `<td>${escape(r.patient_gender || '')}</td>` +
478
+ `<td class="ips-date">${escape(r.patient_birth || '')}</td>` +
479
+ `<td class="ips-date" title="${escape(r.comp_date || '')}">${escape(dateOnly(r.comp_date))}</td>` +
480
+ `<td>${escape(r.comp_title || '')}</td>` +
481
+ `<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>`;
483
+ if (ctx.canManage) {
484
+ h += `<td><form method="post" action="${durl}/delete" class="ips-inline" data-confirm="${escape(`Delete document ${r.id}?`)}">${csrfField(ctx.csrf)}` +
485
+ '<button type="submit" class="btn btn-link" style="padding: 0; font-size: 85%">delete</button></form></td>';
486
+ }
487
+ h += '</tr>';
488
+ }
489
+ h += '</table>';
490
+
491
+ const links = [];
492
+ if (search.offset > 0) {
493
+ links.push(`<a href="${escape(qs({ ...query, _offset: '' }) || '?')}">First</a>`);
494
+ links.push(`<a href="${escape(qs({ ...query, _offset: String(Math.max(0, search.offset - search.count)) }))}">Previous</a>`);
495
+ }
496
+ if (search.count > 0 && search.offset + search.count < results.total) {
497
+ links.push(`<a href="${escape(qs({ ...query, _offset: String(search.offset + search.count) }))}">Next</a>`);
498
+ const last = Math.floor((results.total - 1) / search.count) * search.count;
499
+ links.push(`<a href="${escape(qs({ ...query, _offset: String(last) }))}">Last</a>`);
500
+ }
501
+ if (links.length > 0) {
502
+ h += `<p>${links.join(' &nbsp;|&nbsp; ')}</p>`;
503
+ }
504
+ h += projectDocs(project, ctx);
505
+ return h;
506
+ }
507
+
508
+ /**
509
+ * The form for submitting a document by hand: paste the JSON, or pick a file (which the
510
+ * page reads into the text area - there is no upload).
511
+ */
512
+ function renderSubmit(project, ctx, options = {}) {
513
+ const purl = projectUrl(ctx, project);
514
+ let h = STYLE + userBar(ctx) + crumbs(ctx, project, 'Submit') + alert(options.error, 'danger');
515
+ h += '<p>Paste an IPS document (a FHIR R4 <code>Bundle</code> of type <code>document</code>, as JSON, whose first entry is the ' +
516
+ 'Composition), or choose a file. Once received, the document is queued for processing.</p>';
517
+ if (options.aiEnabled) {
518
+ h += '<div class="alert alert-info">Documents submitted to this project are sent to an external AI service for assessment, as part of their processing.</div>';
519
+ }
520
+ h += `<form method="post" action="${purl}/submit" class="ips-form">${csrfField(ctx.csrf)}`;
521
+ if (options.needsToken) {
522
+ h += '<label for="token">Submission token</label><input type="password" id="token" name="token" required autocomplete="off"/>' +
523
+ '<div class="ips-small">This project requires a token to submit documents; ask its managers.</div>';
524
+ }
525
+ h += '<label for="file">File</label><input type="file" id="file" accept=".json,application/json,application/fhir+json"/>';
526
+ h += `<label for="bundle">Document</label><textarea id="bundle" name="bundle" required>${escape(options.bundle || '')}</textarea>`;
527
+ h += '<p style="margin-top: 10px"><button type="submit" class="btn btn-primary">Submit</button></p></form>';
528
+ h += `<script>
529
+ document.getElementById('file').addEventListener('change', function () {
530
+ var f = this.files && this.files[0];
531
+ if (!f) { return; }
532
+ var r = new FileReader();
533
+ r.onload = function () { document.getElementById('bundle').value = r.result; };
534
+ r.readAsText(f);
535
+ });
536
+ </script>`;
537
+ return h;
538
+ }
539
+
540
+ /** A project's settings, for its managers: the submission token. */
541
+ function renderSettings(project, ctx, message, options = {}) {
542
+ const purl = projectUrl(ctx, project);
543
+ let h = STYLE + userBar(ctx) + crumbs(ctx, project, 'Settings') + alert(message, message && /must|cannot|not a URL|Unknown|no model|no API key/.test(message) ? 'danger' : 'success');
544
+ h += '<h3>Submission token</h3>';
545
+ h += '<p>When a token is set, a document can only be submitted with it: in the <code>token</code> field of the ' +
546
+ `submission form, or in the <code>Authorization</code> header of a <code>POST ${purl}/Bundle</code> ` +
547
+ '(<code>Authorization: Bearer &lt;token&gt;</code>). Clear it and anyone can submit.</p>';
548
+ h += `<p>Current token: ${project.token ? `<code>${escape(project.token)}</code>` : '<i>none - submissions are open</i>'}</p>`;
549
+ h += `<form method="post" action="${purl}/settings" class="ips-form" style="max-width: 480px">${csrfField(ctx.csrf)}` +
550
+ '<label for="token">New token</label><input type="text" id="token" name="token" autocomplete="off" placeholder="leave blank to clear"/>' +
551
+ '<p style="margin-top: 10px"><button type="submit" class="btn btn-primary">Save</button> ' +
552
+ '<button type="submit" name="generate" value="1" class="btn btn-default">Generate a random token</button></p></form>';
553
+ h += aiSettingsHtml(project, ctx, options);
554
+ h += '<h3>Managers</h3>';
555
+ const managers = project.managers || [];
556
+ h += managers.length === 0
557
+ ? '<p><i>No users manage this project</i> (the administrator does).</p>'
558
+ : '<p>' + managers.map(m => `${escape(m.name)} (<code>${escape(m.login)}</code>)`).join(', ') + '</p>';
559
+ return h;
560
+ }
561
+
562
+ /** The AI assessment part of the settings page. */
563
+ function aiSettingsHtml(project, ctx, options) {
564
+ const purl = projectUrl(ctx, project);
565
+ const ai = options.ai || {};
566
+ const { PROVIDERS } = require('./ai-client');
567
+ let h = '<h3>AI assessment</h3>';
568
+ h += '<p>When it is on, every document is sent, after the other processing, to an AI service to be graded for consistency and for its terminology ' +
569
+ '(the document as JSON, with the narrative as the sender wrote it or with none). This sends the document to a third party: turn it on only for a project whose documents may go there. ' +
570
+ (options.serverDefault ? `This server has a default account (${escape(options.serverModel || '')}), which a project without its own uses. ` : 'This server has no default account: a project needs its own. ') +
571
+ `Each project may make ${escape(String(options.dailyCap))} assessments a day unless its cap says otherwise.</p>`;
572
+ if (options.usage) {
573
+ const u = options.usage;
574
+ const row = (label, x) => `<tr><td>${label}</td><td>${tokens(x.calls)}</td><td>${tokens(x.input)}</td><td>${tokens(x.output)}</td>` +
575
+ `<td>${x.costed > 0 ? escape(usd(x.cost)) + (x.costed < x.calls ? ` <small>(${tokens(x.calls - x.costed)} call${x.calls - x.costed === 1 ? '' : 's'} with no known cost)</small>` : '') : (x.calls > 0 ? '<small>not known</small>' : '')}</td></tr>`;
576
+ h += '<table class="table table-sm ips-table" style="max-width: 640px"><tr><th>Usage</th><th>Assessments</th><th>Tokens in</th><th>Tokens out</th><th>Cost</th></tr>' +
577
+ row('Today', u.day) + row('Last 30 days', u.month) + row('All time', u.all) + '</table>' +
578
+ '<p class="ips-small">Cost is what the service reports (OpenRouter does), else from the price list in the server\'s configuration (<code>modules.ips.ai.prices</code>, USD per million tokens by model), else not known.</p>';
579
+ }
580
+ h += `<p>Currently: <b>${ai.enabled ? 'on' : 'off'}</b>` + (ai.keyEnc ? `, with the project's own account (${escape(PROVIDERS[ai.provider]?.name || ai.provider || '')}, model <code>${escape(ai.model || '')}</code>)` : (options.serverDefault ? ', using the server\'s default account' : ', with no account')) + '</p>';
581
+ h += `<form method="post" action="${purl}/settings" class="ips-form" style="max-width: 560px">${csrfField(ctx.csrf)}<input type="hidden" name="form" value="ai"/>`;
582
+ h += `<label><input type="checkbox" name="enabled" value="1"${ai.enabled ? ' checked' : ''}/> Assess documents with an AI service</label>`;
583
+ h += '<label for="ai-provider">Provider</label><select id="ai-provider" name="provider">' +
584
+ Object.entries(PROVIDERS).map(([k, v]) => `<option value="${k}"${(ai.provider || 'openai') === k ? ' selected' : ''}>${escape(v.name)}</option>`).join('') + '</select>';
585
+ h += `<label for="ai-baseUrl">Base URL</label><input type="text" id="ai-baseUrl" name="baseUrl" value="${escape(ai.baseUrl || '')}" placeholder="leave blank for the provider's own"/>` +
586
+ '<div class="ips-small">An OpenAI-compatible service (OpenRouter, LiteLLM, Groq, Ollama, ...) is its base URL, up to and including the version (e.g. https://openrouter.ai/api/v1)</div>';
587
+ h += `<label for="ai-model">Model</label><input type="text" id="ai-model" name="model" value="${escape(ai.model || '')}"/>`;
588
+ if (options.canKeep) {
589
+ h += `<label for="ai-apiKey">API key</label><input type="password" id="ai-apiKey" name="apiKey" autocomplete="off" placeholder="${ai.keyEnc ? 'leave blank to keep the current key' : 'leave blank to use the server\'s default account'}"/>`;
590
+ if (ai.keyEnc) {
591
+ h += '<label><input type="checkbox" name="clearKey" value="1"/> Forget the project\'s key (use the server\'s default account)</label>';
592
+ }
593
+ } else {
594
+ h += '<div class="ips-small">This server cannot keep a key for the project (no aiKeySecret is configured): the server\'s default account is used.</div>';
595
+ }
596
+ const narrative = typeof ai.sendNarrative === 'boolean' ? (ai.sendNarrative ? 'send' : 'strip') : 'default';
597
+ h += '<label for="ai-narrative">Narrative</label><select id="ai-narrative" name="narrative">' +
598
+ `<option value="default"${narrative === 'default' ? ' selected' : ''}>As the server does (${options.serverNarrative ? 'sent, as submitted' : 'not sent'})</option>` +
599
+ `<option value="send"${narrative === 'send' ? ' selected' : ''}>Sent, as submitted - the model also reports what the narrative says that the data does not back up</option>` +
600
+ `<option value="strip"${narrative === 'strip' ? ' selected' : ''}>Not sent - the structured data only (smaller, cheaper)</option></select>`;
601
+ h += `<label for="ai-dailyCap">Daily cap</label><input type="text" id="ai-dailyCap" name="dailyCap" value="${Number.isInteger(ai.dailyCap) ? ai.dailyCap : ''}" placeholder="${escape(String(options.dailyCap))}" style="width: 8em"/>`;
602
+ h += '<p style="margin-top: 10px"><button type="submit" class="btn btn-primary">Save</button></p></form>';
603
+ return h;
604
+ }
605
+
606
+ // ---- a document ------------------------------------------------------------------------
607
+
608
+ /**
609
+ * The narrative of a resource, if there is one and it is clean, as the browser will see it.
610
+ *
611
+ * The check (at submission, and again here) parses each narrative on its own. What is sent
612
+ * is the parsed tree serialised again - never the string as written - because an
613
+ * unterminated tag at the end of one narrative, which the parser drops, would otherwise
614
+ * swallow the start of the next one on the page and could turn its text into attributes of
615
+ * a live element. Serialising closes what was left open, and each narrative is wrapped in
616
+ * its own div.
617
+ */
618
+ function narrativeOf(res) {
619
+ const div = res?.text?.div;
620
+ if (typeof div !== 'string' || div.trim() === '') {
621
+ return null;
622
+ }
623
+ if (checkNarrative(div)) {
624
+ return '<p class="ips-small"><i>(narrative not shown: it contains active content)</i></p>';
625
+ }
626
+ return `<div class="ips-narrative-text">${parse5.serialize(parse5.parseFragment(div))}</div>`;
627
+ }
628
+
629
+ function sectionsHtml(sections, level) {
630
+ let h = '';
631
+ for (const s of Array.isArray(sections) ? sections : []) {
632
+ if (!isObject(s)) {
633
+ continue;
634
+ }
635
+ const tag = level <= 4 ? `h${level}` : 'h5';
636
+ h += `<${tag}>${escape(text(s.title) || text(s.code?.text) || '')}</${tag}>`;
637
+ const n = narrativeOf(s);
638
+ if (n) {
639
+ h += n;
640
+ } else if (!s.section) {
641
+ h += '<p class="ips-small"><i>(no narrative)</i></p>';
642
+ }
643
+ h += sectionsHtml(s.section, level + 1);
644
+ }
645
+ return h;
646
+ }
647
+
648
+ /**
649
+ * The document's assembled narrative: the Composition's own, then each section's, with
650
+ * the section titles.
651
+ */
652
+ function assembledNarrative(bundle) {
653
+ const comp = Array.isArray(bundle?.entry) ? bundle.entry[0]?.resource : null;
654
+ if (!isObject(comp) || comp.resourceType !== 'Composition') {
655
+ return '<p><i>The document has no Composition.</i></p>';
656
+ }
657
+ let h = '<div class="ips-narrative">';
658
+ if (text(comp.title)) {
659
+ h += `<h2>${escape(comp.title)}</h2>`;
660
+ }
661
+ h += narrativeOf(comp) || '';
662
+ h += sectionsHtml(comp.section, 3);
663
+ return h + '</div>';
664
+ }
665
+
666
+ // ---- the terminology view ----
667
+
668
+ /** Text, as a link when there is somewhere to go. */
669
+ function linked(label, href, title) {
670
+ const t = title ? ` title="${escape(title)}"` : '';
671
+ return href ? `<a href="${escape(href)}"${t}>${label}</a>` : `<span${t}>${label}</span>`;
672
+ }
673
+
674
+ /**
675
+ * The contents of a CodeableConcept: its text, and each coding as system, code, display,
676
+ * arranged as the Java DataRenderer does: the system by name with the version described
677
+ * after it in brackets - for SNOMED CT, the edition and release ("SNOMED CT [US rel. Mar
678
+ * 2024]"), with the edition's full name on hover - and the URL on hover. The external code
679
+ * systems (SNOMED CT, LOINC, RxNorm, ...) link to their own browsers, name and code; any
680
+ * other system links to the code system on the terminology server.
681
+ *
682
+ * @param {Object} cc
683
+ * @param {Function|null} codeSystemLink - system URL -> href on the tx server, or null for no server
684
+ */
685
+ function conceptHtml(cc, codeSystemLink) {
686
+ const items = [];
687
+ if (text(cc.text)) {
688
+ items.push(`<li>Text: ${escape(cc.text)}</li>`);
689
+ }
690
+ for (const c of Array.isArray(cc.coding) ? cc.coding : []) {
691
+ if (!isObject(c)) {
692
+ continue;
693
+ }
694
+ const sys = systemName(c.system);
695
+ const parts = [];
696
+ const external = isExternalSystem(c.system);
697
+ if (sys) {
698
+ const href = external ? linkForSystem(c.system) : (codeSystemLink ? codeSystemLink(c.system) : null);
699
+ let h = linked(escape(sys), href, text(c.system));
700
+ if (text(c.version)) {
701
+ const v = describeVersion(c.version);
702
+ h += ` <small>[${linked(escape(v.text), null, v.title ? `${v.title} (${c.version})` : c.version)}]</small>`;
703
+ }
704
+ parts.push(h);
705
+ }
706
+ const codeHref = external ? linkForCode(c.system, c.version, c.code) : null;
707
+ parts.push(`<code>${linked(escape(text(c.code)), codeHref, null)}</code>`);
708
+ if (text(c.display)) {
709
+ parts.push(`"${escape(c.display)}"`);
710
+ }
711
+ // a coding the translate step added (in the analysed copy)
712
+ const tr = translationOf(c);
713
+ if (tr) {
714
+ const detail = [`translated by ${tr.server || 'the terminology server'}`, tr.conceptMap ? `with ${tr.conceptMap}` : '', tr.relationship ? `(${tr.relationship})` : ''].filter(Boolean).join(' ');
715
+ parts.push(`<span class="ips-translated" title="${escape(detail)}">translated${tr.relationship ? `, ${escape(tr.relationship)}` : ''}</span>`);
716
+ }
717
+ items.push(`<li>${parts.join(' ')}</li>`);
718
+ }
719
+ if (items.length === 0) {
720
+ return '<i>(empty)</i>';
721
+ }
722
+ return `<ul>${items.join('')}</ul>`;
723
+ }
724
+
725
+ /**
726
+ * The analysis of a concept: the terminology check's verdict from the analysed copy - a
727
+ * green tick (a coding is in an IPS value set), a red cross (none is), or an orange question
728
+ * mark (the server could not say) - with the detail on hover; nothing for an element the
729
+ * IPS doesn't bind, or a document not yet analysed.
730
+ */
731
+ function analysisHtml(concept, analysed) {
732
+ // verdicts come from the analysed copy only: the master is the submitter's, extensions
733
+ // and all
734
+ const check = analysed ? checkOf(concept) : null;
735
+ if (!check) {
736
+ return '';
737
+ }
738
+ const symbol = { in: '&#10003;', international: '&#9888;', 'not-in': '&#10007;', unknown: '?' }[check.result];
739
+ const name = (url) => url.substring(url.lastIndexOf('/') + 1);
740
+ const title = [check.message, check.valueSet ? `Value set: ${check.valueSet}` : '', check.system ? `Code system: ${check.system}` : ''].filter(Boolean).join('\n');
741
+ return `<span class="ips-check ips-check-${check.result}" title="${escape(title)}">${symbol}</span>` +
742
+ (check.valueSet ? ` <span class="ips-small">${escape(name(check.valueSet))}</span>` : check.system ? ` <span class="ips-small">${escape(name(check.system))}</span>` : '');
743
+ }
744
+
745
+ function conceptRowFor(c, codeSystemLink, analysed) {
746
+ return `<tr><td><code>${escape(c.path)}</code></td><td>${conceptHtml(c.concept, codeSystemLink)}</td><td>${analysisHtml(c.concept, analysed)}</td>` +
747
+ `<td><button type="button" class="btn btn-default ips-copy" data-json="${escape(JSON.stringify(c.concept))}">Copy</button></td></tr>`;
748
+ }
749
+
750
+ /**
751
+ * The terminology view: every CodeableConcept in the document, by section and resource.
752
+ * @param {Object} bundle
753
+ * @param {{codeSystemLink?: Function|null}} [ctx]
754
+ */
755
+ function renderTerminology(bundle, ctx = {}) {
756
+ const t = terminologyOf(bundle);
757
+ const link = ctx.codeSystemLink || null;
758
+ const conceptRow = (c) => conceptRowFor(c, link, !!ctx.analysed);
759
+ // "problems only": just the concepts whose check found something wrong (not in a value
760
+ // set, or undeterminable); the rest, and the section and resource headings left with
761
+ // nothing under them, are hidden
762
+ const problemsOnly = !!ctx.problemsOnly && !!ctx.analysed;
763
+ const isProblem = (c) => {
764
+ const check = checkOf(c.concept);
765
+ return !!check && check.result !== 'in';
766
+ };
767
+ const shown = (concepts) => (problemsOnly ? concepts.filter(isProblem) : concepts);
768
+ let total = t.composition.length;
769
+ let kept = shown(t.composition).length;
770
+ for (const s of t.sections) {
771
+ for (const e of s.entries) {
772
+ total += e.concepts.length;
773
+ kept += shown(e.concepts).length;
774
+ }
775
+ }
776
+ // the explanation goes at the bottom of the page; the filter stays at the top
777
+ const explanation = '<p class="ips-small">Every CodeableConcept in the document: the Composition\'s own, then each section\'s entries, ' +
778
+ 'sections in the IPS order. Section codes, categories, and the clinical and verification status of conditions and allergies are left out. ' +
779
+ 'Analysis: whether any coding is in the value set this server holds the element to for international exchange - ' +
780
+ '<span class="ips-check ips-check-in">&#10003;</span> yes (or, for an element with no value set of its own, the coding is international anyway); ' +
781
+ '<span class="ips-check ips-check-international">&#9888;</span> no, but a coding is international (SNOMED CT International, LOINC, or an HL7 terminology); ' +
782
+ '<span class="ips-check ips-check-not-in">&#10007;</span> no coding is; ' +
783
+ '<span class="ips-check ips-check-unknown">?</span> the terminology server could not say' +
784
+ (ctx.analysed ? '' : '; blank until the document has been processed') + '. The value set, or the code system, is named beside the mark, in full on hover. ' +
785
+ 'A coding marked <span class="ips-translated">translated</span> was added by this server, translating the concept into the value set; the server, concept map and relationship are on hover.</p>';
786
+ let h = '';
787
+ if (ctx.analysed) {
788
+ h += `<form method="get" action="${escape(ctx.docUrl || '')}" class="ips-filter mb-2"><input type="hidden" name="view" value="terminology"/>` +
789
+ `<label><input type="checkbox" name="problems" value="1"${problemsOnly ? ' checked' : ''} onchange="this.form.submit()"/> Only concepts with a problem</label>` +
790
+ '<noscript><button type="submit" class="btn btn-default ips-btn">Apply</button></noscript>' +
791
+ (problemsOnly ? `<span class="ips-small">Showing ${kept} of ${total} concepts</span>` : '') + '</form>';
792
+ }
793
+ h += '<table class="table table-sm table-bordered ips-table ips-term"><tr><th style="width: 30%">Element</th><th>Contents</th><th style="width: 25%">Analysis</th><th></th></tr>';
794
+ const compositionRows = shown(t.composition);
795
+ if (!problemsOnly || compositionRows.length > 0) {
796
+ h += '<tr><th colspan="4" class="ips-section">Composition</th></tr>';
797
+ h += t.composition.length === 0 ? '<tr><td colspan="4"><i>No CodeableConcepts</i></td></tr>' : compositionRows.map(conceptRow).join('');
798
+ }
799
+ for (const s of t.sections) {
800
+ const entries = problemsOnly ? s.entries.filter(e => shown(e.concepts).length > 0) : s.entries;
801
+ if (problemsOnly && entries.length === 0) {
802
+ continue;
803
+ }
804
+ h += `<tr><th colspan="4" class="ips-section">${escape(s.title)}${s.known ? '' : ' <small>(not an IPS section)</small>'}</th></tr>`;
805
+ if (s.entries.length === 0) {
806
+ h += '<tr><td colspan="4"><i>No entries</i></td></tr>';
807
+ }
808
+ for (const e of entries) {
809
+ h += `<tr><th colspan="4" class="ips-resource">${escape(e.label)}${e.resolved ? '' : ' - <b>not found in the document</b>'}</th></tr>`;
810
+ if (e.resolved && e.concepts.length === 0) {
811
+ h += '<tr><td colspan="4"><i>No CodeableConcepts</i></td></tr>';
812
+ }
813
+ h += shown(e.concepts).map(conceptRow).join('');
814
+ }
815
+ }
816
+ if (problemsOnly && kept === 0) {
817
+ h += '<tr><td colspan="4"><i>No problems: every checked concept is in a value set the IPS prefers.</i></td></tr>';
818
+ }
819
+ h += '</table>';
820
+ h += `<div class="ips-explain">${explanation}</div>`;
821
+ h += `<script>
822
+ document.querySelectorAll('.ips-copy').forEach(function (b) {
823
+ b.addEventListener('click', function () {
824
+ var json = JSON.stringify(JSON.parse(b.getAttribute('data-json')), null, 2);
825
+ var done = function () { var t = b.textContent; b.textContent = 'Copied'; setTimeout(function () { b.textContent = t; }, 1200); };
826
+ if (navigator.clipboard && navigator.clipboard.writeText) {
827
+ navigator.clipboard.writeText(json).then(done, function () { window.prompt('Copy the JSON:', json); });
828
+ } else {
829
+ window.prompt('Copy the JSON:', json);
830
+ }
831
+ });
832
+ });
833
+ </script>`;
834
+ return h;
835
+ }
836
+
837
+ // ---- the validation view ----
838
+
839
+ const SEVERITY_COLOURS = { fatal: '#c82333', error: '#c82333', warning: '#d39e00', information: '#17a2b8' };
840
+
841
+ /** Where an issue is: its expression (FHIRPath), else its location. */
842
+ function issueWhere(i) {
843
+ const e = Array.isArray(i.expression) ? i.expression.filter(x => typeof x === 'string') : [];
844
+ if (e.length > 0) {
845
+ return e;
846
+ }
847
+ return Array.isArray(i.location) ? i.location.filter(x => typeof x === 'string') : [];
848
+ }
849
+
850
+ /** The validator's message for an issue: details.text, else diagnostics. */
851
+ function issueMessage(i) {
852
+ return text(i.details?.text) || text(i.diagnostics) || '';
853
+ }
854
+
855
+ // the minimum severity the validation view shows, and how it is said
856
+ const LEVELS = [
857
+ ['information', 'Everything'],
858
+ ['warning', 'Warnings and errors'],
859
+ ['error', 'Errors only']
860
+ ];
861
+ const SEVERITY_RANK = { fatal: 3, error: 2, warning: 1, information: 0 };
862
+
863
+ /**
864
+ * Why a step has no result to show: the document is still queued, or this step failed (its
865
+ * reason, for managers), or an earlier step failed so it never ran, or it was never run.
866
+ */
867
+ function noResultNote(row, step, what, options = {}) {
868
+ if (row.status === 'submitted' || row.status === 'processing') {
869
+ return `<p><i>${what} - the document is ${escape(row.status === 'processing' ? 'being processed' : 'waiting in the queue')}.</i></p>`;
870
+ }
871
+ const r = row.results?.[step]?.result;
872
+ if (r?.status === 'failed') {
873
+ return `<p><i>The ${escape(step)} step failed${options.canManage && r.message ? `: ${escape(r.message)}` : ' (the reason is shown to the project\'s managers)'}.</i></p>`;
874
+ }
875
+ if (r?.status === 'waiting') {
876
+ return `<p><i>The ${escape(step)} step did not run: an earlier step failed${options.canManage && row.status_message ? ` (${escape(row.status_message)})` : ''}.</i></p>`;
877
+ }
878
+ return `<p><i>${what}${row.status === 'error' ? ' - processing failed' + (options.canManage && row.status_message ? `: ${escape(row.status_message)}` : '') : ''}.</i></p>`;
879
+ }
880
+
881
+ // a clinical profile's outcome on the page: [label, colour]
882
+ const PROFILE_OUTCOMES = {
883
+ conforms: ['Conforms', '#1e7e34'],
884
+ fails: ['Does not conform', '#c82333'],
885
+ unknown: ['Could not be fully checked', '#d39e00']
886
+ };
887
+
888
+ /**
889
+ * The profiles step's result: for each clinical profile, whether the document conforms,
890
+ * and the descriptions of the constraints it doesn't meet.
891
+ */
892
+ function renderProfileChecks(row, options = {}) {
893
+ const r = row.results?.profiles?.result;
894
+ if (!r || (r.status && r.status !== 'done') || !Array.isArray(r.profiles)) {
895
+ return noResultNote(row, 'profiles', 'The document has not been checked against the clinical profiles', options);
896
+ }
897
+ const base = escape(options.base || '');
898
+ let h = '';
899
+ if (!r.profiles.length) {
900
+ return `<p>There were no <a href="${base}/StructureDefinition">clinical profiles</a> to check the document against when it was processed.</p>`;
901
+ }
902
+ h += '<table class="table table-sm ips-table"><tr><th>Clinical profile</th><th>Outcome</th><th>Constraints not met</th></tr>';
903
+ for (const p of r.profiles) {
904
+ const [label, colour] = PROFILE_OUTCOMES[p.outcome] || [p.outcome, '#6c757d'];
905
+ const failed = (p.failed || []).map(f => {
906
+ const why = f.reason === 'unresolved' ? ' <span class="ips-small">(could not be checked: a value set the terminology server could not answer for' +
907
+ (options.canManage && f.message ? ` - ${escape(f.message)}` : '') + ')</span>'
908
+ : (f.reason === 'error' ? ` <span class="ips-small">(could not be evaluated${options.canManage && f.message ? `: ${escape(f.message)}` : ''})</span>` : '');
909
+ return `<li>${escape(f.human)}${why}</li>`;
910
+ }).join('');
911
+ h += `<tr><td><a href="${base}/StructureDefinition/${escape(p.id)}">${escape(p.title)}</a></td>` +
912
+ `<td><span class="ips-badge" style="background: ${colour}">${escape(label)}</span></td>` +
913
+ `<td>${failed ? `<ul style="margin: 0; padding-left: 18px">${failed}</ul>` : '<span class="ips-small">(none)</span>'}</td></tr>`;
914
+ }
915
+ h += '</table>';
916
+ h += `<p class="ips-small">Checked against the clinical profiles as they were when the document was processed (${r.profiles.length} profile${r.profiles.length === 1 ? '' : 's'}` +
917
+ (r.questions ? `, ${r.questions} value set question${r.questions === 1 ? '' : 's'} to the terminology server` : '') +
918
+ `, ${escape(String(r.ms))} ms); reprocess the document to check it against them as they are now.</p>`;
919
+ return h;
920
+ }
921
+
922
+ // what the translate step's outcomes mean on the page: [label, colour]
923
+ const TRANSLATION_OUTCOMES = {
924
+ translated: ['Translated', '#1e7e34'],
925
+ 'no-translation': ['No translation', '#c82333'],
926
+ failed: ['Failed', '#c82333'],
927
+ 'not-needed': ['Not needed', '#6c757d'],
928
+ 'not-checked': ['Not checked', '#fd7e14'],
929
+ 'no-codings': ['No codings', '#6c757d']
930
+ };
931
+
932
+ /** A coding, briefly: system|code "display". */
933
+ function briefCoding(c) {
934
+ return `<code>${escape(`${systemName(c.system) || c.system || ''}|${c.code}`)}</code>` + (typeof c.display === 'string' && c.display ? ` "${escape(c.display)}"` : '');
935
+ }
936
+
937
+ /**
938
+ * The translation view: what the translate step did with each CodeableConcept - left it
939
+ * (already in its value set, or the check could not say), translated it (the codings that
940
+ * came back, with their concept maps and relationships), or found no translation.
941
+ * @param {Object} row - from store.read(), with .results.translate or not
942
+ */
943
+ function renderTranslation(row, options = {}) {
944
+ const r = row.results?.translate;
945
+ if (!r || (r.result.status && r.result.status !== 'done') || !Array.isArray(r.result.entries)) {
946
+ return noResultNote(row, 'translate', 'There is no translation record for this document', options);
947
+ }
948
+ const v = r.result;
949
+ const counts = v.counts || {};
950
+ let h = `<p class="ips-small">Translated ${escape(r.made)}` + (v.server ? ` with ${escape(String(v.server))}` : '') +
951
+ (typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') + '</p>';
952
+ h += `<p><b>${escape(`${counts.translated || 0} of ${counts.tried || 0} concepts translated (${counts.codings || 0} codings added)`)}` +
953
+ (counts.failed ? escape(`, ${counts.failed} failed`) : '') + '</b></p>';
954
+ 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
+ for (const e of v.entries) {
956
+ const [label, colour] = TRANSLATION_OUTCOMES[e.outcome] || [e.outcome || '?', '#6c757d'];
957
+ const codings = (Array.isArray(e.codings) ? e.codings : []).map(c => `<li>${briefCoding(c)}</li>`);
958
+ if (e.text) {
959
+ codings.unshift(`<li>Text: ${escape(e.text)}</li>`);
960
+ }
961
+ let detail = e.message ? escape(e.message) : '';
962
+ if (Array.isArray(e.added) && e.added.length > 0) {
963
+ detail += '<ul>' + e.added.map(m => `<li>${briefCoding(m.coding)}` + (m.relationship ? ` <i>${escape(m.relationship)}</i>` : '') +
964
+ (m.conceptMap ? ` from <code>${escape(m.conceptMap)}</code>` : '') + (m.present ? ' (already in the concept)' : '') + '</li>').join('') + '</ul>';
965
+ }
966
+ h += `<tr><td><code>${escape(e.path || '')}</code></td><td>${codings.length ? `<ul>${codings.join('')}</ul>` : '<i>(empty)</i>'}</td>` +
967
+ `<td><span title="${escape(e.target || '')}">${escape(e.target ? e.target.substring(e.target.lastIndexOf('/') + 1) : '')}</span>${e.ownValueSet ? '' : ' <small>(fallback)</small>'}</td>` +
968
+ `<td><span class="ips-badge" style="background-color: ${colour}">${escape(label)}</span></td><td>${detail}</td></tr>`;
969
+ }
970
+ h += '</table>';
971
+ h += '<div class="ips-explain"><p class="ips-small">What the translate step did with each CodeableConcept, before validation: a concept already in the value set this server holds its element to ' +
972
+ '(or, with no value set of its own, already international) needs no translation; one the terminology server could not check is left alone; the rest are sent to the server to translate into the value set ' +
973
+ '(<code>ConceptMap/$translate</code>), and the codings that come back are added to the concept in the analysed copy, with the concept map and relationship the server gave for each.</p></div>';
974
+ return h;
975
+ }
976
+
977
+ /**
978
+ * The generated narrative view: what the narrative step made of the document.
979
+ * @param {Object} row - from store.read(), with .results.narrative or not
980
+ */
981
+ function renderGenerated(row, options = {}) {
982
+ const r = row.results?.narrative;
983
+ if (!r || (r.result.status && r.result.status !== 'done') || typeof r.result.html !== 'string') {
984
+ return noResultNote(row, 'narrative', 'There is no generated narrative for this document', options);
985
+ }
986
+ const v = r.result;
987
+ let h = `<p class="ips-small">Generated ${escape(r.made)}` + (typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') +
988
+ (v.lookups ? `, with ${escape(String(v.lookups))} display lookup${v.lookups === 1 ? '' : 's'}` : '') + '</p>';
989
+ // our own output, but checked and parsed and re-serialised like any narrative, never the string itself
990
+ if (checkNarrative(v.html)) {
991
+ return h + '<p><i>The generated narrative is not shown: it failed the active content check.</i></p>';
992
+ }
993
+ h += `<div class="ips-narrative ips-generated">${parse5.serialize(parse5.parseFragment(v.html))}</div>`;
994
+ 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
+ '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
+ '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>';
997
+ return h;
998
+ }
999
+
1000
+ const tokens = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '?');
1001
+ const usd = (n) => (n < 0.01 && n > 0 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`);
1002
+
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' };
1005
+
1006
+ /**
1007
+ * The AI assessment view: the grade, the summary, and the findings.
1008
+ * @param {Object} row - from store.read(), with .results.ai or not
1009
+ */
1010
+ function renderAi(row, options = {}) {
1011
+ const r = row.results?.ai;
1012
+ if (!r || (r.result.status && r.result.status !== 'done')) {
1013
+ return noResultNote(row, 'ai', 'There is no AI assessment for this document', options);
1014
+ }
1015
+ const v = r.result;
1016
+ if (v.skipped) {
1017
+ return `<p><i>The document was not assessed: ${escape(v.skipped)}.</i></p>`;
1018
+ }
1019
+ let h = `<p class="ips-small">Assessed ${escape(r.made)} by ${escape(String(v.model || ''))} (${escape(String(v.provider || ''))}, the ${escape(String(v.account || ''))}'s account)` +
1020
+ (v.withNarrative ? ', with the narrative as submitted' : ', without the narrative') +
1021
+ (typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') +
1022
+ (v.usage && (v.usage.input !== null || v.usage.output !== null) ? `; ${escape(tokens(v.usage.input))} tokens in, ${escape(tokens(v.usage.output))} out` : '') +
1023
+ (typeof v.cost === 'number' ? ` (${escape(usd(v.cost))})` : '') +
1024
+ (v.truncated ? '; <b>the answer was cut off at the token limit</b>' : '') +
1025
+ (typeof v.bytesSent === 'number' ? `; ${escape(v.bytesSent.toLocaleString('en-US'))} bytes sent` : '') + '</p>';
1026
+ const a = v.assessment;
1027
+ if (!a) {
1028
+ h += '<p><b>The model did not answer with the expected JSON.</b>' +
1029
+ (v.truncated ? ` Its answer was cut off at the limit of ${escape(tokens(v.maxTokens))} tokens - raise <code>modules.ips.ai.maxTokens</code>.` : '') + ' Its answer:</p>';
1030
+ h += `<pre class="ips-json">${escape(String(v.raw || ''))}</pre>`;
1031
+ return h;
1032
+ }
1033
+ 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
+ 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>';
1036
+ if (a.consistency.length === 0) {
1037
+ h += '<p><i>No findings</i></p>';
1038
+ } else {
1039
+ h += '<table class="table table-sm table-striped ips-table"><tr><th>Severity</th><th>Finding</th><th>Where</th></tr>' +
1040
+ a.consistency.map(x => `<tr><td>${sev(x.severity)}</td><td>${escape(x.finding)}</td><td>${escape(x.where || '')}</td></tr>`).join('') + '</table>';
1041
+ }
1042
+ h += '<h4>Missing</h4>';
1043
+ h += a.missing.length === 0 ? '<p><i>Nothing noted as missing</i></p>' : `<ul>${a.missing.map(x => `<li>${escape(x)}</li>`).join('')}</ul>`;
1044
+ h += '<h4>Terminology</h4>';
1045
+ if (a.terminology.length === 0) {
1046
+ h += '<p><i>No findings</i></p>';
1047
+ } else {
1048
+ h += '<table class="table table-sm table-striped ips-table"><tr><th>Severity</th><th>Finding</th><th>Codes</th></tr>' +
1049
+ 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
+ }
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>`;
1060
+ 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
+ '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
+ return h;
1063
+ }
1064
+
1065
+ /**
1066
+ * The validation view: what the validator made of the document.
1067
+ * @param {Object} row - from store.read(), with .results.validation (from store.readResult) or not
1068
+ * @param {{level?: string, docUrl?: string}} [options] - level: the least severity shown
1069
+ * (information, the default, shows everything); docUrl: the page's address, for the form
1070
+ */
1071
+ function renderValidation(row, options = {}) {
1072
+ const r = row.results?.validation;
1073
+ // a step that has only been marked waiting, running or failed has made nothing yet
1074
+ if (!r || (r.result.status && r.result.status !== 'done')) {
1075
+ return noResultNote(row, 'validation', 'There is no validation result for this document', options);
1076
+ }
1077
+ const v = r.result;
1078
+ const counts = v.counts || {};
1079
+ let h = `<p class="ips-small">Validated ${escape(r.made)}` +
1080
+ (v.validatorVersion ? ` with validator ${escape(String(v.validatorVersion))}` : '') +
1081
+ (Array.isArray(v.profiles) && v.profiles.length ? ` against ${v.profiles.map(p => `<code>${escape(p)}</code>`).join(', ')}` : '') +
1082
+ (typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') + '</p>';
1083
+ h += `<p><b>${escape(issueSummary(counts))}</b></p>`;
1084
+ const all = Array.isArray(v.outcome?.issue) ? v.outcome.issue.filter(isObject) : [];
1085
+ if (all.length === 0) {
1086
+ return h;
1087
+ }
1088
+ const level = LEVELS.some(([l]) => l === options.level) ? options.level : 'information';
1089
+ const min = SEVERITY_RANK[level];
1090
+ const issues = all.filter(i => (SEVERITY_RANK[text(i.severity)] ?? 3) >= min);
1091
+ // 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"/>` +
1093
+ '<label for="f-level">Show</label> <select id="f-level" name="level" onchange="this.form.submit()">' +
1094
+ LEVELS.map(([l, label]) => `<option value="${l}"${l === level ? ' selected' : ''}>${escape(label)}</option>`).join('') +
1095
+ '</select><noscript><button type="submit" class="btn btn-default ips-btn">Apply</button></noscript>';
1096
+ if (issues.length < all.length) {
1097
+ const which = level === 'error' ? 'errors and above' : 'warnings and above';
1098
+ h += `<span class="ips-small">Showing ${issues.length} of ${all.length} issues (${which})</span>`;
1099
+ }
1100
+ h += '</form>';
1101
+ // the severity is the colour of the code (its name on hover); paths break at their dots,
1102
+ // slashes and hyphens (only), so that the message keeps its room
1103
+ h += '<table class="table table-sm table-striped ips-table ips-issues"><tr><th>Code</th><th>Location</th><th>Message</th></tr>';
1104
+ for (const i of issues) {
1105
+ const sev = text(i.severity);
1106
+ h += `<tr><td><span class="ips-badge" style="background-color: ${SEVERITY_COLOURS[sev] || '#495057'}" title="${escape(sev)}">${escape(text(i.code) || sev)}</span></td>` +
1107
+ `<td class="ips-path">${issueWhere(i).map(w => `<code>${escape(w).replace(/([./-])/g, '$1<wbr/>')}</code>`).join('<br/>')}</td>` +
1108
+ `<td>${escape(issueMessage(i))}</td></tr>`;
1109
+ }
1110
+ 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'; });
1119
+ });
1120
+ </script>`;
1121
+ return h;
1122
+ }
1123
+
1124
+ /** The editor page: a container the script fills, and what it needs. */
1125
+ function renderCompose(project, ctx, options) {
1126
+ const purl = projectUrl(ctx, project);
1127
+ let h = STYLE + userBar(ctx) + crumbs(ctx, project, options.from ? `Edit document ${options.from}` : 'Compose');
1128
+ h += `<h3>${options.from ? `Edit document ${options.from}` : 'Compose a document'}</h3>`;
1129
+ h += '<p>Build an International Patient Summary here: the patient, the document\'s author, and the sections. ' +
1130
+ (options.from ? 'The form holds what it can of the original document; submitting makes a new document in the project. ' : '') +
1131
+ (options.canLookup ? 'Coded fields look codes up on the terminology server as you type (in the value sets this server holds the element to); ' : 'This server has no terminology server to look codes up on (modules.ips.txServer), so coded fields take text only; ') +
1132
+ 'anything can also be entered as plain text. What you enter stays in this browser until you submit it' +
1133
+ (options.aiEnabled ? '; submitted documents are sent to an external AI service for assessment' : '') + '.</p>';
1134
+ h += '<noscript><div class="alert alert-warning">The editor needs scripts; without them, use the <a href="' + purl + '/submit">submission form</a>.</div></noscript>';
1135
+ h += '<div id="ips-editor"></div>';
1136
+ const cfg = {
1137
+ project: project.code, projectName: project.name, schema: options.schema, model: options.model, needsToken: !!options.needsToken,
1138
+ urls: { compose: `${purl}/compose`, lookup: options.canLookup ? `${purl}/lookup` : null, back: purl }
1139
+ };
1140
+ // JSON in a script: </script> and the like must not end the block
1141
+ 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>`;
1143
+ return h;
1144
+ }
1145
+
1146
+ // ---- clinical profiles -----------------------------------------------------------------
1147
+
1148
+ function profileCrumbs(ctx, extra) {
1149
+ let h = `<p class="ips-small"><a href="${escape(ctx.base)}">IPS projects</a> &raquo; <a href="${escape(ctx.base)}/StructureDefinition">Clinical profiles</a>`;
1150
+ return h + (extra ? ` &raquo; ${escape(extra)}` : '') + '</p>';
1151
+ }
1152
+
1153
+ /** The list of clinical profiles; the administrator can make, and upload, new ones. */
1154
+ function renderProfiles(list, ctx) {
1155
+ const base = escape(ctx.base);
1156
+ let h = STYLE + userBar(ctx) + `<p class="ips-small"><a href="${base}">IPS projects</a> &raquo; Clinical profiles</p>`;
1157
+ h += '<div class="ips-banner"><b class="ips-project-name">Clinical profiles</b></div>';
1158
+ if (ctx.user?.isAdmin) {
1159
+ h += '<div class="ips-actions-line">' +
1160
+ `<a class="btn btn-primary" href="${base}/StructureDefinition/new">New profile</a> ` +
1161
+ '<label class="btn btn-default" style="margin: 0">Upload a profile (JSON)<input type="file" id="ips-pf-upload" accept=".json,application/json,application/fhir+json" hidden/></label>' +
1162
+ '<span id="ips-pf-upload-msg" class="ips-small" style="margin-left: 8px"></span></div>';
1163
+ }
1164
+ h += '<p>A clinical profile says what a kind of clinical document is expected to contain: a profile on Bundle (the document), with a constraint for each expectation - ' +
1165
+ 'usually that a section is there, or that a section has entries with a code. They apply across all projects.' +
1166
+ (ctx.user?.isAdmin ? '' : ' Only the administrator can change them.') + '</p>';
1167
+ if (list.length === 0) {
1168
+ h += '<p>There are no clinical profiles yet.</p>';
1169
+ } else {
1170
+ h += '<table class="table table-sm table-striped ips-table"><tr><th>Profile</th><th>Constraints</th><th>Updated</th><th></th></tr>';
1171
+ for (const p of list) {
1172
+ const url = `${base}/StructureDefinition/profile-${escape(p.id)}`;
1173
+ h += `<tr><td><a href="${url}">${escape(p.title)}</a></td><td>${p.constraints}</td><td>${escape(dateOnly(new Date(p.updated_ms).toISOString()))}</td>` +
1174
+ `<td><a href="${url}?download=1">Download JSON</a></td></tr>`;
1175
+ }
1176
+ h += '</table>';
1177
+ }
1178
+ h += `<p class="ips-small">As FHIR: <code>GET ${base}/StructureDefinition</code> (a searchset of the StructureDefinitions), <code>GET ${base}/StructureDefinition/{id}</code>.</p>`;
1179
+ if (ctx.user?.isAdmin) {
1180
+ h += `<script>(function () {
1181
+ var input = document.getElementById('ips-pf-upload');
1182
+ var msg = document.getElementById('ips-pf-upload-msg');
1183
+ input.addEventListener('change', function () {
1184
+ var f = input.files && input.files[0];
1185
+ if (!f) { return; }
1186
+ msg.textContent = 'Uploading ' + f.name + '...';
1187
+ f.text().then(function (t) {
1188
+ var sd;
1189
+ try { sd = JSON.parse(t); } catch (e) { msg.textContent = f.name + ' is not JSON'; return; }
1190
+ return fetch(${JSON.stringify(ctx.base + '/StructureDefinition')}, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
1191
+ body: JSON.stringify({ upload: sd, _csrf: ${JSON.stringify(ctx.csrf || '').replace(/</g, '\\u003c')} }) })
1192
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }, function () { return { ok: false, j: { problems: ['The server answered ' + r.status] } }; }); })
1193
+ .then(function (a) { if (a.ok) { location.href = a.j.url; } else { msg.textContent = 'Not uploaded: ' + (a.j.problems || []).join('; '); } });
1194
+ }).finally(function () { input.value = ''; });
1195
+ });
1196
+ })();</script>`;
1197
+ }
1198
+ return h;
1199
+ }
1200
+
1201
+ // the parts of a profile, as the view and the editor show them
1202
+ const PROFILE_PARTS = [['section', 'Required sections and entries'], ['resource', 'Rules about resources'], ['other', 'Other rules']];
1203
+
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>`;
1226
+ }
1227
+
1228
+ /** A clinical profile, read-only (the administrator gets edit and delete). */
1229
+ function renderProfile(sd, ctx) {
1230
+ const base = escape(ctx.base);
1231
+ const url = `${base}/StructureDefinition/${escape(sd.id)}`;
1232
+ let h = STYLE + userBar(ctx) + profileCrumbs(ctx, sd.title);
1233
+ h += `<div class="ips-banner"><b class="ips-project-name">${escape(sd.title)}</b>` +
1234
+ `<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
+ h += '<div class="ips-actions-line">' +
1236
+ `<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> ` +
1238
+ `<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;
1248
+ }
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>`;
1250
+ for (const c of these) {
1251
+ h += `<tr><td>${escape(c.human || '')}</td><td>${ruleHtml(c.expression || '', ctx.valueSetTitle)}</td></tr>`;
1252
+ }
1253
+ h += '</table>';
1254
+ }
1255
+ }
1256
+ 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
+ '(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
+ '(their <code>code</code>, <code>medicationCodeableConcept</code> or <code>vaccineCode</code>), when there are min..max of them; ' +
1259
+ '<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>';
1261
+ return h;
1262
+ }
1263
+
1264
+ /** The profile editor (the administrator's): the page carries the profile, the script edits it. */
1265
+ function renderProfileEditor(sd, ctx, options) {
1266
+ const base = ctx.base;
1267
+ let h = STYLE + userBar(ctx) + profileCrumbs(ctx, sd ? sd.title : 'New profile');
1268
+ h += `<h3>${sd ? 'Edit clinical profile' : 'New clinical profile'}</h3>`;
1269
+ h += '<p>The title, and the expectations: each a description, and a rule. A rule that a section is there, or that a section has entries with a code, ' +
1270
+ 'is a row of the table - the section (one of the IPS sections, or any code), how many there may be, and optionally the code of the entries and how many - ' +
1271
+ 'where a cardinality is blank for any number, or <code>min..max</code> (<code>1..1</code>, <code>1..*</code>). A rule about the resources in the document with a code ' +
1272
+ '(<code>Bundle.resources(system, code, type)...</code>) is a row of the second table: the type (or any), the code, and the rest of the rule, as FHIRPath on those resources ' +
1273
+ '(<code>.exists()</code>, <code>.count() = 1</code>, <code>.all(clinicalStatus.coding.code = \'active\')</code>). Entries and resources can be matched by a value set instead of a code: ' +
1274
+ 'choose <i>In a value set...</i> as the system, and search for one by title, or paste its canonical. Any other rule is a FHIRPath expression on the document (the Bundle).' +
1275
+ (options.canLookup ? '' : ' (There is no terminology server, so codes can\'t be looked up.)') + '</p>';
1276
+ h += '<noscript><div class="alert alert-warning">The editor needs scripts.</div></noscript>';
1277
+ h += '<div id="ips-profile-editor"></div>';
1278
+ const cfg = {
1279
+ profile: options.profile, sections: options.sections, systems: options.systems, resourceTypes: options.resourceTypes, sectionSystem: options.sectionSystem, csrf: ctx.csrf || '',
1280
+ parts: PROFILE_PARTS,
1281
+ urls: {
1282
+ save: sd ? `${base}/StructureDefinition/${sd.id}` : `${base}/StructureDefinition`,
1283
+ lookup: options.canLookup ? `${base}/lookup` : null,
1284
+ lookupVs: `${base}/lookup-vs`,
1285
+ back: sd ? `${base}/StructureDefinition/${sd.id}` : `${base}/StructureDefinition`
1286
+ }
1287
+ };
1288
+ 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>`;
1290
+ return h;
1291
+ }
1292
+
1293
+ /** The QR code and its links, as the share page and the panel show them. */
1294
+ function shareHtml(out) {
1295
+ const kind = out.kind === 'vhl' ? 'VHL' : 'SHL';
1296
+ let h = `<div class="ips-share-code">${out.qr}</div>`;
1297
+ h += `<p class="ips-small">${kind === 'VHL' ? 'A Verifiable Health Link, signed as the GDHCN does it: the code carries the signed link' : 'A SMART Health Link: the code opens the document in a viewer'}; ` +
1298
+ `the document is encrypted on the SHL server until ${escape(dateText(out.expires))}. Scan it, or copy what it carries:</p>`;
1299
+ h += `<p><textarea class="ips-share-text" readonly rows="3">${escape(out.text)}</textarea></p>`;
1300
+ h += `<p class="ips-small">Manifest: <a href="${escape(out.link)}">${escape(out.link)}</a></p>`;
1301
+ return h;
1302
+ }
1303
+
1304
+ /** The page a share lands on when the browser has no scripts. */
1305
+ function renderShare(project, row, out, ctx) {
1306
+ const durl = `${projectUrl(ctx, project)}/Bundle/${row.id}`;
1307
+ let h = STYLE + userBar(ctx) + crumbs(ctx, project, `Document ${row.id}`);
1308
+ h += `<h3>Document ${row.id} as ${out.kind === 'vhl' ? 'a VHL' : 'an SHL'}</h3>`;
1309
+ h += shareHtml(out);
1310
+ h += `<p><a href="${durl}">Back to the document</a></p>`;
1311
+ return h;
1312
+ }
1313
+
1314
+ /** The panel over the page that shows a share, and the script that asks for one. */
1315
+ function sharePanelScript() {
1316
+ return '<div id="ips-share" class="ips-share-overlay" hidden><div class="ips-share-panel"><button type="button" class="close ips-share-close" aria-label="Close">&times;</button><div class="ips-share-body"></div></div></div>' +
1317
+ `<script>
1318
+ (function () {
1319
+ var overlay = document.getElementById('ips-share');
1320
+ var body = overlay.querySelector('.ips-share-body');
1321
+ var escapeHtml = function (s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
1322
+ var close = function () { overlay.hidden = true; body.innerHTML = ''; };
1323
+ overlay.querySelector('.ips-share-close').addEventListener('click', close);
1324
+ overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(); } });
1325
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !overlay.hidden) { close(); } });
1326
+ var fmt = function (d) { var m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; var x = new Date(d); return isNaN(x) ? d : ('0' + x.getDate()).slice(-2) + '-' + m[x.getMonth()] + ' ' + String(x.getFullYear()).slice(-2); };
1327
+ document.querySelectorAll('.ips-share-form').forEach(function (form) {
1328
+ form.addEventListener('submit', function (e) {
1329
+ e.preventDefault();
1330
+ var kind = form.querySelector('[name=kind]').value;
1331
+ var button = form.querySelector('button');
1332
+ button.disabled = true;
1333
+ body.innerHTML = '<p><i>Preparing the ' + kind.toUpperCase() + '...</i></p>';
1334
+ overlay.hidden = false;
1335
+ fetch(form.action, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: 'kind=' + encodeURIComponent(kind), credentials: 'same-origin' })
1336
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok) { throw new Error(j.error || ('HTTP ' + r.status)); } return j; }); })
1337
+ .then(function (out) {
1338
+ var name = out.kind === 'vhl' ? 'VHL' : 'SHL';
1339
+ body.innerHTML = '<h3>' + name + '</h3><div class="ips-share-code">' + out.qr + '</div>' +
1340
+ '<p class="ips-small">' + (name === 'VHL' ? 'A Verifiable Health Link, signed as the GDHCN does it: the code carries the signed link' : 'A SMART Health Link: the code opens the document in a viewer') +
1341
+ '; the document is encrypted on the SHL server until ' + escapeHtml(fmt(out.expires)) + '. Scan it, or copy what it carries:</p>' +
1342
+ '<p><textarea class="ips-share-text" readonly rows="3">' + escapeHtml(out.text) + '</textarea> <button type="button" class="btn btn-default ips-btn ips-share-copy">Copy</button></p>' +
1343
+ '<p class="ips-small">Manifest: <a href="' + escapeHtml(out.link) + '">' + escapeHtml(out.link) + '</a></p>';
1344
+ var copy = body.querySelector('.ips-share-copy');
1345
+ copy.addEventListener('click', function () {
1346
+ var t = body.querySelector('.ips-share-text');
1347
+ var done = function () { copy.textContent = 'Copied'; setTimeout(function () { copy.textContent = 'Copy'; }, 1200); };
1348
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(t.value).then(done, function () { t.select(); }); } else { t.select(); }
1349
+ });
1350
+ })
1351
+ .catch(function (err) { body.innerHTML = '<p class="text-danger">Could not share the document: ' + escapeHtml(err.message) + '</p>'; })
1352
+ .finally(function () { button.disabled = false; });
1353
+ });
1354
+ });
1355
+ })();
1356
+ </script>`;
1357
+ }
1358
+
1359
+ // the views of a document; more come later
1360
+ const VIEWS = [
1361
+ ['narrative', 'Narrative'],
1362
+ ['translation', 'Translation'],
1363
+ ['generated', 'Generated Narrative'],
1364
+ ['validation', 'Validation'],
1365
+ ['terminology', 'Terminology'],
1366
+ ['profiles', 'Clinical Profiles'],
1367
+ ['ai', 'AI Assessment'],
1368
+ ['json', 'JSON']
1369
+ ];
1370
+
1371
+ /**
1372
+ * A document.
1373
+ * @param {Object} project
1374
+ * @param {Object} row - from store.read(): the columns and .bundle
1375
+ * @param {string} view - one of VIEWS
1376
+ * @param {{base: string, user?: Object, csrf?: string, canManage?: boolean}} ctx
1377
+ */
1378
+ // the processing steps, as the page shows them: [state, symbol, label]
1379
+ const STEP_STATES = {
1380
+ done: ['done', '&#10003;', 'done'],
1381
+ failed: ['failed', '&#10007;', 'failed'],
1382
+ running: ['running', '&#9881;', 'running'],
1383
+ waiting: ['waiting', '&#9790;', 'waiting']
1384
+ };
1385
+
1386
+ /**
1387
+ * Where each processing step is with the document - from the step results, read against
1388
+ * the document's own status: a document still in the queue has every step waiting, a
1389
+ * document that finished with no result rows (processed before steps were recorded) has
1390
+ * every step done.
1391
+ */
1392
+ function stepState(row, step) {
1393
+ const r = row.results?.[step]?.result;
1394
+ if (r && STEP_STATES[r.status]) {
1395
+ return { state: r.status, message: r.message || null };
1396
+ }
1397
+ if (row.status === 'processed') {
1398
+ return { state: 'done', message: null };
1399
+ }
1400
+ return { state: 'waiting', message: null };
1401
+ }
1402
+
1403
+ function stepsHtml(row, ctx) {
1404
+ let h = '';
1405
+ for (const step of ctx.steps || []) {
1406
+ const { state, message } = stepState(row, step);
1407
+ const [, symbol, word] = STEP_STATES[state];
1408
+ // a failure's reason can name the inside of the server: for managers
1409
+ 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>`;
1411
+ }
1412
+ return h;
1413
+ }
1414
+
1415
+ function renderDocument(project, row, view, ctx) {
1416
+ const purl = projectUrl(ctx, project);
1417
+ const durl = `${purl}/Bundle/${row.id}`;
1418
+ let h = STYLE + userBar(ctx) + crumbs(ctx, project, `Document ${row.id}`);
1419
+
1420
+ // the banner: who and what
1421
+ h += '<div class="ips-banner">';
1422
+ h += `<div class="ips-patient"><b>${escape(row.patient_name || '(no name)')}</b>` +
1423
+ (row.patient_gender ? `, ${escape(row.patient_gender)}` : '') +
1424
+ (row.patient_birth ? `, born ${escape(row.patient_birth)}` : '') + '</div>';
1425
+ h += '<div class="ips-facts">' +
1426
+ (row.comp_date ? `Document date ${escape(row.comp_date)} &nbsp;|&nbsp; ` : '') +
1427
+ `Received ${escape(row.received)} (${escape(row.source)}) &nbsp;|&nbsp; ${row.size.toLocaleString()} bytes` +
1428
+ ` &nbsp;|&nbsp; <a href="${durl}?_format=json">FHIR JSON</a> &nbsp;|&nbsp; <a href="${durl}?download=1">Download</a></div>`;
1429
+ h += '</div>';
1430
+
1431
+ // the steps, and what can be done
1432
+ h += '<div class="ips-steps">' + stepsHtml(row, ctx);
1433
+ {
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>`;
1436
+ if (ctx.canShare) {
1437
+ // sharing as a QR code: a form each (they work without scripts); with scripts, the
1438
+ // page asks for JSON and shows the code in a panel over the page
1439
+ h += `<form method="post" action="${durl}/share" class="ips-inline ips-share-form"><input type="hidden" name="kind" value="shl"/><button type="submit" class="btn btn-default" title="A SMART Health Link to this document, as a QR code">Share as SHL</button></form>` +
1440
+ `<form method="post" action="${durl}/share" class="ips-inline ips-share-form"><input type="hidden" name="kind" value="vhl"/><button type="submit" class="btn btn-default" title="A Verifiable Health Link (GDHCN) to this document, as a QR code">Share as VHL</button></form>`;
1441
+ }
1442
+ if (ctx.canManage) {
1443
+ 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
+ `<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
+ }
1446
+ h += '</span>';
1447
+ }
1448
+ h += '</div>';
1449
+ if (ctx.canShare) {
1450
+ h += sharePanelScript();
1451
+ }
1452
+
1453
+ // the views, as tabs
1454
+ h += '<ul class="nav nav-tabs ips-nav">' + VIEWS.map(([v, label]) =>
1455
+ `<li${v === view ? ' class="active"' : ''}><a href="${durl}?view=${v}">${label}</a></li>`).join('') + '</ul>';
1456
+
1457
+ if (view === 'json') {
1458
+ h += `<pre class="ips-json">${escape(JSON.stringify(row.bundle, null, 2))}</pre>`;
1459
+ } else if (view === 'terminology') {
1460
+ // the analysed copy carries the checks; the concepts are the same
1461
+ h += renderTerminology(row.analysed || row.bundle, { ...ctx, analysed: !!row.analysed, docUrl: durl });
1462
+ } else if (view === 'translation') {
1463
+ h += renderTranslation(row, { canManage: ctx.canManage });
1464
+ } else if (view === 'generated') {
1465
+ h += renderGenerated(row, { canManage: ctx.canManage });
1466
+ } else if (view === 'profiles') {
1467
+ h += renderProfileChecks(row, { base: ctx.base, canManage: ctx.canManage });
1468
+ } else if (view === 'ai') {
1469
+ h += renderAi(row, { canManage: ctx.canManage });
1470
+ } else if (view === 'validation') {
1471
+ h += renderValidation(row, { level: ctx.level, docUrl: durl, canManage: ctx.canManage });
1472
+ } else {
1473
+ h += assembledNarrative(row.bundle);
1474
+ }
1475
+ return h;
1476
+ }
1477
+
1478
+ module.exports = {
1479
+ renderHome, renderLogin, renderProjects, renderUsers, renderProject, renderSubmit, renderSettings, renderDocument,
1480
+ assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderShare, renderCompose, renderProfileChecks,
1481
+ renderProfiles, renderProfile, renderProfileEditor, VIEWS
1482
+ };