fhirsmith 0.13.4 → 0.14.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 (46) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +16 -3
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +22 -3
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/operation-outcome.js +3 -1
  31. package/tx/library/renderer.js +42 -5
  32. package/tx/operation-context.js +80 -17
  33. package/tx/provider.js +85 -0
  34. package/tx/sct/expressions.js +128 -44
  35. package/tx/tx-html.js +426 -44
  36. package/tx/tx.js +117 -9
  37. package/tx/workers/cache-control.js +2 -2
  38. package/tx/workers/closure.js +483 -13
  39. package/tx/workers/metadata.js +8 -3
  40. package/tx/workers/read.js +18 -3
  41. package/tx/workers/subsumes.js +18 -2
  42. package/tx/workers/translate.js +167 -20
  43. package/tx/workers/validate.js +61 -182
  44. package/tx/workers/worker.js +9 -0
  45. package/tx/xversion/xv-conceptmap.js +17 -8
  46. package/utilities/submit-test-report.js +150 -0
@@ -1,3 +1,17 @@
1
+ <!-- Text Search: the whole code system as an anonymous value set, expanded with the
2
+ text as the filter parameter. This is what a person opening a code system usually
3
+ wants - "is there a code for X" - so it comes first. -->
4
+ <div class="operation-form" style="margin-bottom: 15px;">
5
+ <strong>Text Search</strong>
6
+ <form method="post" action="{{ expandUrl }}" id="{{ searchId }}_form"
7
+ data-system="{{ url }}" data-version="{{ systemVersion }}"
8
+ style="display: inline-block; margin-left: 10px;" onsubmit="return txSearchSubmit('{{ searchId }}')">
9
+ <input type="text" name="filter" placeholder="Text" size="30"/>
10
+ <button type="submit" class="btn btn-sm btn-primary">Search</button>
11
+ </form>
12
+ <div id="{{ searchId }}_preview" style="display: none; margin-left: 10px; margin-top: 8px;"></div>
13
+ </div>
14
+
1
15
  <div class="operation-form" style="margin-bottom: 15px;">
2
16
  <strong>Lookup</strong>
3
17
  <form method="get" action="$lookup" style="display: inline-block; margin-left: 10px;">
@@ -16,3 +30,166 @@
16
30
  <button type="submit" class="btn btn-sm btn-primary">Subsumes</button>
17
31
  </form>
18
32
  </div>
