fhirsmith 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/README.md +1 -1
- package/library/package-manager.js +1 -1
- package/package.json +2 -2
- package/tx/html/search-form.liquid +5 -2
- package/tx/library/canonical-resource.js +63 -0
- package/tx/library.js +7 -2
- package/tx/problems.js +41 -19
- package/tx/provider.js +15 -3
- package/tx/tx-html.js +130 -17
- package/tx/vs/vs-api.js +3 -0
- package/tx/vs/vs-database.js +15 -7
- package/tx/vs/vs-package.js +6 -1
- package/tx/vs/vs-vsac.js +18 -2
- package/tx/workers/expand.js +8 -3
- package/tx/workers/search.js +18 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,32 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [v0.5.
|
|
8
|
+
## [v0.5.5] - 2026-02-26
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Fix loading problem for multiple versions of the same code system
|
|
12
|
+
- Fix url matching in search to be precise
|
|
13
|
+
|
|
14
|
+
### Tx Conformance Statement
|
|
15
|
+
|
|
16
|
+
FHIRsmith 0.5.5 passed all 1382 HL7 terminology service tests (modes tx.fhir.org,omop,general,snomed, tests v1.9.0, runner v6.8.1)
|
|
17
|
+
|
|
18
|
+
## [v0.5.4] - 2026-02-25
|
|
19
|
+
|
|
20
|
+
This version requires that you delete all package content from the terminology-cache directly
|
|
21
|
+
by hand before running this version.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Improved Problem page
|
|
25
|
+
- Ignore system version in VSAC value sets
|
|
26
|
+
- Improve value set search
|
|
27
|
+
- better handling of code systems without a content property
|
|
28
|
+
|
|
29
|
+
### Tx Conformance Statement
|
|
30
|
+
|
|
31
|
+
FHIRsmith 0.5.4 passed all 1382 HL7 terminology service tests (modes tx.fhir.org,omop,general,snomed, tests v1.9.0, runner v6.8.1)
|
|
32
|
+
|
|
33
|
+
## [v0.5.3] - 2026-02-24
|
|
9
34
|
|
|
10
35
|
### Added
|
|
11
36
|
- Page listing logical problems in terminology definitions
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"engines": {
|
|
@@ -116,4 +116,4 @@
|
|
|
116
116
|
"url": "https://github.com/HealthIntersections/fhirsmith/issues"
|
|
117
117
|
},
|
|
118
118
|
"homepage": "https://github.com/HealthIntersections/fhirsmith#readme"
|
|
119
|
-
}
|
|
119
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<form method="get" id="searchForm" action="{{ baseUrl }}/CodeSystem">
|
|
4
4
|
<table class="grid" cellpadding="0" cellspacing="0">
|
|
5
5
|
<tr>
|
|
6
|
-
<td
|
|
6
|
+
<td>URL: <input type="text" name="url" size="40"/></td>
|
|
7
7
|
<td>Version: <input type="text" name="version"/></td>
|
|
8
8
|
</tr>
|
|
9
9
|
<tr>
|
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
<td>Status: <select name="status" class="form-select"><option value="">(any status)</option>
|
|
12
12
|
<option value="draft">draft</option><option value="active">active</option>
|
|
13
13
|
<option value="retired">retired</option><option value="unknown">unknown</option></select></td>
|
|
14
|
+
</tr>
|
|
15
|
+
<tr>
|
|
14
16
|
<td>Language: <input type="text" name="lang" size="10"/> (ietf code)</td>
|
|
17
|
+
<td>Source: <select name="source" class="form-select">{{ sourceOptions }}</select></td>
|
|
15
18
|
</tr>
|
|
16
19
|
<tr>
|
|
17
|
-
<td
|
|
20
|
+
<td title="CodeSystem - for supplements, value sets, and concept maps">System: <input type="text" name="system" size="40"/></td>
|
|
18
21
|
<td>CS Content: <select name="content-mode" class="form-select"><option value="">(any content)</option>
|
|
19
22
|
<option value="not-present">not-present</option><option value="example">example</option>
|
|
20
23
|
<option value="fragment">fragment</option><option value="complete">complete</option>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const {VersionUtilities, VersionPrecision} = require("../../library/version-utilities");
|
|
1
2
|
|
|
2
3
|
/**
|
|
3
4
|
* Base class for metadata resources to provide common interface
|
|
@@ -83,6 +84,68 @@ class CanonicalResource {
|
|
|
83
84
|
}
|
|
84
85
|
return this.jsonObj.versionAlgorithmString;
|
|
85
86
|
}
|
|
87
|
+
|
|
88
|
+
guessVersionAlgorithmFromVersion(version) {
|
|
89
|
+
if (VersionUtilities.isSemVerWithWildcards(version)) {
|
|
90
|
+
return 'semver';
|
|
91
|
+
}
|
|
92
|
+
if (this.appearsToBeDate(version)) {
|
|
93
|
+
return 'date';
|
|
94
|
+
}
|
|
95
|
+
if (this.isAnInteger(version)) {
|
|
96
|
+
return 'integer';
|
|
97
|
+
}
|
|
98
|
+
return 'alpha';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* returns true if this is more recent than other.
|
|
103
|
+
*
|
|
104
|
+
* Uses version if possible, otherwise uses date
|
|
105
|
+
*
|
|
106
|
+
* @param other
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
isMoreRecent(other) {
|
|
110
|
+
if (this.version && other.version && this.version != other.version) {
|
|
111
|
+
const fmt = this.versionAlgorithm() || other.versionAlgorithm() || this.guessVersionAlgorithmFromVersion(this.version);
|
|
112
|
+
switch (fmt) {
|
|
113
|
+
case 'semver':
|
|
114
|
+
return VersionUtilities.isThisOrLater(other.version, this.version, VersionPrecision.PATCH);
|
|
115
|
+
case 'date':
|
|
116
|
+
return this.dateIsMoreRecent(this.version, other.version);
|
|
117
|
+
case 'integer':
|
|
118
|
+
return parseInt(this.version, 10) > parseInt(other.version, 10);
|
|
119
|
+
case 'alpha': return this.version.localeCompare(other.version) > 0;
|
|
120
|
+
default: return this.version.localeCompare(other.version);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (this.date && other.date && this.date != other.date) {
|
|
124
|
+
return this.dateIsMoreRecent(this.date, other.date);
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
appearsToBeDate(version) {
|
|
130
|
+
if (!version || typeof version !== 'string') return false;
|
|
131
|
+
// Strip optional time portion (T...) before checking
|
|
132
|
+
const datePart = version.split('T')[0];
|
|
133
|
+
return /^\d{4}-?\d{2}(-?\d{2})?$/.test(datePart);
|
|
134
|
+
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
dateIsMoreRecent(date, date2) {
|
|
138
|
+
return this.normaliseDateString(date) > this.normaliseDateString(date2);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
normaliseDateString(date) {
|
|
142
|
+
// Strip time portion, then remove dashes so all formats compare uniformly as YYYYMMDD or YYYYMM
|
|
143
|
+
return date.split('T')[0].replace(/-/g, '');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
isAnInteger(version) {
|
|
147
|
+
return /^\d+$/.test(version);
|
|
148
|
+
}
|
|
86
149
|
}
|
|
87
150
|
|
|
88
151
|
module.exports = { CanonicalResource };
|
package/tx/library.js
CHANGED
|
@@ -449,8 +449,13 @@ class Library {
|
|
|
449
449
|
for (const resource of resources) {
|
|
450
450
|
const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
|
|
451
451
|
cs.sourcePackage = contentLoader.pid();
|
|
452
|
-
cp.codeSystems.
|
|
453
|
-
|
|
452
|
+
const existing = cp.codeSystems.get(cs.url);
|
|
453
|
+
if (!existing || cs.isMoreRecent(existing)) {
|
|
454
|
+
cp.codeSystems.set(cs.url, cs);
|
|
455
|
+
}
|
|
456
|
+
if (cs.version) {
|
|
457
|
+
cp.codeSystems.set(cs.vurl, cs);
|
|
458
|
+
}
|
|
454
459
|
csc++;
|
|
455
460
|
}
|
|
456
461
|
this.codeSystemProviders.push(cp);
|
package/tx/problems.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const escape = require('escape-html');
|
|
1
2
|
|
|
2
3
|
class ProblemFinder {
|
|
3
4
|
|
|
@@ -8,49 +9,70 @@ class ProblemFinder {
|
|
|
8
9
|
async scanValueSets(provider) {
|
|
9
10
|
let unknownVersions = {}; // system -> Set of versions not known to the server
|
|
10
11
|
for (let vsp of provider.valueSetProviders) {
|
|
12
|
+
let sourceUnknownVersions = unknownVersions[vsp.sourcePackage()];
|
|
13
|
+
if (!sourceUnknownVersions) {
|
|
14
|
+
sourceUnknownVersions = {};
|
|
15
|
+
unknownVersions[vsp.sourcePackage()] = sourceUnknownVersions;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
let list = await vsp.listAllValueSets();
|
|
12
19
|
for (let url of list) {
|
|
13
20
|
let vs = await vsp.fetchValueSet(url);
|
|
14
21
|
if (vs && vs.jsonObj.compose) {
|
|
15
|
-
await this.scanValueSet(vs.jsonObj.compose,
|
|
22
|
+
await this.scanValueSet(vs.jsonObj.compose, sourceUnknownVersions);
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
}
|
|
19
|
-
|
|
20
|
-
for (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
let result = '';
|
|
27
|
+
for (let sp of provider.listValueSetSourceCodes()) {
|
|
28
|
+
let sourceUnknownVersions = unknownVersions[sp];
|
|
29
|
+
if (sourceUnknownVersions) {
|
|
30
|
+
// Filter to only versions the server doesn't know about
|
|
31
|
+
for (const [system, vset] of Object.entries(sourceUnknownVersions)) {
|
|
32
|
+
for (let v of [...vset]) {
|
|
33
|
+
if (await provider.hasCsVersion(system, v)) {
|
|
34
|
+
vset.delete(v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (vset.size === 0) {
|
|
38
|
+
delete sourceUnknownVersions[system];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let list = await this.unknownVersionsHtml(sourceUnknownVersions, provider, sp);
|
|
42
|
+
if (list) {
|
|
43
|
+
result = result + `<h4>${sp}</h4>` + list;
|
|
24
44
|
}
|
|
25
|
-
}
|
|
26
|
-
if (vset.size === 0) {
|
|
27
|
-
delete unknownVersions[system];
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
|
-
return
|
|
47
|
+
return result;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
|
-
unknownVersionsHtml(unknownVersions) {
|
|
50
|
+
async unknownVersionsHtml(unknownVersions, provider, source) {
|
|
34
51
|
const entries = Object.entries(unknownVersions || {});
|
|
35
52
|
if (entries.length === 0) {
|
|
36
53
|
return '<p>No unknown system versions found.</p>';
|
|
37
54
|
}
|
|
38
55
|
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
39
|
-
let html = '<table class="grid"><thead><tr><th>System</th><th>Unknown Versions</th></tr></thead><tbody>';
|
|
56
|
+
let html = '<table class="grid"><thead><tr><th>System</th><th>Unknown Versions</th><th>Known Versions</th></tr></thead><tbody>';
|
|
40
57
|
for (const [system, vset] of entries) {
|
|
41
|
-
const
|
|
42
|
-
|
|
58
|
+
const systemEsc = escape(system);
|
|
59
|
+
const versions = [...vset].sort((a, b) => a.localeCompare(b));
|
|
60
|
+
const versionRefs = [];
|
|
61
|
+
for (const v of versions) {
|
|
62
|
+
versionRefs.push(`<a href="ValueSet?system=${systemEsc}|${escape(v)}&source=${source}&_elements=url%2Cversion%2Cname%2Ctitle%2Cstatus%2Ccontent%2Cdate">${v}</a>`);
|
|
63
|
+
}
|
|
64
|
+
const knownVersions = [...await provider.listCodeSystemVersions(system)].join('<br/>');
|
|
65
|
+
html += `<tr><td><a href="CodeSystem?url=${systemEsc}&_elements=url%2Cversion%2Cname%2Ctitle%2Cstatus%2Ccontent%2Cdate">${system}</a></td>`+
|
|
66
|
+
`<td>${versionRefs.join('<br/>')}</td><td>${knownVersions}</td></tr>`;
|
|
43
67
|
}
|
|
44
68
|
html += '</tbody></table>';
|
|
45
69
|
return html;
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
async scanValueSet(compose, versions
|
|
72
|
+
async scanValueSet(compose, versions) {
|
|
49
73
|
for (let inc of compose.include || []) {
|
|
50
|
-
if (inc.system) {
|
|
51
|
-
|
|
52
|
-
this.seeVersion(versions, inc.system, inc.version);
|
|
53
|
-
}
|
|
74
|
+
if (inc.system && inc.version) {
|
|
75
|
+
this.seeVersion(versions, inc.system, inc.version);
|
|
54
76
|
}
|
|
55
77
|
}
|
|
56
78
|
}
|
package/tx/provider.js
CHANGED
|
@@ -136,8 +136,13 @@ class Provider {
|
|
|
136
136
|
for (const resource of resources) {
|
|
137
137
|
const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
|
|
138
138
|
cs.sourcePackage = contentLoader.pid();
|
|
139
|
-
this.codeSystems.
|
|
140
|
-
|
|
139
|
+
const existing = this.codeSystems.get(cs.url);
|
|
140
|
+
if (!existing || cs.isMoreRecent(existing)) {
|
|
141
|
+
this.codeSystems.set(cs.url, cs);
|
|
142
|
+
}
|
|
143
|
+
if (cs.version) {
|
|
144
|
+
this.codeSystems.set(cs.vurl, cs);
|
|
145
|
+
}
|
|
141
146
|
}
|
|
142
147
|
const vs = new PackageValueSetProvider(contentLoader);
|
|
143
148
|
await vs.initialize();
|
|
@@ -218,9 +223,16 @@ class Provider {
|
|
|
218
223
|
await csp.findImplicitConceptMap(url, version);
|
|
219
224
|
}
|
|
220
225
|
}
|
|
221
|
-
|
|
222
226
|
}
|
|
223
227
|
|
|
228
|
+
listValueSetSourceCodes() {
|
|
229
|
+
let result = [];
|
|
230
|
+
for (let vsp of this.valueSetProviders) {
|
|
231
|
+
result.push(vsp.sourcePackage());
|
|
232
|
+
}
|
|
233
|
+
result.sort((a, b) => {a.localeCompare(b)});
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
224
236
|
|
|
225
237
|
async listCodeSystemVersions(url) {
|
|
226
238
|
let result = new Set();
|
package/tx/tx-html.js
CHANGED
|
@@ -137,7 +137,7 @@ class TxHtmlRenderer {
|
|
|
137
137
|
|
|
138
138
|
// eslint-disable-next-line no-unused-vars
|
|
139
139
|
async buildSearchForm(req, mode, params) {
|
|
140
|
-
const html = await this.liquid.renderFile('search-form', { baseUrl: escape(req.baseUrl) });
|
|
140
|
+
const html = await this.liquid.renderFile('search-form', { baseUrl: escape(req.baseUrl), sourceOptions : this.buildSourceOptions(req.txProvider) });
|
|
141
141
|
return html;
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -800,14 +800,128 @@ class TxHtmlRenderer {
|
|
|
800
800
|
return firstEntry?.resourceType || 'Resource';
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
+
/**
|
|
804
|
+
* Build a human-readable description of what this search bundle represents,
|
|
805
|
+
* by parsing the self link URL parameters.
|
|
806
|
+
*/
|
|
807
|
+
describeSearchBundle(json) {
|
|
808
|
+
const selfLink = json.link?.find(l => l.relation === 'self')?.url || '';
|
|
809
|
+
if (!selfLink) return '';
|
|
810
|
+
|
|
811
|
+
let urlObj;
|
|
812
|
+
try {
|
|
813
|
+
urlObj = new URL(selfLink);
|
|
814
|
+
} catch {
|
|
815
|
+
return '';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Extract resource type from path
|
|
819
|
+
const typeMatch = selfLink.match(/\/(CodeSystem|ValueSet|ConceptMap)\b/);
|
|
820
|
+
const resourceType = typeMatch ? typeMatch[1] : 'Resource';
|
|
821
|
+
|
|
822
|
+
// Human-friendly labels for search params
|
|
823
|
+
const PARAM_LABELS = {
|
|
824
|
+
'url': 'URL',
|
|
825
|
+
'version': 'Version',
|
|
826
|
+
'name': 'Name',
|
|
827
|
+
'title': 'Title',
|
|
828
|
+
'status': 'Status',
|
|
829
|
+
'publisher': 'Publisher',
|
|
830
|
+
'description': 'Description',
|
|
831
|
+
'identifier': 'Identifier',
|
|
832
|
+
'jurisdiction': 'Jurisdiction',
|
|
833
|
+
'date': 'Date',
|
|
834
|
+
'text': 'Text',
|
|
835
|
+
'system': 'System',
|
|
836
|
+
'supplements': 'Supplements',
|
|
837
|
+
'content-mode': 'Content mode',
|
|
838
|
+
'source': 'Source'
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const WORDS = {
|
|
842
|
+
'url': 'contains',
|
|
843
|
+
'version': 'contains',
|
|
844
|
+
'name': 'contains',
|
|
845
|
+
'title': 'contains',
|
|
846
|
+
'status': 'is',
|
|
847
|
+
'publisher': 'contains',
|
|
848
|
+
'description': 'contains',
|
|
849
|
+
'identifier': 'matches',
|
|
850
|
+
'jurisdiction': 'contains',
|
|
851
|
+
'date': 'matches',
|
|
852
|
+
'text': 'contains',
|
|
853
|
+
'system': 'matches',
|
|
854
|
+
'supplements': 'matches',
|
|
855
|
+
'content-mode': 'is',
|
|
856
|
+
'source': 'is'
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const CONTROL_PARAMS = new Set(['_offset', '_count', '_sort', '_summary', '_elements', '_total']);
|
|
860
|
+
|
|
861
|
+
// Collect filter criteria
|
|
862
|
+
const criteria = [];
|
|
863
|
+
for (const [key, value] of urlObj.searchParams) {
|
|
864
|
+
if (key != 'mode') {
|
|
865
|
+
if (CONTROL_PARAMS.has(key) || !value) continue;
|
|
866
|
+
const label = PARAM_LABELS[key] || key;
|
|
867
|
+
const word = WORDS[key] || "contains";
|
|
868
|
+
criteria.push(`<strong>${escape(label)}</strong> ${word} “${escape(value)}”`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Collect display/pagination context
|
|
873
|
+
const total = json.total;
|
|
874
|
+
const offset = parseInt(urlObj.searchParams.get('_offset') || '0');
|
|
875
|
+
const count = parseInt(urlObj.searchParams.get('_count') || '20');
|
|
876
|
+
const sort = urlObj.searchParams.get('_sort');
|
|
877
|
+
const summary = urlObj.searchParams.get('_summary');
|
|
878
|
+
const elementsParam = urlObj.searchParams.get('_elements');
|
|
879
|
+
|
|
880
|
+
// Build the description sentence
|
|
881
|
+
let desc = `Searching <strong>${escape(resourceType)}s</strong>`;
|
|
882
|
+
|
|
883
|
+
if (criteria.length > 0) {
|
|
884
|
+
desc += ' where ' + criteria.join(', ');
|
|
885
|
+
} else {
|
|
886
|
+
desc += ' (all)';
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Pagination context
|
|
890
|
+
if (typeof total === 'number') {
|
|
891
|
+
const from = Math.min(offset + 1, total);
|
|
892
|
+
const to = Math.min(offset + count, total);
|
|
893
|
+
if (total === 0) {
|
|
894
|
+
desc += ' — <strong>no results found</strong>';
|
|
895
|
+
} else {
|
|
896
|
+
desc += ` — showing <strong>${from}–${to}</strong> of <strong>${total}</strong>`;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Sort
|
|
901
|
+
if (sort) {
|
|
902
|
+
desc += `, sorted by <strong>${escape(sort)}</strong>`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Summary mode
|
|
906
|
+
if (summary && summary !== 'false') {
|
|
907
|
+
desc += ` [summary: ${escape(summary)}]`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Elements
|
|
911
|
+
if (elementsParam) {
|
|
912
|
+
desc += ` [fields: ${escape(elementsParam)}]`;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return `<p class="search-description">${desc}</p>`;
|
|
916
|
+
}
|
|
917
|
+
|
|
803
918
|
/**
|
|
804
919
|
* Render search results as a table (when _elements is specified)
|
|
805
920
|
*/
|
|
806
921
|
async renderSearchTable(json, elements, req) {
|
|
807
922
|
const entries = json.entry || [];
|
|
808
|
-
const total = json.total || 0;
|
|
809
923
|
|
|
810
|
-
let html =
|
|
924
|
+
let html = this.describeSearchBundle(json);
|
|
811
925
|
|
|
812
926
|
// Pagination links
|
|
813
927
|
html += this.renderPaginationLinks(json);
|
|
@@ -859,22 +973,12 @@ class TxHtmlRenderer {
|
|
|
859
973
|
*/
|
|
860
974
|
async renderSearchSummary(json, req) {
|
|
861
975
|
const entries = json.entry || [];
|
|
862
|
-
const total = json.total || 0;
|
|
863
976
|
|
|
864
|
-
let html =
|
|
977
|
+
let html = this.describeSearchBundle(json);
|
|
865
978
|
|
|
866
979
|
// Pagination links
|
|
867
980
|
html += this.renderPaginationLinks(json);
|
|
868
981
|
|
|
869
|
-
// Bundle summary
|
|
870
|
-
html += '<div class="card mb-3">';
|
|
871
|
-
html += '<div class="card-header">Bundle Summary</div>';
|
|
872
|
-
html += '<div class="card-body">';
|
|
873
|
-
html += `<p><strong>Type:</strong> ${escape(json.type)}</p>`;
|
|
874
|
-
html += `<p><strong>Total:</strong> ${total}</p>`;
|
|
875
|
-
html += '</div>';
|
|
876
|
-
html += '</div>';
|
|
877
|
-
|
|
878
982
|
// Each entry
|
|
879
983
|
for (const entry of entries) {
|
|
880
984
|
html += '<hr/>';
|
|
@@ -1010,7 +1114,7 @@ class TxHtmlRenderer {
|
|
|
1010
1114
|
html += '<div class="narrative">(No Narrative)</div>';
|
|
1011
1115
|
}
|
|
1012
1116
|
if (json.text && json.text.div) {
|
|
1013
|
-
|
|
1117
|
+
// Collapsible JSON source
|
|
1014
1118
|
html += '<div class="xhtml">';
|
|
1015
1119
|
html += `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleOriginalNarrative('${resourceId}x')">`;
|
|
1016
1120
|
html += 'Show Original Narrative</button>';
|
|
@@ -1047,8 +1151,17 @@ class TxHtmlRenderer {
|
|
|
1047
1151
|
valueSetsJson: JSON.stringify(json.valueSets || [])
|
|
1048
1152
|
});
|
|
1049
1153
|
}
|
|
1154
|
+
|
|
1155
|
+
buildSourceOptions(provider) {
|
|
1156
|
+
let result = '';
|
|
1157
|
+
result += `<option value="internal">internal</option>`;
|
|
1158
|
+
for (let sp of provider.listValueSetSourceCodes()) {
|
|
1159
|
+
result += `<option value="${sp}">${sp}</option>`;
|
|
1160
|
+
}
|
|
1161
|
+
return result;
|
|
1162
|
+
}
|
|
1050
1163
|
}
|
|
1051
1164
|
|
|
1052
1165
|
module.exports = {
|
|
1053
|
-
|
|
1054
|
-
};
|
|
1166
|
+
TxHtmlRenderer, loadTemplate
|
|
1167
|
+
};
|
package/tx/vs/vs-api.js
CHANGED
package/tx/vs/vs-database.js
CHANGED
|
@@ -37,7 +37,7 @@ class ValueSetDatabase {
|
|
|
37
37
|
this._db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
|
|
38
38
|
if (err) {
|
|
39
39
|
this._db = null;
|
|
40
|
-
reject(new Error(`Failed to open database: ${err.message}`));
|
|
40
|
+
reject(new Error(`Failed to open database ${this.dbPath}: ${err.message}`));
|
|
41
41
|
} else {
|
|
42
42
|
resolve(this._db);
|
|
43
43
|
}
|
|
@@ -122,7 +122,7 @@ class ValueSetDatabase {
|
|
|
122
122
|
return new Promise((resolve, reject) => {
|
|
123
123
|
const db = new sqlite3.Database(this.dbPath, (err) => {
|
|
124
124
|
if (err) {
|
|
125
|
-
reject(new Error(`Failed to create database: ${err.message}`));
|
|
125
|
+
reject(new Error(`Failed to create database ${this.dbPath}: ${err.message}`));
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -177,6 +177,7 @@ class ValueSetDatabase {
|
|
|
177
177
|
CREATE TABLE valueset_systems (
|
|
178
178
|
valueset_id TEXT,
|
|
179
179
|
system TEXT,
|
|
180
|
+
version TEXT,
|
|
180
181
|
FOREIGN KEY (valueset_id) REFERENCES valuesets(url)
|
|
181
182
|
)
|
|
182
183
|
`);
|
|
@@ -193,7 +194,7 @@ class ValueSetDatabase {
|
|
|
193
194
|
db.run('CREATE INDEX idx_identifiers_value ON valueset_identifiers(value)');
|
|
194
195
|
db.run('CREATE INDEX idx_jurisdictions_system ON valueset_jurisdictions(system)');
|
|
195
196
|
db.run('CREATE INDEX idx_jurisdictions_code ON valueset_jurisdictions(code)');
|
|
196
|
-
db.run('CREATE INDEX idx_systems_system ON valueset_systems(system)');
|
|
197
|
+
db.run('CREATE INDEX idx_systems_system ON valueset_systems(system, version)');
|
|
197
198
|
|
|
198
199
|
db.close((err) => {
|
|
199
200
|
if (err) {
|
|
@@ -392,8 +393,8 @@ class ValueSetDatabase {
|
|
|
392
393
|
pendingOperations++;
|
|
393
394
|
|
|
394
395
|
db.run(`
|
|
395
|
-
INSERT INTO valueset_systems (valueset_id, system) VALUES (?, ?)
|
|
396
|
-
`, [valueSet.id, include.system], function(err) {
|
|
396
|
+
INSERT INTO valueset_systems (valueset_id, system, version) VALUES (?, ?, ?)
|
|
397
|
+
`, [valueSet.id, include.system, include.version], function(err) {
|
|
397
398
|
if (err) {
|
|
398
399
|
operationError(new Error(`Failed to insert system: ${err.message}`));
|
|
399
400
|
} else {
|
|
@@ -735,8 +736,15 @@ class ValueSetDatabase {
|
|
|
735
736
|
|
|
736
737
|
case 'system':
|
|
737
738
|
joins.add('JOIN valueset_systems vs ON v.id = vs.valueset_id');
|
|
738
|
-
|
|
739
|
-
|
|
739
|
+
if (value.includes('|')) {
|
|
740
|
+
conditions.push('vs.system = ?');
|
|
741
|
+
params.push(value.substring(0, value.indexOf('|')));
|
|
742
|
+
conditions.push('vs.version = ?');
|
|
743
|
+
params.push(value.substring(value.indexOf('|')+1));
|
|
744
|
+
} else {
|
|
745
|
+
conditions.push('vs.system = ?');
|
|
746
|
+
params.push(value);
|
|
747
|
+
}
|
|
740
748
|
break;
|
|
741
749
|
|
|
742
750
|
default:
|
package/tx/vs/vs-package.js
CHANGED
|
@@ -23,6 +23,11 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
23
23
|
this.valueSetMap = new Map();
|
|
24
24
|
this.initialized = false;
|
|
25
25
|
this.count = 0;
|
|
26
|
+
this.sourcePackageCode = packageLoader.id();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
sourcePackage() {
|
|
30
|
+
return this.sourcePackageCode;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/**
|
|
@@ -42,7 +47,7 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
42
47
|
await this._populateDatabase();
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
this.valueSetMap = await this.database.loadAllValueSets(this.
|
|
50
|
+
this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
|
|
46
51
|
this.initialized = true;
|
|
47
52
|
}
|
|
48
53
|
|
package/tx/vs/vs-vsac.js
CHANGED
|
@@ -51,6 +51,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
sourcePackage() {
|
|
55
|
+
return "vsac";
|
|
56
|
+
}
|
|
57
|
+
|
|
54
58
|
/**
|
|
55
59
|
* Initialize the provider - setup database and start refresh cycle
|
|
56
60
|
* @returns {Promise<void>}
|
|
@@ -313,8 +317,20 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
313
317
|
* @private
|
|
314
318
|
*/
|
|
315
319
|
async _reloadMap() {
|
|
316
|
-
const newMap = await this.database.loadAllValueSets(
|
|
317
|
-
|
|
320
|
+
const newMap = await this.database.loadAllValueSets(this.sourcePackage());
|
|
321
|
+
for (const vs of newMap.values()) {
|
|
322
|
+
if (vs.jsonObj.compose) {
|
|
323
|
+
for (const inc of vs.jsonObj.compose.include || []) {
|
|
324
|
+
if (inc.version) {
|
|
325
|
+
delete inc.version;
|
|
326
|
+
}
|
|
327
|
+
}for (const inc of vs.jsonObj.compose.exclude || []) {
|
|
328
|
+
if (inc.version) {
|
|
329
|
+
delete inc.version;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
318
334
|
// Atomic replacement of the map
|
|
319
335
|
this.valueSetMap = newMap;
|
|
320
336
|
}
|
package/tx/workers/expand.js
CHANGED
|
@@ -12,7 +12,7 @@ const { TerminologyWorker } = require('./worker');
|
|
|
12
12
|
const {TxParameters} = require("../params");
|
|
13
13
|
const {Designations, SearchFilterText} = require("../library/designations");
|
|
14
14
|
const {Extensions} = require("../library/extensions");
|
|
15
|
-
const {getValuePrimitive, getValueName} = require("../../library/utilities");
|
|
15
|
+
const {getValuePrimitive, getValueName, validateParameter} = require("../../library/utilities");
|
|
16
16
|
const {div} = require("../../library/html");
|
|
17
17
|
const {Issue, OperationOutcome} = require("../library/operation-outcome");
|
|
18
18
|
const crypto = require('crypto');
|
|
@@ -624,13 +624,15 @@ class ValueSetExpander {
|
|
|
624
624
|
if (vsInfo && vsInfo.isSimple) {
|
|
625
625
|
vsInfo.handleByCS = cs.handlesSelecting();
|
|
626
626
|
}
|
|
627
|
-
if (cs.contentMode()
|
|
627
|
+
if (!cs.contentMode()) {
|
|
628
|
+
throw new Issue('error', 'business-rule', null, null, 'The code system definition for ' + cset.system + ' has no content property, so this expansion cannot be performed', 'invalid');
|
|
629
|
+
} else if (cs.contentMode() !== 'complete') {
|
|
628
630
|
if (cs.contentMode() === 'not-present') {
|
|
629
631
|
throw new Issue('error', 'business-rule', null, null, 'The code system definition for ' + cset.system + ' has no content, so this expansion cannot be performed', 'invalid');
|
|
630
632
|
} else if (cs.contentMode() === 'supplement') {
|
|
631
633
|
throw new Issue('error', 'business-rule', null, null, 'The code system definition for ' + cset.system + ' defines a supplement, so this expansion cannot be performed', 'invalid');
|
|
632
634
|
} else {
|
|
633
|
-
this.addParamUri(cs.contentMode(), cs.system + '|' + cs.version);
|
|
635
|
+
this.addParamUri(exp, cs.contentMode(), cs.system + '|' + cs.version);
|
|
634
636
|
Extensions.addString(exp, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed",
|
|
635
637
|
"This extension is based on a fragment of the code system " + cset.system);
|
|
636
638
|
}
|
|
@@ -1437,6 +1439,9 @@ class ValueSetExpander {
|
|
|
1437
1439
|
}
|
|
1438
1440
|
|
|
1439
1441
|
addParamUri(exp, name, value) {
|
|
1442
|
+
validateParameter(name, 'name', String);
|
|
1443
|
+
validateParameter(value, 'value', String);
|
|
1444
|
+
|
|
1440
1445
|
if (!this.hasParam(exp, name, value)) {
|
|
1441
1446
|
if (!exp.parameter) {
|
|
1442
1447
|
exp.parameter = [];
|
package/tx/workers/search.js
CHANGED
|
@@ -177,6 +177,12 @@ class SearchWorker extends TerminologyWorker {
|
|
|
177
177
|
isMatch = false;
|
|
178
178
|
break;
|
|
179
179
|
}
|
|
180
|
+
} else if (param === 'url') { // exact match
|
|
181
|
+
const propValue = json.url;
|
|
182
|
+
if (propValue != searchValue) {
|
|
183
|
+
isMatch = false;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
180
186
|
} else {
|
|
181
187
|
// Standard partial text match
|
|
182
188
|
const propValue = json[jsonProp];
|
|
@@ -205,19 +211,25 @@ class SearchWorker extends TerminologyWorker {
|
|
|
205
211
|
// Convert params object to array format expected by ValueSet providers
|
|
206
212
|
// Exclude control params (_offset, _count, _elements, _sort)
|
|
207
213
|
const searchParams = [];
|
|
214
|
+
let source = null;
|
|
208
215
|
for (const [key, value] of Object.entries(params)) {
|
|
209
216
|
if (!key.startsWith('_') && value && SearchWorker.ALLOWED_PARAMS.includes(key)) {
|
|
210
217
|
searchParams.push({ name: key, value: value });
|
|
211
218
|
}
|
|
219
|
+
if (key == 'source') {
|
|
220
|
+
source = value;
|
|
221
|
+
}
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
for (const vsp of this.provider.valueSetProviders) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
if (!source || source == vsp.sourcePackage()) {
|
|
226
|
+
this.deadCheck('searchValueSets-providers');
|
|
227
|
+
const results = await vsp.searchValueSets(searchParams, elements);
|
|
228
|
+
if (results && Array.isArray(results)) {
|
|
229
|
+
for (const vs of results) {
|
|
230
|
+
this.deadCheck('searchValueSets-results');
|
|
231
|
+
allMatches.push(vs.jsonObj || vs);
|
|
232
|
+
}
|
|
221
233
|
}
|
|
222
234
|
}
|
|
223
235
|
}
|