33
+
34
+
35
+ <!-- Filter: builds a throwaway ValueSet (uuid url, one include for this code system carrying
36
+ every filter row) and posts it to $expand, replacing the page with the rendered expansion. -->
37
+ <div class="operation-form" style="margin-bottom: 15px;">
38
+ <strong>Filter</strong>
39
+ <form method="post" action="{{ expandUrl }}" id="{{ filterId }}_form"
40
+ data-system="{{ url }}" data-version="{{ systemVersion }}"
41
+ style="margin-left: 10px; margin-top: 5px;" onsubmit="return txFilterSubmit('{{ filterId }}')">
42
+ <datalist id="{{ filterId }}_props">{{ filterPropertyOptions }}</datalist>
43
+ <table class="grid" cellpadding="0" cellspacing="0">
44
+ <thead>
45
+ <tr><th>Property</th><th>Operation</th><th>Value</th><th></th></tr>
46
+ </thead>
47
+ <tbody id="{{ filterId }}_rows">
48
+ <tr>
49
+ <td><input type="text" name="property" list="{{ filterId }}_props" size="24" autocomplete="off"/></td>
50
+ <td><select name="op">{{ filterOpOptions }}</select></td>
51
+ <td><input type="text" name="value" size="24"/></td>
52
+ <td><button type="button" class="btn btn-sm" title="Remove this filter" onclick="txFilterRemoveRow(this)">&#215;</button></td>
53
+ </tr>
54
+ </tbody>
55
+ </table>
56
+ <button type="button" class="btn btn-sm" onclick="txFilterAddRow('{{ filterId }}')">Add Filter</button>
57
+ <button type="submit" class="btn btn-sm btn-primary">Expand</button>
58
+ </form>
59
+ <div id="{{ filterId }}_preview" style="display: none; margin-left: 10px; margin-top: 8px;"></div>
60
+ </div>
61
+
62
+ <script>
63
+ // The two forms on this tab that expand something both do the same thing: build an
64
+ // anonymous value set over the code system being viewed and post it to $expand, then
65
+ // replace the page with whatever comes back. They differ only in what they put on the
66
+ // include (nothing, for a text search) and what they put on the request (the filter).
67
+
68
+ function txOpMessage(id, text) {
69
+ var preview = document.getElementById(id + '_preview');
70
+ preview.textContent = text;
71
+ preview.style.display = 'block';
72
+ return false;
73
+ }
74
+
75
+ function txOpUuid() {
76
+ if (window.crypto && typeof crypto.randomUUID === 'function') {
77
+ return crypto.randomUUID();
78
+ }
79
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
80
+ var r = Math.random() * 16 | 0;
81
+ return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
82
+ });
83
+ }
84
+
85
+ // One include, so that filters are ANDed. The value set exists only for the duration of
86
+ // the expansion - it is never stored - so its url is a uuid.
87
+ function txOpBuildValueSet(form, filters) {
88
+ var include = { system: form.dataset.system };
89
+ if (form.dataset.version) {
90
+ include.version = form.dataset.version;
91
+ }
92
+ if (filters && filters.length > 0) {
93
+ include.filter = filters.map(function (f) {
94
+ return { property: f.property, op: f.op, value: f.value };
95
+ });
96
+ }
97
+ return {
98
+ resourceType: 'ValueSet',
99
+ // Marks the value set as one that exists only for this request, so that the server
100
+ // renders the expansion rather than a summary of a value set nobody can look up.
101
+ extension: [{ url: 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient', valueBoolean: true }],
102
+ url: 'urn:uuid:' + txOpUuid(),
103
+ status: 'active',
104
+ compose: { include: [include] }
105
+ };
106
+ }
107
+
108
+ // The value set goes in the body; anything that is an expansion parameter rather than
109
+ // part of the value set goes on the query, which $expand reads either way.
110
+ function txOpExpand(id, form, valueSet, params) {
111
+ var url = new URL(form.getAttribute('action'), window.location.href);
112
+ // The definition has to come back for the transient marker and the filters to survive
113
+ // the expansion - $expand drops both along with the rest of the definition otherwise.
114
+ url.searchParams.set('includeDefinition', 'true');
115
+ for (var name in params || {}) {
116
+ url.searchParams.set(name, params[name]);
117
+ }
118
+ txOpMessage(id, 'Expanding...');
119
+ fetch(url.toString(), {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/fhir+json', 'Accept': 'text/html' },
122
+ body: JSON.stringify(valueSet)
123
+ }).then(function (response) {
124
+ return response.text();
125
+ }).then(function (html) {
126
+ document.open();
127
+ document.write(html);
128
+ document.close();
129
+ }).catch(function (err) {
130
+ txOpMessage(id, 'Expansion failed: ' + err.message);
131
+ });
132
+ return false;
133
+ }
134
+
135
+ function txSearchSubmit(id) {
136
+ var form = document.getElementById(id + '_form');
137
+ var text = form.querySelector('input[name="filter"]').value.trim();
138
+ if (text === '') {
139
+ return txOpMessage(id, 'Enter some text to search for.');
140
+ }
141
+ return txOpExpand(id, form, txOpBuildValueSet(form, null), { filter: text });
142
+ }
143
+
144
+ function txFilterClearRow(row) {
145
+ row.querySelectorAll('input').forEach(function (input) { input.value = ''; });
146
+ row.querySelectorAll('select').forEach(function (select) { select.selectedIndex = 0; });
147
+ }
148
+
149
+ function txFilterAddRow(id) {
150
+ var rows = document.getElementById(id + '_rows');
151
+ var row = rows.rows[0].cloneNode(true);
152
+ txFilterClearRow(row);
153
+ rows.appendChild(row);
154
+ }
155
+
156
+ function txFilterRemoveRow(btn) {
157
+ var row = btn.closest('tr');
158
+ var rows = row.parentNode;
159
+ if (rows.rows.length > 1) {
160
+ rows.removeChild(row);
161
+ } else {
162
+ txFilterClearRow(row);
163
+ }
164
+ }
165
+
166
+ // Every row that has anything in it. Rows left entirely blank are ignored, so an unused
167
+ // extra row does not stop the expansion; a half-filled one is an error the user must fix.
168
+ function txFilterCollect(id) {
169
+ var filters = [];
170
+ var rows = document.getElementById(id + '_rows').rows;
171
+ for (var i = 0; i < rows.length; i++) {
172
+ var property = rows[i].querySelector('input[name="property"]').value.trim();
173
+ var op = rows[i].querySelector('select[name="op"]').value;
174
+ var value = rows[i].querySelector('input[name="value"]').value.trim();
175
+ if (property !== '' || value !== '') {
176
+ filters.push({ row: i + 1, property: property, op: op, value: value });
177
+ }
178
+ }
179
+ return filters;
180
+ }
181
+
182
+ function txFilterSubmit(id) {
183
+ var form = document.getElementById(id + '_form');
184
+ var filters = txFilterCollect(id);
185
+ if (filters.length === 0) {
186
+ return txOpMessage(id, 'No filters specified.');
187
+ }
188
+ for (var i = 0; i < filters.length; i++) {
189
+ if (filters[i].property === '' || filters[i].value === '') {
190
+ return txOpMessage(id, 'Filter ' + filters[i].row + ' needs both a property and a value.');
191
+ }
192
+ }
193
+ return txOpExpand(id, form, txOpBuildValueSet(form, filters), null);
194
+ }
195
+ </script>
@@ -0,0 +1,93 @@
1
+ <p>Expand a SNOMED CT expression constraint. The expression is run as a value set that
2
+ composes SNOMED CT with a <code>constraint</code> filter; the result appears below, and
3
+ the inputs stay where they are so the expression can be adjusted and run again.</p>
4
+
5
+ {% if hasVersions %}
6
+ <div class="operation-form" style="margin-bottom: 15px;">
7
+ <form id="{{ eclId }}_form" action="{{ expandUrl }}" data-system="{{ system }}"
8
+ data-transient="{{ transientExtension }}" onsubmit="return eclRun('{{ eclId }}')">
9
+ <table class="grid" cellpadding="0" cellspacing="0" style="width: 100%;">
10
+ <tr>
11
+ <td style="width: 100%;">ECL: <input type="text" name="ecl" id="{{ eclId }}_ecl" size="80"
12
+ style="width: 90%;" autocomplete="off" placeholder="&lt; 404684003 |Clinical finding|"/></td>
13
+ </tr>
14
+ <tr>
15
+ <td>Edition: <select name="version" id="{{ eclId }}_version">{{ versionOptions }}</select>
16
+ <button type="submit" class="btn btn-sm btn-primary" id="{{ eclId }}_go">Go</button>
17
+ </td>
18
+ </tr>
19
+ </table>
20
+ </form>
21
+ </div>
22
+
23
+ <div id="{{ eclId }}_results"></div>
24
+
25
+ <script>
26
+ function eclUuid() {
27
+ if (window.crypto && typeof crypto.randomUUID === 'function') {
28
+ return crypto.randomUUID();
29
+ }
30
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
31
+ var r = Math.random() * 16 | 0;
32
+ return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
33
+ });
34
+ }
35
+
36
+ function eclMessage(id, text) {
37
+ document.getElementById(id + '_results').innerHTML = '';
38
+ var p = document.createElement('p');
39
+ p.textContent = text;
40
+ document.getElementById(id + '_results').appendChild(p);
41
+ return false;
42
+ }
43
+
44
+ // The expansion comes back as a fragment rather than a page, so it can go straight under
45
+ // the inputs. An error arrives the same way - as the rendered OperationOutcome - which is
46
+ // the point: an ECL is usually wrong several times before it is right, and the message
47
+ // belongs next to the expression that caused it.
48
+ function eclRun(id) {
49
+ var form = document.getElementById(id + '_form');
50
+ var ecl = document.getElementById(id + '_ecl').value.trim();
51
+ var version = document.getElementById(id + '_version').value;
52
+ var go = document.getElementById(id + '_go');
53
+
54
+ if (ecl === '') {
55
+ return eclMessage(id, 'Enter an expression constraint.');
56
+ }
57
+
58
+ var valueSet = {
59
+ resourceType: 'ValueSet',
60
+ extension: [{ url: form.dataset.transient, valueBoolean: true }],
61
+ url: 'urn:uuid:' + eclUuid(),
62
+ status: 'active',
63
+ compose: {
64
+ include: [{
65
+ system: form.dataset.system,
66
+ version: version,
67
+ filter: [{ property: 'constraint', op: '=', value: ecl }]
68
+ }]
69
+ }
70
+ };
71
+
72
+ eclMessage(id, 'Expanding...');
73
+ go.disabled = true;
74
+ fetch(form.getAttribute('action'), {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/fhir+json', 'Accept': 'text/html' },
77
+ body: JSON.stringify(valueSet)
78
+ }).then(function (response) {
79
+ return response.text();
80
+ }).then(function (html) {
81
+ document.getElementById(id + '_results').innerHTML = html;
82
+ }).catch(function (err) {
83
+ eclMessage(id, 'Expansion failed: ' + err.message);
84
+ }).then(function () {
85
+ go.disabled = false;
86
+ });
87
+ return false;
88
+ }
89
+ </script>
90
+ {% else %}
91
+ <p><em>This endpoint has no SNOMED CT editions loaded, so there is nothing to run an
92
+ expression constraint against.</em></p>
93
+ {% endif %}
@@ -4,8 +4,8 @@
4
4
  <canvas id="memoryChart" height="200"></canvas>
5
5
  </div>
6
6
  <div style="flex: 1;">
7
- <h6>Expansion Cache</h6>
8
- <canvas id="cacheChart" height="200"></canvas>
7
+ <h6>Requests per Minute</h6>
8
+ <canvas id="requestChart" height="200"></canvas>
9
9
  </div>
10
10
  <div style="flex: 1;">
11
11
  <h6>Turnaround Time</h6>
@@ -13,18 +13,18 @@
13
13
  </div>
14
14
  </div>
15
15
  <div style="display: flex; gap: 20px; margin-bottom: 20px;">
16
- <div style="flex: 1;">
17
- <h6>Requests per Minute</h6>
18
- <canvas id="requestChart" height="200"></canvas>
19
- </div>
20
16
  <div style="flex: 1;">
21
17
  <h6>Client Cache</h6>
22
18
  <canvas id="clientCacheChart" height="200"></canvas>
23
19
  </div>
24
20
  <div style="flex: 1;">
25
- <h6>Node Blocking</h6>
21
+ <h6>Node Blocking (mean)</h6>
26
22
  <canvas id="blockChart" height="200"></canvas>
27
23
  </div>
24
+ <div style="flex: 1;">
25
+ <h6>Node Blocking (max)</h6>
26
+ <canvas id="blockMaxChart" height="200"></canvas>
27
+ </div>
28
28
  </div>
29
29
 
30
30
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@@ -99,36 +99,6 @@
99
99
  }
100
100
  });
101
101
 
102
- // Expansion cache chart
103
- new Chart(document.getElementById('cacheChart'), {
104
- type: 'line',
105
- data: {
106
- labels: historyData.map(d => formatTime(d.time)),
107
- datasets: [{
108
- data: historyData.map(d => d.expansion),
109
- borderColor: '#7b7b7b',
110
- backgroundColor: 'rgba(123, 123, 123, 0.1)',
111
- fill: true,
112
- tension: 0.3,
113
- pointRadius: 2
114
- }]
115
- },
116
- options: {
117
- ...chartOptions,
118
- scales: {
119
- ...chartOptions.scales,
120
- y: {
121
- ...chartOptions.scales.y,
122
- title: {
123
- display: true,
124
- text: 'Expansion cache # items'
125
- }
126
- }
127
- }
128
- }
129
- });
130
-
131
-
132
102
  // Tat chart
133
103
  new Chart(document.getElementById('tatChart'), {
134
104
  type: 'line',
@@ -215,48 +185,41 @@
215
185
  }
216
186
  }
217
187
  });
218
- // Blocking chart
219
- new Chart(document.getElementById('blockChart'), {
220
- type: 'line',
221
- data: {
222
- labels: historyData.map(d => formatTime(d.time)),
223
- datasets: [{
224
- label: 'mean',
225
- data: historyData.map(d => d.block),
226
- borderColor: '#2845a7',
227
- backgroundColor: 'rgba(40, 69, 167, 0.1)',
228
- fill: true,
229
- tension: 0.3,
230
- pointRadius: 2
231
- }, {
232
- // Worst single event-loop stall in the window: the mean dilutes a
233
- // multi-second block into noise, so this is the line that shows
234
- // request-freezing stalls.
235
- label: 'max',
236
- data: historyData.map(d => d.blockMax),
237
- borderColor: '#a72828',
238
- fill: false,
239
- tension: 0.3,
240
- pointRadius: 2
241
- }]
242
- },
243
- options: {
244
- ...chartOptions,
245
- plugins: {
246
- ...chartOptions.plugins,
247
- legend: { display: true }
188
+ // Blocking charts. Mean and max are separate charts because they are on different
189
+ // scales: a multi-second stall puts max in the thousands of ms while the mean sits at
190
+ // a few ms, so on one chart the mean is a flat line along the bottom.
191
+ function blockChart(id, field, colour, fill) {
192
+ new Chart(document.getElementById(id), {
193
+ type: 'line',
194
+ data: {
195
+ labels: historyData.map(d => formatTime(d.time)),
196
+ datasets: [{
197
+ data: historyData.map(d => d[field]),
198
+ borderColor: colour,
199
+ backgroundColor: fill,
200
+ fill: true,
201
+ tension: 0.3,
202
+ pointRadius: 2
203
+ }]
248
204
  },
249
- scales: {
250
- ...chartOptions.scales,
251
- y: {
252
- ...chartOptions.scales.y,
253
- title: {
254
- display: true,
255
- text: 'ms'
205
+ options: {
206
+ ...chartOptions,
207
+ scales: {
208
+ ...chartOptions.scales,
209
+ y: {
210
+ ...chartOptions.scales.y,
211
+ title: {
212
+ display: true,
213
+ text: 'ms'
214
+ }
256
215
  }
257
216
  }
258
217
  }
259
- }
260
- });
218
+ });
219
+ }
220
+ // the average event-loop delay over each interval
221
+ blockChart('blockChart', 'block', '#2845a7', 'rgba(40, 69, 167, 0.1)');
222
+ // the worst single stall in each interval: the one that shows request-freezing blocks
223
+ blockChart('blockMaxChart', 'blockMax', '#a72828', 'rgba(167, 40, 40, 0.1)');
261
224
  })();
262
225
  </script>
@@ -89,6 +89,7 @@
89
89
  <a href="[%endpoint-path%]/metadata" style="color: gold">Capability Statement</a> &nbsp;|&nbsp;
90
90
  <a href="[%endpoint-path%]/metadata?mode=terminology" style="color: gold">Terminology Capabilities</a> &nbsp;|&nbsp;
91
91
  <a href="[%endpoint-path%]/op.html" style="color: gold">Operations</a> &nbsp;|&nbsp;
92
+ <a href="[%endpoint-path%]/ecl" style="color: gold">ECL</a> &nbsp;|&nbsp;
92
93
  <a href="[%endpoint-path%]/problems.html" style="color: gold">Problems</a> &nbsp;|&nbsp;
93
94
  [%library-link%]
94
95
  &nbsp;
@@ -196,6 +196,14 @@ class ConceptMap extends CanonicalResource {
196
196
  * their elements matched. group.unmapped only applies to a group that was in scope
197
197
  * for the translation, so the caller needs the same source/target test that
198
198
  * listTranslations applies - without the per-element filter.
199
+ *
200
+ * "In scope" here is only about this group's source and target code systems. It assumes
201
+ * the MAP itself is in scope, which is a separate and prior question: ConceptMap.sourceScope
202
+ * limits the map to codes that are members of that value set, and a map that does not cover
203
+ * the code has no groups in scope at all, whatever their systems say. That test needs a value
204
+ * set lookup and a $validate-code, so it lives in the worker (TranslateWorker.filterMapsByScope)
205
+ * and is applied to the map before this is ever called. Do not read a group coming back from
206
+ * here as evidence that the map applies.
199
207
  * @returns {Object[]} the matching group objects
200
208
  */
201
209
  listGroupsInScope(coding, targetScope, targetSystem) {
@@ -238,19 +246,27 @@ class ConceptMap extends CanonicalResource {
238
246
  return result;
239
247
  }
240
248
  /**
241
- * Gets the source scope (R5) or source system (R3/R4)
242
- * @returns {string|undefined} Source scope/system
249
+ * Gets the source scope (R5+) or source (R3/R4).
250
+ *
251
+ * R5 renamed ConceptMap.source[x] to ConceptMap.sourceScope[x], so an R4 resource spells this
252
+ * sourceUri / sourceCanonical and an R5 one sourceScopeUri / sourceScopeCanonical. Both have to
253
+ * be read: this server serves R4 as well as R5, and the R4 client sends the R4 spelling. Reading
254
+ * only the R5 names made this silently undefined on an R4 server, which made every map look
255
+ * unscoped - so the scope never limited anything, and $translate answered for codes outside it.
256
+ * @returns {string|undefined} Source scope
243
257
  */
244
258
  get sourceScope() {
245
- return this.jsonObj.sourceScopeUri ? this.jsonObj.sourceScopeUri : this.jsonObj.sourceScopeCanonical;
259
+ return this.jsonObj.sourceScopeUri || this.jsonObj.sourceScopeCanonical
260
+ || this.jsonObj.sourceUri || this.jsonObj.sourceCanonical;
246
261
  }
247
262
 
248
263
  /**
249
- * Gets the target scope (R5) or target system (R3/R4)
250
- * @returns {string|undefined} Target scope/system
264
+ * Gets the target scope (R5+) or target (R3/R4). See sourceScope for why both are read.
265
+ * @returns {string|undefined} Target scope
251
266
  */
252
267
  get targetScope() {
253
- return this.jsonObj.targetScopeUri ? this.jsonObj.targetScopeUri : this.jsonObj.targetScopeCanonical;
268
+ return this.jsonObj.targetScopeUri || this.jsonObj.targetScopeCanonical
269
+ || this.jsonObj.targetUri || this.jsonObj.targetCanonical;
254
270
  }
255
271
 
256
272
  /**
@@ -26,6 +26,10 @@ class SearchFilterText {
26
26
 
27
27
  this.filter = filter ? filter.toLowerCase() : null;
28
28
  this.stems = [];
29
+ // The words the stems came from, index-aligned with stems. A provider whose text
30
+ // index cannot hold a particular token (RxNorm's stem table has nothing that starts
31
+ // with a digit, for one) needs the word itself to search the text with instead.
32
+ this.words = [];
29
33
  this.stemmer = natural.PorterStemmer;
30
34
  if (filter) {
31
35
  this._process();
@@ -135,6 +139,7 @@ class SearchFilterText {
135
139
 
136
140
  _process() {
137
141
  let i = 0;
142
+ const found = [];
138
143
 
139
144
  while (i < this.filter.length) {
140
145
  if (this._isAlphaNumeric(this.filter[i])) {
@@ -142,14 +147,18 @@ class SearchFilterText {
142
147
  while (i < this.filter.length && this._isAlphaNumeric(this.filter[i])) {
143
148
  i++;
144
149
  }
145
- const word = this.filter.substring(j, i);
146
- this.stems.push(this.stemmer.stem(word.toLowerCase()));
150
+ const word = this.filter.substring(j, i).toLowerCase();
151
+ found.push({ word: word, stem: this.stemmer.stem(word) });
147
152
  } else {
148
153
  i++;
149
154
  }
150
155
  }
151
156
 
152
- this.stems.sort();
157
+ // Sorted by stem, because _find() binary searches stems - words ride along so that
158
+ // words[n] is always the word stems[n] came from.
159
+ found.sort((a, b) => (a.stem < b.stem ? -1 : (a.stem > b.stem ? 1 : 0)));
160
+ this.stems = found.map((f) => f.stem);
161
+ this.words = found.map((f) => f.word);
153
162
  }
154
163
 
155
164
  _isAlphaNumeric(char) {
@@ -198,7 +198,9 @@ class OperationOutcome {
198
198
  listMissedErrors(list) {
199
199
  for (let iss of this.jsonObj.issue || []) {
200
200
  if (iss.severity === 'error' && iss.details && iss.details.text && !list.find(msg => msg === iss.details.text )) {
201
- return list.push(iss.details.text);
201
+ // every error that nothing else reported, not just the first one - an early return here
202
+ // meant the summary message named only one of them
203
+ list.push(iss.details.text);
202
204
  }
203
205
  }
204
206
 
@@ -1445,6 +1445,7 @@ class Renderer {
1445
1445
 
1446
1446
  // Analyze columns needed
1447
1447
  const columnInfo = this.analyzeExpansionColumns(vs.expansion);
1448
+ columnInfo.versions = this.expansionVersions(vs);
1448
1449
 
1449
1450
  // Build the expansion table
1450
1451
  const expTbl = x.table("codes");
@@ -1595,6 +1596,40 @@ class Renderer {
1595
1596
  /**
1596
1597
  * Add a row for an expansion contains entry
1597
1598
  */
1599
+ /**
1600
+ * The version each code system was expanded at, by system uri.
1601
+ *
1602
+ * An expansion.contains only carries a version when the expansion needed to distinguish
1603
+ * one, so a link built from the row alone would silently point at whichever version the
1604
+ * server happens to prefer - not what the reader is looking at. The expansion says which
1605
+ * version it used in its used-codesystem parameters, and a value set that came with its
1606
+ * definition says so in its compose; either will do.
1607
+ *
1608
+ * @param {Object} vs the expanded ValueSet
1609
+ * @returns {Map<string, string>} system uri to version
1610
+ */
1611
+ expansionVersions(vs) {
1612
+ const versions = new Map();
1613
+ for (const inc of vs.compose?.include || []) {
1614
+ if (inc.system && inc.version && !versions.has(inc.system)) {
1615
+ versions.set(inc.system, inc.version);
1616
+ }
1617
+ }
1618
+ for (const p of vs.expansion?.parameter || []) {
1619
+ if (p.name !== 'used-codesystem') {
1620
+ continue;
1621
+ }
1622
+ const vurl = getValuePrimitive(p);
1623
+ if (vurl && vurl.includes('|')) {
1624
+ const system = vurl.substring(0, vurl.indexOf('|'));
1625
+ if (!versions.has(system)) {
1626
+ versions.set(system, vurl.substring(vurl.indexOf('|') + 1));
1627
+ }
1628
+ }
1629
+ }
1630
+ return versions;
1631
+ }
1632
+
1598
1633
  async addExpansionRow(tbl, contains, level, columnInfo) {
1599
1634
  const tr = tbl.tr();
1600
1635
 
@@ -1617,12 +1652,14 @@ class Renderer {
1617
1652
  codeTd.tx('\u00A0'.repeat(level * 2)); // Non-breaking spaces for indentation
1618
1653
  }
1619
1654
 
1620
- // Try to link the code
1655
+ // The code links to $lookup on it - what a reader wants from a row in an expansion is
1656
+ // to find out what that code is - at the version the expansion used.
1621
1657
  if (contains.code) {
1622
- const link = this.linkResolver ?
1623
- await this.linkResolver.resolveCode(this.opContext, contains.system, contains.version, contains.code) : null;
1658
+ const version = contains.version || columnInfo.versions?.get(contains.system) || null;
1659
+ const link = this.linkResolver?.lookupLink
1660
+ ? this.linkResolver.lookupLink(contains.system, version, contains.code) : null;
1624
1661
  if (link) {
1625
- codeTd.ah(link.link).tx(contains.code);
1662
+ codeTd.ah(link, 'Look this code up').code().tx(contains.code);
1626
1663
  } else {
1627
1664
  codeTd.code().tx(contains.code);
1628
1665
  }
@@ -2732,4 +2769,4 @@ class Renderer {
2732
2769
 
2733
2770
  }
2734
2771
 
2735
- module.exports = { Renderer };
2772
+ module.exports = { Renderer, VALID_FILTER_OPS };