fhirsmith 0.18.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -16,9 +16,11 @@ const {ConceptMap} = require("../library/conceptmap");
|
|
|
16
16
|
const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
|
|
17
17
|
const {Issue} = require("../library/operation-outcome");
|
|
18
18
|
const {debugLog} = require("../operation-context");
|
|
19
|
-
const {editionName, editionCode} = require("../sct/editions");
|
|
19
|
+
const {editionName, editionCode, SNOMED_LINKABLE_EDITIONS} = require("../sct/editions");
|
|
20
20
|
const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
|
|
21
21
|
const Logger = require("../../library/logger");
|
|
22
|
+
const {Language} = require("../../library/languages");
|
|
23
|
+
const regexUtilities = require("../../library/regex-utilities");
|
|
22
24
|
|
|
23
25
|
// Symbol key under which a resolved ECL filter analysis is memoised directly on
|
|
24
26
|
// the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
|
|
@@ -30,6 +32,7 @@ const Logger = require("../../library/logger");
|
|
|
30
32
|
// _cacheEcl): a wildcard's materialised form depends on forIteration and is not
|
|
31
33
|
// shared - it simply recomputes.
|
|
32
34
|
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
35
|
+
const SNOMED_DISPLAY_REGEX = Symbol('snomedDisplayRegex');
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Alternate identifier schemes (sct2_Identifier) that FHIRsmith knows how to present:
|
|
@@ -37,7 +40,7 @@ const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
|
37
40
|
* returned as. An identifier in an unknown scheme is kept but not reported.
|
|
38
41
|
*/
|
|
39
42
|
const IDENTIFIER_SCHEMES = {
|
|
40
|
-
'30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
|
|
43
|
+
'30051010000102': { system: 'http://loinc.org', property: 'loinc', alias: 'LOINC' } // |LOINC code identifier| (LOINC Extension)
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
/** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
|
|
@@ -58,6 +61,8 @@ const SCT_LANGUAGE_CODES = {
|
|
|
58
61
|
9: 'cs'
|
|
59
62
|
};
|
|
60
63
|
|
|
64
|
+
const SCT_LANGUAGE_BYTES = Object.fromEntries(Object.entries(SCT_LANGUAGE_CODES).map(([b, c]) => [c, Number(b)]));
|
|
65
|
+
|
|
61
66
|
// ECL dialect aliases (ECL specification, appendix "Dialect aliases") to the
|
|
62
67
|
// language reference set they denote. Used by {{ D dialect = en-gb }}.
|
|
63
68
|
const ECL_DIALECT_ALIASES = {
|
|
@@ -77,6 +82,7 @@ const ECL_DIALECT_ALIASES = {
|
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
// Context kinds matching Pascal enum
|
|
85
|
+
|
|
80
86
|
const SnomedProviderContextKind = {
|
|
81
87
|
CODE: 0,
|
|
82
88
|
EXPRESSION: 1
|
|
@@ -146,14 +152,54 @@ class SnomedFilterContext {
|
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
class SnomedPrep {
|
|
149
|
-
constructor() {
|
|
155
|
+
constructor(languages = null) {
|
|
150
156
|
this.filters = [];
|
|
157
|
+
this.languages = languages; // the languages the request named, or null
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
/**
|
|
155
162
|
* Core SNOMED services providing access to structures and expression processing
|
|
156
163
|
*/
|
|
164
|
+
/**
|
|
165
|
+
* A matcher for an ECL wild: term: case-insensitive, anchored at both ends, '*' matching any
|
|
166
|
+
* run of characters (a run of '*'s the same as one) and every other character literal.
|
|
167
|
+
*
|
|
168
|
+
* No regex: a native RegExp built from '*' -> '.*' backtracks for a time that grows as the
|
|
169
|
+
* term length to the power of the number of '*'s (one $expand burned 90 s of CPU), and re2js,
|
|
170
|
+
* though linear, is 10-70 times slower than this over a whole edition's descriptions. The
|
|
171
|
+
* pieces between the '*'s are literal, so the term must start with the first and end with
|
|
172
|
+
* the last, and each one in between is found with indexOf after the one before - taking the
|
|
173
|
+
* leftmost match of each is always right for such a pattern. Linear, with no backtracking.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} value - the wild: term
|
|
176
|
+
* @returns {function(string): boolean}
|
|
177
|
+
*/
|
|
178
|
+
function wildTermMatcher(value) {
|
|
179
|
+
const parts = value.toLowerCase().split(/\*+/);
|
|
180
|
+
const first = parts[0];
|
|
181
|
+
const last = parts[parts.length - 1];
|
|
182
|
+
if (parts.length === 1) {
|
|
183
|
+
return (term) => term.toLowerCase() === first;
|
|
184
|
+
}
|
|
185
|
+
const middle = parts.slice(1, -1).filter(p => p.length > 0);
|
|
186
|
+
return (term) => {
|
|
187
|
+
const t = term.toLowerCase();
|
|
188
|
+
if (t.length < first.length + last.length || !t.startsWith(first)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
let pos = first.length;
|
|
192
|
+
for (const part of middle) {
|
|
193
|
+
const i = t.indexOf(part, pos);
|
|
194
|
+
if (i < 0) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
pos = i + part.length;
|
|
198
|
+
}
|
|
199
|
+
return t.length - pos >= last.length && t.endsWith(last);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
157
203
|
class SnomedServices {
|
|
158
204
|
constructor(sharedData) {
|
|
159
205
|
// Core data structures
|
|
@@ -206,6 +252,39 @@ class SnomedServices {
|
|
|
206
252
|
this._mrcmRangeCache = new Map();
|
|
207
253
|
|
|
208
254
|
this.#loadIdentifiers(sharedData.identifiers || []);
|
|
255
|
+
this.#loadConcreteValues(sharedData.concreteValues);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Index the concrete value relationships (cache version 19: rows of
|
|
260
|
+
* [sourceId, typeId, group, value], value as written in RF2 - "#500", "#5.5",
|
|
261
|
+
* a quoted string, or true/false) by source concept index, for the ECL
|
|
262
|
+
* evaluator. `concreteValues` stays null when the cache predates them, which
|
|
263
|
+
* is how a concrete-value refinement knows to report itself as unsupported
|
|
264
|
+
* rather than answering with nothing.
|
|
265
|
+
*/
|
|
266
|
+
#loadConcreteValues(rows) {
|
|
267
|
+
this.concreteValues = null;
|
|
268
|
+
if (!rows) return;
|
|
269
|
+
this.concreteValues = new Map();
|
|
270
|
+
for (const [sourceId, typeId, group, raw] of rows) {
|
|
271
|
+
const src = this.concepts.findConcept(sourceId);
|
|
272
|
+
const type = this.concepts.findConcept(typeId);
|
|
273
|
+
if (!src.found || !type.found) continue;
|
|
274
|
+
let value;
|
|
275
|
+
if (/^#-?\d+(\.\d+)?$/.test(raw)) {
|
|
276
|
+
value = { kind: 'number', value: Number(raw.substring(1)), decimal: raw.includes('.') };
|
|
277
|
+
} else if (/^".*"$/.test(raw)) {
|
|
278
|
+
value = { kind: 'string', value: raw.substring(1, raw.length - 1) };
|
|
279
|
+
} else if (/^(true|false)$/i.test(raw)) {
|
|
280
|
+
value = { kind: 'boolean', value: raw.toLowerCase() === 'true' };
|
|
281
|
+
} else {
|
|
282
|
+
value = { kind: 'string', value: raw };
|
|
283
|
+
}
|
|
284
|
+
const list = this.concreteValues.get(src.index) || [];
|
|
285
|
+
list.push({ relType: type.index, group: group || 0, concrete: value });
|
|
286
|
+
this.concreteValues.set(src.index, list);
|
|
287
|
+
}
|
|
209
288
|
}
|
|
210
289
|
|
|
211
290
|
/**
|
|
@@ -626,6 +705,9 @@ class SnomedServices {
|
|
|
626
705
|
}
|
|
627
706
|
}
|
|
628
707
|
|
|
708
|
+
// The concept's active outbound relationships. Inactive rows are imported (they are in the
|
|
709
|
+
// cache) but ignored altogether, as agreed with the SNOMED leads: a retired row no longer
|
|
710
|
+
// describes the concept, so no filter, lookup or refinement may see it.
|
|
629
711
|
getConceptRelationships(reference) {
|
|
630
712
|
try {
|
|
631
713
|
const concept = this.concepts.getConcept(reference);
|
|
@@ -633,7 +715,7 @@ class SnomedServices {
|
|
|
633
715
|
|
|
634
716
|
if (relRef === 0) return [];
|
|
635
717
|
|
|
636
|
-
return this.refs.getReferences(relRef) || [];
|
|
718
|
+
return (this.refs.getReferences(relRef) || []).filter(relIdx => this.relationships.isActive(relIdx));
|
|
637
719
|
} catch (error) {
|
|
638
720
|
return [];
|
|
639
721
|
}
|
|
@@ -806,6 +888,35 @@ class SnomedServices {
|
|
|
806
888
|
return result;
|
|
807
889
|
}
|
|
808
890
|
|
|
891
|
+
// descendent-leaf (R5): the concept's descendants that have no children of their own
|
|
892
|
+
filterDescendentLeaf(id) {
|
|
893
|
+
const result = new SnomedFilterContext();
|
|
894
|
+
const conceptResult = this.concepts.findConcept(id);
|
|
895
|
+
|
|
896
|
+
if (!conceptResult.found) {
|
|
897
|
+
throw new Error(`The SNOMED CT Concept ${id} is not known`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
result.descendants = this.getConceptDescendants(conceptResult.index)
|
|
901
|
+
.filter(ref => this.getConceptChildren(ref).length === 0);
|
|
902
|
+
return result;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// is-not-a (R5): every concept except the concept itself and its descendants - the
|
|
906
|
+
// same population as the ECL * MINUS << id
|
|
907
|
+
filterIsNotA(id, opContext = null) {
|
|
908
|
+
const result = new SnomedFilterContext();
|
|
909
|
+
const conceptResult = this.concepts.findConcept(id);
|
|
910
|
+
|
|
911
|
+
if (!conceptResult.found) {
|
|
912
|
+
throw new Error(`The SNOMED CT Concept ${id} is not known`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const excluded = new Set([conceptResult.index, ...this.getConceptDescendants(conceptResult.index)]);
|
|
916
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext).filter(ref => !excluded.has(ref));
|
|
917
|
+
return result;
|
|
918
|
+
}
|
|
919
|
+
|
|
809
920
|
filterChildOf(id = true) {
|
|
810
921
|
const result = new SnomedFilterContext();
|
|
811
922
|
const conceptResult = this.concepts.findConcept(id);
|
|
@@ -853,10 +964,25 @@ class SnomedServices {
|
|
|
853
964
|
}
|
|
854
965
|
|
|
855
966
|
|
|
967
|
+
// concept in A,B,C: any of the listed concepts; a listed reference set stands for its
|
|
968
|
+
// members. Members are kept as reference set member records ({ref}), which is what every
|
|
969
|
+
// consumer of set.members reads, so a plain concept is wrapped the same way; and each
|
|
970
|
+
// concept appears once, however many times it is named or reached.
|
|
856
971
|
filterIn(idList) {
|
|
857
972
|
const result = new SnomedFilterContext();
|
|
858
|
-
|
|
973
|
+
const members = [];
|
|
974
|
+
const seen = new Set();
|
|
975
|
+
const add = (member) => {
|
|
976
|
+
if (!seen.has(member.ref)) {
|
|
977
|
+
seen.add(member.ref);
|
|
978
|
+
members.push(member);
|
|
979
|
+
}
|
|
980
|
+
};
|
|
859
981
|
for (let id of idList.split(',')) {
|
|
982
|
+
id = id.trim();
|
|
983
|
+
if (!id) {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
860
986
|
const conceptResult = this.concepts.findConcept(id);
|
|
861
987
|
|
|
862
988
|
if (!conceptResult.found) {
|
|
@@ -865,9 +991,11 @@ class SnomedServices {
|
|
|
865
991
|
|
|
866
992
|
const refSetIndex = this.getConceptRefSet(conceptResult.index, false);
|
|
867
993
|
if (refSetIndex === 0) {
|
|
868
|
-
|
|
994
|
+
add({ ref: conceptResult.index });
|
|
869
995
|
} else {
|
|
870
|
-
|
|
996
|
+
for (const member of this.refSetMembers.getMembers(refSetIndex) || []) {
|
|
997
|
+
add(member);
|
|
998
|
+
}
|
|
871
999
|
}
|
|
872
1000
|
}
|
|
873
1001
|
result.members = members;
|
|
@@ -912,14 +1040,16 @@ class SnomedServices {
|
|
|
912
1040
|
* hierarchy operators on *, ^ and bracketed sets (applied per member)
|
|
913
1041
|
* AND / OR / MINUS compound expressions
|
|
914
1042
|
* !!> / !!< (top / bottom of set)
|
|
915
|
-
* refinements (= against an expression,
|
|
916
|
-
* attribute
|
|
1043
|
+
* refinements (= and != against an expression, reversed (R) attributes,
|
|
1044
|
+
* attribute AND/OR/",", groups, attribute and group cardinality,
|
|
1045
|
+
* attribute names that are expressions),
|
|
917
1046
|
* dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
|
|
918
|
-
* filters,
|
|
1047
|
+
* filters, {{ + HISTORY... }} history supplements, concrete (numeric /
|
|
1048
|
+
* string / boolean) values in refinements (cache version 19+), comments
|
|
1049
|
+
*
|
|
1050
|
+
* alternate identifiers (LOINC#code, from the identifier scheme aliases)
|
|
919
1051
|
*
|
|
920
|
-
* Everything else (
|
|
921
|
-
* boolean) values in refinements, alternate identifiers, reference set field
|
|
922
|
-
* selection on ^, {{ + ... }} history supplements) throws an informative
|
|
1052
|
+
* Everything else (reference set field selection on ^) throws an informative
|
|
923
1053
|
* error which is reported as UNSUPPORTED_ECL.
|
|
924
1054
|
*/
|
|
925
1055
|
|
|
@@ -1068,9 +1198,6 @@ class SnomedServices {
|
|
|
1068
1198
|
// ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
|
|
1069
1199
|
// and description filters ({{ D ... }}) are applied to the result below.
|
|
1070
1200
|
if (node.filters && node.filters.length > 0) {
|
|
1071
|
-
if (node.filters.some(fc => fc.kind === '+')) {
|
|
1072
|
-
throw new Error('ECL history supplements ({{ + ... }}) are not supported');
|
|
1073
|
-
}
|
|
1074
1201
|
const inner = { ...node, filters: undefined };
|
|
1075
1202
|
let ctx = this._evalSubExpression(inner, opContext);
|
|
1076
1203
|
const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
|
|
@@ -1080,12 +1207,26 @@ class SnomedServices {
|
|
|
1080
1207
|
for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
|
|
1081
1208
|
ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
|
|
1082
1209
|
}
|
|
1210
|
+
for (const fc of node.filters.filter(fc => fc.kind === '+')) {
|
|
1211
|
+
ctx = this._applyHistorySupplement(ctx, fc, opContext);
|
|
1212
|
+
}
|
|
1083
1213
|
return ctx;
|
|
1084
1214
|
}
|
|
1085
1215
|
|
|
1086
|
-
// Alternate identifiers (ECL 2.1 "scheme#code")
|
|
1216
|
+
// Alternate identifiers (ECL 2.1 "scheme#code"): the concept(s) that carry
|
|
1217
|
+
// the code in that identifier scheme, then the operator applies to them as
|
|
1218
|
+
// to any set. The scheme is an alias from the ECL specification (LOINC) or
|
|
1219
|
+
// the identifier scheme concept's own id.
|
|
1087
1220
|
if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
|
|
1088
|
-
|
|
1221
|
+
const set = this._eclAltIdentifier(focus);
|
|
1222
|
+
if (!operator) {
|
|
1223
|
+
const result = new SnomedFilterContext();
|
|
1224
|
+
result.descendants = set;
|
|
1225
|
+
return result;
|
|
1226
|
+
}
|
|
1227
|
+
const base = new SnomedFilterContext();
|
|
1228
|
+
base.descendants = set;
|
|
1229
|
+
return this._eclApplyOperatorToSet(base, operator, opContext);
|
|
1089
1230
|
}
|
|
1090
1231
|
|
|
1091
1232
|
// Top / bottom of set: the members of the focus set with no ancestor
|
|
@@ -1160,6 +1301,32 @@ class SnomedServices {
|
|
|
1160
1301
|
return result;
|
|
1161
1302
|
};
|
|
1162
1303
|
|
|
1304
|
+
/**
|
|
1305
|
+
* Resolve an ECL alternate identifier (scheme#code) to concept indexes. The
|
|
1306
|
+
* scheme is a known alias (IDENTIFIER_SCHEMES[].alias, case-insensitive) or
|
|
1307
|
+
* an identifier scheme concept id. An unknown scheme, or a code nobody has,
|
|
1308
|
+
* is an invalid expression - the same standing as an unknown concept.
|
|
1309
|
+
*/
|
|
1310
|
+
_eclAltIdentifier = function (node) {
|
|
1311
|
+
let schemeId = null;
|
|
1312
|
+
if (/^\d{6,18}$/.test(node.scheme)) {
|
|
1313
|
+
schemeId = node.scheme;
|
|
1314
|
+
} else {
|
|
1315
|
+
for (const [id, def] of Object.entries(IDENTIFIER_SCHEMES)) {
|
|
1316
|
+
if (def.alias && def.alias.toLowerCase() === node.scheme.toLowerCase()) schemeId = id;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const invalid = (msg) => { const e = new Error(msg); e.eclInvalid = true; return e; };
|
|
1320
|
+
if (!schemeId) {
|
|
1321
|
+
throw invalid(`The alternate identifier scheme "${node.scheme}" is not known`);
|
|
1322
|
+
}
|
|
1323
|
+
const concepts = this.getConceptsForIdentifier(schemeId, node.code);
|
|
1324
|
+
if (concepts.length === 0) {
|
|
1325
|
+
throw invalid(`No concept has the alternate identifier ${node.scheme}#${node.code}`);
|
|
1326
|
+
}
|
|
1327
|
+
return concepts.map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1163
1330
|
/**
|
|
1164
1331
|
* !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
|
|
1165
1332
|
* descendant (bottom) that is also in `set`. Walks the stated parents of each
|
|
@@ -1317,6 +1484,9 @@ class SnomedServices {
|
|
|
1317
1484
|
}
|
|
1318
1485
|
const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
|
|
1319
1486
|
const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
|
|
1487
|
+
const rs = memberOfNode.refSet;
|
|
1488
|
+
const explicit = rs.type === ECLNodeType.CONCEPT_REFERENCE ||
|
|
1489
|
+
(rs.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT && !rs.operator && rs.focus.type === ECLNodeType.CONCEPT_REFERENCE);
|
|
1320
1490
|
|
|
1321
1491
|
const members = new Set();
|
|
1322
1492
|
for (const refsetIdx of refsetConcepts) {
|
|
@@ -1334,9 +1504,12 @@ class SnomedServices {
|
|
|
1334
1504
|
}
|
|
1335
1505
|
const memberList = this.refSetMembers.getMembers(membersRef) || [];
|
|
1336
1506
|
// A reference set whose members are descriptions (a language reference
|
|
1337
|
-
// set, say) has no concept members to return.
|
|
1338
|
-
// answering with an empty set, which would
|
|
1339
|
-
|
|
1507
|
+
// set, say) has no concept members to return. When it was named
|
|
1508
|
+
// explicitly, say so rather than answering with an empty set, which would
|
|
1509
|
+
// read as "no members". When it merely falls inside a computed operand
|
|
1510
|
+
// (^ *, ^ (< 900000000000455006)) it contributes nothing and the rest of
|
|
1511
|
+
// the operand still counts.
|
|
1512
|
+
if (explicit && memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
|
|
1340
1513
|
throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
|
|
1341
1514
|
}
|
|
1342
1515
|
for (const m of memberList) {
|
|
@@ -1356,6 +1529,82 @@ class SnomedServices {
|
|
|
1356
1529
|
return result;
|
|
1357
1530
|
};
|
|
1358
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* Apply a history supplement ({{ + HISTORY... }}): add to the result the
|
|
1534
|
+
* inactive concepts that the selected historical association reference sets
|
|
1535
|
+
* associate with a concept in the result (the association rows whose
|
|
1536
|
+
* targetComponentId is in the result).
|
|
1537
|
+
*
|
|
1538
|
+
* Profiles (ECL 2.x): MIN = SAME AS; MOD = SAME AS, WAS A, REPLACED BY,
|
|
1539
|
+
* PARTIALLY EQUIVALENT TO; MAX (and a bare HISTORY) = every reference set
|
|
1540
|
+
* under 900000000000522004 |Historical association|; HISTORY (expr) = the
|
|
1541
|
+
* reference sets the expression yields. A reference set the edition does not
|
|
1542
|
+
* carry simply contributes nothing.
|
|
1543
|
+
*/
|
|
1544
|
+
_applyHistorySupplement = function (ctx, hs, opContext) {
|
|
1545
|
+
const HISTORICAL_ASSOCIATION = '900000000000522004';
|
|
1546
|
+
const PROFILES = {
|
|
1547
|
+
MIN: ['900000000000527005'],
|
|
1548
|
+
MOD: ['900000000000527005', '900000000000528000', '900000000000526001', '1186924009']
|
|
1549
|
+
};
|
|
1550
|
+
let refsetIdxs;
|
|
1551
|
+
if (hs.profile === 'EXPLICIT') {
|
|
1552
|
+
refsetIdxs = this._eclResolveSet(this._evalECLNode(hs.refsets, opContext), opContext);
|
|
1553
|
+
} else if (hs.profile === 'MAX') {
|
|
1554
|
+
const root = this.concepts.findConcept(HISTORICAL_ASSOCIATION);
|
|
1555
|
+
refsetIdxs = root.found ? this._eclToIndexArray(this.filterIsA(HISTORICAL_ASSOCIATION, false)) : [];
|
|
1556
|
+
} else {
|
|
1557
|
+
refsetIdxs = PROFILES[hs.profile].map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const base = this._eclResolveSet(ctx, opContext);
|
|
1561
|
+
const inResult = new Set(base);
|
|
1562
|
+
const added = new Set();
|
|
1563
|
+
for (const refsetIdx of refsetIdxs) {
|
|
1564
|
+
if (opContext) opContext.deadCheck('ecl:history');
|
|
1565
|
+
const byTarget = this._historyIndex(refsetIdx);
|
|
1566
|
+
if (!byTarget) continue;
|
|
1567
|
+
for (const [target, sources] of byTarget) {
|
|
1568
|
+
if (inResult.has(target)) {
|
|
1569
|
+
for (const src of sources) {
|
|
1570
|
+
if (!inResult.has(src)) added.add(src);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const result = new SnomedFilterContext();
|
|
1576
|
+
result.descendants = base.concat([...added]);
|
|
1577
|
+
return result;
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* target concept index -> [referenced (inactive) concept indexes] for one
|
|
1582
|
+
* association reference set, built once per edition. null when the concept
|
|
1583
|
+
* is not a reference set with concept-valued members.
|
|
1584
|
+
*/
|
|
1585
|
+
_historyIndex = function (refsetIdx) {
|
|
1586
|
+
this._historyIndexes = this._historyIndexes || new Map();
|
|
1587
|
+
if (this._historyIndexes.has(refsetIdx)) {
|
|
1588
|
+
return this._historyIndexes.get(refsetIdx);
|
|
1589
|
+
}
|
|
1590
|
+
let index = null;
|
|
1591
|
+
const refSet = this._findRefSetRecord(refsetIdx);
|
|
1592
|
+
if (refSet && refSet.membersByRef && refSet.membersByRef !== 0xFFFFFFFF) {
|
|
1593
|
+
for (const m of this.refSetMembers.getMembers(refSet.membersByRef) || []) {
|
|
1594
|
+
if (m.kind !== 0 || !m.values) continue;
|
|
1595
|
+
const values = this.refs.getReferences(m.values);
|
|
1596
|
+
// targetComponentId is the first additional field, stored as (value, type) with type 1 = concept
|
|
1597
|
+
if (!values || values.length < 2 || values[1] !== 1) continue;
|
|
1598
|
+
index = index || new Map();
|
|
1599
|
+
const list = index.get(values[0]) || [];
|
|
1600
|
+
list.push(m.ref);
|
|
1601
|
+
index.set(values[0], list);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
this._historyIndexes.set(refsetIdx, index);
|
|
1605
|
+
return index;
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1359
1608
|
/**
|
|
1360
1609
|
* Apply {{ C ... }} concept filters to a result: each concept is tested on
|
|
1361
1610
|
* its own active flag, module, effectiveTime and definition status. Several
|
|
@@ -1546,8 +1795,7 @@ class SnomedServices {
|
|
|
1546
1795
|
*/
|
|
1547
1796
|
_eclTermMatcher = function (v) {
|
|
1548
1797
|
if (v.kind === 'wild') {
|
|
1549
|
-
|
|
1550
|
-
return (term) => re.test(term);
|
|
1798
|
+
return wildTermMatcher(v.value);
|
|
1551
1799
|
}
|
|
1552
1800
|
const words = v.value.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
|
1553
1801
|
return (term) => {
|
|
@@ -1855,17 +2103,11 @@ class SnomedServices {
|
|
|
1855
2103
|
* @returns {boolean}
|
|
1856
2104
|
*/
|
|
1857
2105
|
_attributeMatches = function (rels, attr, groupFilter, opContext) {
|
|
1858
|
-
if (attr.reverse) {
|
|
1859
|
-
throw new Error('ECL reverse attributes (R) are not yet supported');
|
|
1860
|
-
}
|
|
1861
2106
|
if (!attr.comparison) {
|
|
1862
2107
|
throw new Error('ECL attribute without a comparison is not supported');
|
|
1863
2108
|
}
|
|
1864
|
-
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
|
|
1865
|
-
throw new Error('ECL concrete values (numeric, string and boolean attribute values)
|
|
1866
|
-
}
|
|
1867
|
-
if (attr.comparison.operator !== ECLTokenType.EQUALS) {
|
|
1868
|
-
throw new Error('ECL != in refinements is not yet supported');
|
|
2109
|
+
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON && !this.concreteValues) {
|
|
2110
|
+
throw new Error('ECL concrete values (numeric, string and boolean attribute values) cannot be evaluated - this edition was imported without its concrete value relationships');
|
|
1869
2111
|
}
|
|
1870
2112
|
|
|
1871
2113
|
const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
|
|
@@ -1895,15 +2137,30 @@ class SnomedServices {
|
|
|
1895
2137
|
// fresh per request, so memoising on the node is safe within a request.
|
|
1896
2138
|
let resolved = attr._eclResolved;
|
|
1897
2139
|
if (!resolved) {
|
|
1898
|
-
resolved = {
|
|
1899
|
-
|
|
1900
|
-
valueSet
|
|
1901
|
-
}
|
|
2140
|
+
resolved = { types: this._eclAttributeTypes(attr.name, opContext) };
|
|
2141
|
+
if (attr.comparison.type === ECLNodeType.EXPRESSION_COMPARISON) {
|
|
2142
|
+
resolved.valueSet = new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext));
|
|
2143
|
+
} else {
|
|
2144
|
+
resolved.concrete = this._eclConcreteMatcher(attr.comparison);
|
|
2145
|
+
}
|
|
1902
2146
|
attr._eclResolved = resolved;
|
|
1903
2147
|
}
|
|
1904
2148
|
const types = resolved.types;
|
|
1905
2149
|
const valueSet = resolved.valueSet;
|
|
1906
2150
|
|
|
2151
|
+
if (resolved.concrete) {
|
|
2152
|
+
// A concrete-value comparison: only the concrete rows count, each
|
|
2153
|
+
// occurrence being a (group, value) pair
|
|
2154
|
+
const matched = new Set();
|
|
2155
|
+
for (const rel of rels) {
|
|
2156
|
+
if (!rel.concrete) continue;
|
|
2157
|
+
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
2158
|
+
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
2159
|
+
if (resolved.concrete(rel.concrete)) matched.add(rel.group + ':' + JSON.stringify(rel.concrete.value));
|
|
2160
|
+
}
|
|
2161
|
+
return matched.size;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
1907
2164
|
// ECL cardinality counts OCCURRENCES of a matching attribute, per role group
|
|
1908
2165
|
// — not distinct values. ECL runs against the necessary normal form, where a
|
|
1909
2166
|
// role group has no redundant attributes, so the same (type, value) in two
|
|
@@ -1912,13 +2169,26 @@ class SnomedServices {
|
|
|
1912
2169
|
// form, not to de-duplicating across groups). We therefore count distinct
|
|
1913
2170
|
// (group, target) pairs: within a group filter this is the matching values
|
|
1914
2171
|
// in that group; ungrouped it is the total occurrences across all groups.
|
|
2172
|
+
//
|
|
2173
|
+
// "!=" counts the occurrences whose value is NOT in the set (so "A != X"
|
|
2174
|
+
// matches a concept with at least one A whose value is something other than
|
|
2175
|
+
// X, and "[0..0] A != X" one whose every A is X).
|
|
2176
|
+
//
|
|
2177
|
+
// A reversed attribute ("R A = X") is matched against the concept's INBOUND
|
|
2178
|
+
// relationships: the concept is the value of an A attribute on some concept
|
|
2179
|
+
// in X. _conceptRelRecords supplies those tagged `inbound`; the records built
|
|
2180
|
+
// for a post-coordinated expression have none, so R matches nothing there.
|
|
2181
|
+
const notEquals = attr.comparison.operator === ECLTokenType.NOT_EQUALS;
|
|
2182
|
+
const reverse = !!attr.reverse;
|
|
1915
2183
|
const matched = new Set();
|
|
1916
2184
|
for (const rel of rels) {
|
|
1917
2185
|
if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
|
|
1918
|
-
if (!rel.active) continue;
|
|
2186
|
+
if (!rel.active || rel.concrete) continue;
|
|
2187
|
+
if (!!rel.inbound !== reverse) continue;
|
|
1919
2188
|
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
1920
2189
|
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
1921
|
-
|
|
2190
|
+
const value = reverse ? rel.source : rel.target;
|
|
2191
|
+
if (valueSet.has(value) !== notEquals) matched.add(rel.group + ':' + value);
|
|
1922
2192
|
}
|
|
1923
2193
|
return matched.size;
|
|
1924
2194
|
};
|
|
@@ -1983,12 +2253,58 @@ class SnomedServices {
|
|
|
1983
2253
|
// target, group} — the shape the refinement matchers consume. Sharing this
|
|
1984
2254
|
// shape lets the same matchers run against a post-coordinated expression's
|
|
1985
2255
|
// relationships (see _expressionRelRecords).
|
|
2256
|
+
/**
|
|
2257
|
+
* A predicate over a stored concrete value for one comparison node:
|
|
2258
|
+
* numeric: = != < <= > >= against a number (integers and decimals compare as
|
|
2259
|
+
* numbers, so #5 = #5.0)
|
|
2260
|
+
* string: = / != against typed search terms (match / wild semantics, as for
|
|
2261
|
+
* description terms), any of a set
|
|
2262
|
+
* boolean: = / != true/false
|
|
2263
|
+
* A value of another kind never matches (and never "not-matches" either: a
|
|
2264
|
+
* string attribute is not "!= #5").
|
|
2265
|
+
*/
|
|
2266
|
+
_eclConcreteMatcher = function (comparison) {
|
|
2267
|
+
switch (comparison.type) {
|
|
2268
|
+
case ECLNodeType.NUMERIC_COMPARISON: {
|
|
2269
|
+
const n = Number(comparison.value);
|
|
2270
|
+
const op = { [ECLTokenType.EQUALS]: '=', [ECLTokenType.NOT_EQUALS]: '!=', [ECLTokenType.LT]: '<', [ECLTokenType.LTE]: '<=', [ECLTokenType.GT]: '>', [ECLTokenType.GTE]: '>=' }[comparison.operator] || comparison.operator;
|
|
2271
|
+
return (v) => v.kind === 'number' && this._eclCompareValues(v.value, op, [n], (a, b) => a - b);
|
|
2272
|
+
}
|
|
2273
|
+
case ECLNodeType.STRING_COMPARISON: {
|
|
2274
|
+
const matchers = comparison.values.map(t => this._eclTermMatcher(typeof t === 'string' ? { kind: 'match', value: t } : t));
|
|
2275
|
+
const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
|
|
2276
|
+
return (v) => v.kind === 'string' && (matchers.some(m => m(v.value)) !== neg);
|
|
2277
|
+
}
|
|
2278
|
+
case ECLNodeType.BOOLEAN_COMPARISON: {
|
|
2279
|
+
const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
|
|
2280
|
+
return (v) => v.kind === 'boolean' && ((v.value === comparison.value) !== neg);
|
|
2281
|
+
}
|
|
2282
|
+
default:
|
|
2283
|
+
throw new Error(`Unsupported comparison ${comparison.type}`);
|
|
2284
|
+
}
|
|
2285
|
+
};
|
|
2286
|
+
|
|
1986
2287
|
_conceptRelRecords = function (conceptIdx) {
|
|
1987
|
-
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1988
2288
|
const out = [];
|
|
1989
|
-
for (const relIdx of
|
|
2289
|
+
for (const relIdx of this.getConceptRelationships(conceptIdx)) {
|
|
1990
2290
|
out.push(this.relationships.getRelationship(relIdx));
|
|
1991
2291
|
}
|
|
2292
|
+
// concrete value rows, for numeric / string / boolean comparisons
|
|
2293
|
+
if (this.concreteValues) {
|
|
2294
|
+
for (const cv of this.concreteValues.get(conceptIdx) || []) {
|
|
2295
|
+
out.push({ active: true, relType: cv.relType, group: cv.group, target: -1, concrete: cv.concrete });
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
// inbound relationships, for reversed (R) attributes
|
|
2299
|
+
const concept = this.concepts.getConcept(conceptIdx);
|
|
2300
|
+
if (concept.inbounds) {
|
|
2301
|
+
for (const relIdx of this.refs.getReferences(concept.inbounds) || []) {
|
|
2302
|
+
const rel = this.relationships.getRelationship(relIdx);
|
|
2303
|
+
if (rel.active && rel.relType !== this.isAIndex) {
|
|
2304
|
+
out.push({ ...rel, inbound: true });
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
1992
2308
|
return out;
|
|
1993
2309
|
};
|
|
1994
2310
|
|
|
@@ -2217,16 +2533,20 @@ class SnomedServices {
|
|
|
2217
2533
|
* matches for a typical multi-word filter, with the event loop blocked for
|
|
2218
2534
|
* the whole scan (tx.fhir.org incident 2026-09-17).
|
|
2219
2535
|
*
|
|
2536
|
+
* Only descriptions in `lang` take part, when it is given (a text filter searches one
|
|
2537
|
+
* language: see SnomedProvider.searchFilter).
|
|
2538
|
+
*
|
|
2220
2539
|
* @param {SearchFilterText} searchText
|
|
2221
2540
|
* @param {boolean} includeInactive - include inactive concepts
|
|
2222
2541
|
* @param {OperationContext|null} opContext - for yielding / deadline checks
|
|
2542
|
+
* @param {number|null} lang - the description language byte, or null for all
|
|
2223
2543
|
* @returns {Promise<SnomedFilterContext>}
|
|
2224
2544
|
*/
|
|
2225
|
-
async searchFilter(searchText, includeInactive = false, opContext = null) {
|
|
2545
|
+
async searchFilter(searchText, includeInactive = false, opContext = null, lang = null) {
|
|
2226
2546
|
const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
|
|
2227
2547
|
const tokens = filterTokens(filterText);
|
|
2228
2548
|
if (tokens.length > 0) {
|
|
2229
|
-
const index = await this.getTextIndex(opContext);
|
|
2549
|
+
const index = await this.getTextIndex(opContext, lang);
|
|
2230
2550
|
if (index) {
|
|
2231
2551
|
const result = new SnomedFilterContext();
|
|
2232
2552
|
const ordinals = index.search(tokens, opContext);
|
|
@@ -2237,7 +2557,16 @@ class SnomedServices {
|
|
|
2237
2557
|
return result;
|
|
2238
2558
|
}
|
|
2239
2559
|
}
|
|
2240
|
-
return await this.scanSearch(filterText, includeInactive, opContext);
|
|
2560
|
+
return await this.scanSearch(filterText, includeInactive, opContext, lang);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// whether lang is the only language the edition has active descriptions in (then there is
|
|
2564
|
+
// nothing to restrict - the International Edition is all English)
|
|
2565
|
+
#onlyLanguage(lang) {
|
|
2566
|
+
if (!this._activeDescLanguages) {
|
|
2567
|
+
this._activeDescLanguages = this.descriptions.activeLanguages();
|
|
2568
|
+
}
|
|
2569
|
+
return this._activeDescLanguages.size === 1 && this._activeDescLanguages.has(lang);
|
|
2241
2570
|
}
|
|
2242
2571
|
|
|
2243
2572
|
/**
|
|
@@ -2248,28 +2577,53 @@ class SnomedServices {
|
|
|
2248
2577
|
* @param {OperationContext|null} opContext
|
|
2249
2578
|
* @returns {Promise<SnomedTextIndex|null>}
|
|
2250
2579
|
*/
|
|
2251
|
-
async getTextIndex(opContext = null) {
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2580
|
+
async getTextIndex(opContext = null, lang = null) {
|
|
2581
|
+
// One index per language the edition is searched in; an edition with only one language
|
|
2582
|
+
// (the International Edition) has just the one, for all its descriptions
|
|
2583
|
+
if (lang === null || this.#onlyLanguage(lang)) {
|
|
2584
|
+
if (this._textIndex) {
|
|
2585
|
+
return this._textIndex;
|
|
2586
|
+
}
|
|
2587
|
+
if (!this._textIndexPromise) {
|
|
2588
|
+
this._textIndexPromise = this.#buildTextIndex(null, index => { this._textIndex = index; }, () => { this._textIndexPromise = null; });
|
|
2589
|
+
}
|
|
2590
|
+
return await this.#awaitTextIndex(this._textIndexPromise, opContext);
|
|
2591
|
+
}
|
|
2592
|
+
if (!this._textIndexes) {
|
|
2593
|
+
this._textIndexes = new Map();
|
|
2594
|
+
this._textIndexPromises = new Map();
|
|
2595
|
+
}
|
|
2596
|
+
if (this._textIndexes.has(lang)) {
|
|
2597
|
+
return this._textIndexes.get(lang);
|
|
2598
|
+
}
|
|
2599
|
+
if (!this._textIndexPromises.has(lang)) {
|
|
2600
|
+
this._textIndexPromises.set(lang, this.#buildTextIndex(lang, index => { this._textIndexes.set(lang, index); },
|
|
2601
|
+
() => { this._textIndexPromises.delete(lang); }));
|
|
2602
|
+
}
|
|
2603
|
+
return await this.#awaitTextIndex(this._textIndexPromises.get(lang), opContext);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
#buildTextIndex(lang, onBuilt, onFailed) {
|
|
2607
|
+
const started = Date.now();
|
|
2608
|
+
// Logger fetched lazily: importers and tests require this module too
|
|
2609
|
+
const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
|
|
2610
|
+
const which = lang === null ? '' : ` (language ${lang})`;
|
|
2611
|
+
return SnomedTextIndex.build(this, lang).then(index => {
|
|
2612
|
+
onBuilt(index);
|
|
2613
|
+
textIndexLog.info(`SNOMED text index for ${this.versionUri}${which}: ${index.words.length} words, built in ${Date.now() - started}ms`);
|
|
2614
|
+
return index;
|
|
2615
|
+
}, error => {
|
|
2616
|
+
onFailed();
|
|
2617
|
+
textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}${which}: ${error.message}`);
|
|
2618
|
+
return null;
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
async #awaitTextIndex(promise, opContext) {
|
|
2269
2623
|
if (opContext && typeof opContext.waitFor === 'function') {
|
|
2270
|
-
return await opContext.waitFor(
|
|
2624
|
+
return await opContext.waitFor(promise, 'sct:textIndex');
|
|
2271
2625
|
}
|
|
2272
|
-
return await
|
|
2626
|
+
return await promise;
|
|
2273
2627
|
}
|
|
2274
2628
|
|
|
2275
2629
|
/**
|
|
@@ -2283,7 +2637,7 @@ class SnomedServices {
|
|
|
2283
2637
|
* @param {OperationContext|null} opContext
|
|
2284
2638
|
* @returns {Promise<SnomedFilterContext>}
|
|
2285
2639
|
*/
|
|
2286
|
-
async scanSearch(filterText, includeInactive = false, opContext = null) {
|
|
2640
|
+
async scanSearch(filterText, includeInactive = false, opContext = null, lang = null) {
|
|
2287
2641
|
const result = new SnomedFilterContext();
|
|
2288
2642
|
const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
|
|
2289
2643
|
const matches = [];
|
|
@@ -2315,7 +2669,7 @@ class SnomedServices {
|
|
|
2315
2669
|
|
|
2316
2670
|
for (const descIndex of descriptionIndices) {
|
|
2317
2671
|
const description = this.descriptions.getDescription(descIndex);
|
|
2318
|
-
if (description.active) {
|
|
2672
|
+
if (description.active && (lang === null || description.lang === lang)) {
|
|
2319
2673
|
const term = this.strings.getEntry(description.iDesc).toLowerCase();
|
|
2320
2674
|
if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
|
|
2321
2675
|
matchFound = true;
|
|
@@ -2469,6 +2823,10 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2469
2823
|
if (disp) return disp;
|
|
2470
2824
|
|
|
2471
2825
|
if (ctxt.isComplex()) {
|
|
2826
|
+
// an extension-codes supplement in scope can give a post-coordinated expression a display;
|
|
2827
|
+
// otherwise it is generated from the expression
|
|
2828
|
+
const ext = this.#extensionCodeDisplay(ctxt);
|
|
2829
|
+
if (ext) return ext;
|
|
2472
2830
|
return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing);
|
|
2473
2831
|
} else {
|
|
2474
2832
|
return this.sct.getDisplayName(ctxt.getReference(), this.sct.defaultLanguage);
|
|
@@ -2517,8 +2875,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2517
2875
|
|
|
2518
2876
|
|
|
2519
2877
|
if (ctxt.isComplex()) {
|
|
2520
|
-
// For complex expressions
|
|
2521
|
-
|
|
2878
|
+
// For complex expressions: the display an extension-codes supplement in scope gives it (first,
|
|
2879
|
+
// so it is the preferred display), and the display generated from the expression, which stays
|
|
2880
|
+
// a valid display
|
|
2881
|
+
this._listSupplementDesignations(this.#extensionCodeKey(ctxt), displays, true);
|
|
2882
|
+
const display = this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing);
|
|
2522
2883
|
if (display) {
|
|
2523
2884
|
displays.addDesignation(true, 'active', 'en-US', DesignationUse.PREFERRED, display);
|
|
2524
2885
|
}
|
|
@@ -2635,6 +2996,30 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2635
2996
|
}
|
|
2636
2997
|
}
|
|
2637
2998
|
|
|
2999
|
+
/**
|
|
3000
|
+
* The code an extension-codes supplement gives a post-coordinated expression under: the expression
|
|
3001
|
+
* as it was written, or, if no supplement in scope has that, the expression in its minimal rendering
|
|
3002
|
+
* (so that a supplement written without spaces or terms matches an expression written with them).
|
|
3003
|
+
*
|
|
3004
|
+
* @param {SnomedExpressionContext} ctxt - a complex expression
|
|
3005
|
+
* @returns {string} the code to look for in the supplements
|
|
3006
|
+
*/
|
|
3007
|
+
#extensionCodeKey(ctxt) {
|
|
3008
|
+
const source = ctxt.getCode();
|
|
3009
|
+
if (source && this._extensionCodeDisplay(source)) {
|
|
3010
|
+
return source;
|
|
3011
|
+
}
|
|
3012
|
+
return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.Minimal);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
/**
|
|
3016
|
+
* @param {SnomedExpressionContext} ctxt - a complex expression
|
|
3017
|
+
* @returns {string|null} the display an extension-codes supplement in scope gives it, if one does
|
|
3018
|
+
*/
|
|
3019
|
+
#extensionCodeDisplay(ctxt) {
|
|
3020
|
+
return this._extensionCodeDisplay(this.#extensionCodeKey(ctxt));
|
|
3021
|
+
}
|
|
3022
|
+
|
|
2638
3023
|
getLanguageCode(langIndex) {
|
|
2639
3024
|
return SCT_LANGUAGE_CODES[langIndex] || 'en';
|
|
2640
3025
|
}
|
|
@@ -2756,15 +3141,50 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2756
3141
|
return SnomedExpressionContext.fromReference(key);
|
|
2757
3142
|
}
|
|
2758
3143
|
|
|
3144
|
+
// The FHIR value for a concrete value in $lookup. A number is typed by the attribute's MRCM
|
|
3145
|
+
// range where it has one - so #4000 for a dec() attribute is a decimal, as the concept model
|
|
3146
|
+
// defines it - and otherwise by how it is written (#4000 an integer, #2.5 a decimal).
|
|
3147
|
+
#concreteLookupValue(cv) {
|
|
3148
|
+
const c = cv.concrete;
|
|
3149
|
+
switch (c.kind) {
|
|
3150
|
+
case 'string':
|
|
3151
|
+
return { valueString: c.value };
|
|
3152
|
+
case 'boolean':
|
|
3153
|
+
return { valueBoolean: c.value };
|
|
3154
|
+
case 'number': {
|
|
3155
|
+
let type = c.decimal ? 'dec' : 'int';
|
|
3156
|
+
const services = this.sct.expressionServices;
|
|
3157
|
+
const constraint = services && services.mrcmAttributeRanges ? services.mrcmAttributeRanges().get(cv.relType) : null;
|
|
3158
|
+
const range = constraint ? services.parseConcreteRange(constraint) : null;
|
|
3159
|
+
if (range && (range.type === 'dec' || range.type === 'int')) {
|
|
3160
|
+
type = range.type;
|
|
3161
|
+
}
|
|
3162
|
+
return type === 'int' && Number.isInteger(c.value) ? { valueInteger: c.value } : { valueDecimal: c.value };
|
|
3163
|
+
}
|
|
3164
|
+
default:
|
|
3165
|
+
return null;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
/**
|
|
3170
|
+
* The concept's properties for $expand: built by extendLookup, so that an expansion reports
|
|
3171
|
+
* the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup)
|
|
3172
|
+
*/
|
|
3173
|
+
async properties(context, names) {
|
|
3174
|
+
return this._propertiesFromLookup(context, names);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
2759
3177
|
async extendLookup(context, props, params) {
|
|
2760
3178
|
const ctxt = await this.#ensureContext(context);
|
|
2761
3179
|
if (ctxt) {
|
|
2762
3180
|
if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
|
|
2763
|
-
// Only what was asked for, if anything was (see _hasProp): effectiveTime,
|
|
3181
|
+
// Only what was asked for, if anything was (see _hasProp): effectiveTime, moduleId,
|
|
2764
3182
|
// parent, child and the defining relationships are returned by default but are not
|
|
2765
3183
|
// important enough to return when the client named the properties it wants.
|
|
2766
3184
|
// The mappings (alternate identifiers such as the LOINC code) are returned by
|
|
2767
|
-
// default too, and when asked for by name.
|
|
3185
|
+
// default too, and when asked for by name. property=* asks for every property the
|
|
3186
|
+
// SNOMED CT on FHIR page defines (adding sufficientlyDefined and semanticTag) except
|
|
3187
|
+
// the normal forms, which are computed and have to be asked for by name.
|
|
2768
3188
|
if (this._hasProp(props, 'effectiveTime', true)) {
|
|
2769
3189
|
const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
|
|
2770
3190
|
// Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
|
|
@@ -2794,11 +3214,35 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2794
3214
|
}
|
|
2795
3215
|
}
|
|
2796
3216
|
|
|
2797
|
-
|
|
3217
|
+
// moduleId, as the SNOMED CT on FHIR page names it ("module" is still understood)
|
|
3218
|
+
if (this._hasProp(props, 'moduleId', true) || SnomedProvider.#askedFor(props, 'module')) {
|
|
2798
3219
|
const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
|
|
2799
3220
|
if (moduleId) {
|
|
2800
3221
|
const code = this.sct.getConceptId(moduleId);
|
|
2801
|
-
this._addCodeProperty(params, 'property', '
|
|
3222
|
+
this._addCodeProperty(params, 'property', 'moduleId', code, null, this.sct.getDisplayName(moduleId));
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
// returned for property=* or by name, not by default
|
|
3227
|
+
if (this._hasProp(props, 'sufficientlyDefined', false)) {
|
|
3228
|
+
params.push({
|
|
3229
|
+
name: 'property',
|
|
3230
|
+
part: [
|
|
3231
|
+
{name: 'code', valueCode: 'sufficientlyDefined'},
|
|
3232
|
+
{name: 'value', valueBoolean: !this.sct.isPrimitive(ctxt.getReference())}
|
|
3233
|
+
]
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
if (this._hasProp(props, 'semanticTag', false)) {
|
|
3237
|
+
const tag = this.#semanticTag(ctxt.getReference());
|
|
3238
|
+
if (tag) {
|
|
3239
|
+
params.push({
|
|
3240
|
+
name: 'property',
|
|
3241
|
+
part: [
|
|
3242
|
+
{name: 'code', valueCode: 'semanticTag'},
|
|
3243
|
+
{name: 'value', valueString: tag}
|
|
3244
|
+
]
|
|
3245
|
+
});
|
|
2802
3246
|
}
|
|
2803
3247
|
}
|
|
2804
3248
|
|
|
@@ -2819,8 +3263,28 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2819
3263
|
}
|
|
2820
3264
|
}
|
|
2821
3265
|
|
|
3266
|
+
// concrete values (strengths, counts, ...): not relationships to concepts, so held apart
|
|
3267
|
+
for (const cv of (this.sct.concreteValues && this.sct.concreteValues.get(ctxt.getReference())) || []) {
|
|
3268
|
+
const relType = this.sct.getConceptId(cv.relType);
|
|
3269
|
+
if (this._hasProp(props, relType, true)) {
|
|
3270
|
+
const value = this.#concreteLookupValue(cv);
|
|
3271
|
+
if (value) {
|
|
3272
|
+
params.push({
|
|
3273
|
+
name: 'property',
|
|
3274
|
+
part: [
|
|
3275
|
+
{name: 'code', valueCode: relType},
|
|
3276
|
+
{name: 'value', ...value},
|
|
3277
|
+
{name: 'code-display', valueString: this.sct.getDisplayName(cv.relType)}
|
|
3278
|
+
]
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
2822
3284
|
this.#addMappingProperties(ctxt, props, params);
|
|
2823
3285
|
}
|
|
3286
|
+
// the normal forms: only when asked for by name (not by default, not by property=*)
|
|
3287
|
+
this.#addNormalFormProperties(ctxt, props, params);
|
|
2824
3288
|
if (ctxt instanceof SnomedExpressionContext) {
|
|
2825
3289
|
// ignore concepts for now, but list refinements and refinement groups
|
|
2826
3290
|
for (const refinement of ctxt.expression.refinements) {
|
|
@@ -2835,6 +3299,63 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2835
3299
|
}
|
|
2836
3300
|
}
|
|
2837
3301
|
|
|
3302
|
+
// Whether props names this property explicitly (property=* does not count)
|
|
3303
|
+
static #askedFor(props, name) {
|
|
3304
|
+
const lower = name.toLowerCase();
|
|
3305
|
+
return !!props && props.some(p => p && p.toLowerCase() === lower);
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// The semantic tag: the bracketed suffix of the concept's active fully specified name
|
|
3309
|
+
#semanticTag(reference) {
|
|
3310
|
+
const K = this.sct._displayConstants();
|
|
3311
|
+
const concept = this.sct.concepts.getConcept(reference);
|
|
3312
|
+
const descIndices = concept.descriptions ? (this.sct.refs.getReferences(concept.descriptions) || []) : [];
|
|
3313
|
+
for (const descIndex of descIndices) {
|
|
3314
|
+
const d = this.sct.descriptions.getDescription(descIndex);
|
|
3315
|
+
if (d.active && d.kind === K.fsn) {
|
|
3316
|
+
const m = /\(([^()]+)\)\s*$/.exec(this.sct.strings.getEntry(d.iDesc));
|
|
3317
|
+
if (m) {
|
|
3318
|
+
return m[1];
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
return null;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// normalForm (with terms) and normalFormTerse (without), for a concept or an expression
|
|
3326
|
+
#addNormalFormProperties(ctxt, props, params) {
|
|
3327
|
+
const wantFull = SnomedProvider.#askedFor(props, 'normalForm');
|
|
3328
|
+
const wantTerse = SnomedProvider.#askedFor(props, 'normalFormTerse');
|
|
3329
|
+
if (!wantFull && !wantTerse) {
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
const services = this.sct.expressionServices;
|
|
3333
|
+
let nf;
|
|
3334
|
+
if (ctxt instanceof SnomedExpressionContext && ctxt.isComplex()) {
|
|
3335
|
+
nf = services.normaliseExpression(ctxt.expression);
|
|
3336
|
+
} else {
|
|
3337
|
+
nf = services.createNormalForm(ctxt.getReference());
|
|
3338
|
+
}
|
|
3339
|
+
if (wantFull) {
|
|
3340
|
+
params.push({
|
|
3341
|
+
name: 'property',
|
|
3342
|
+
part: [
|
|
3343
|
+
{name: 'code', valueCode: 'normalForm'},
|
|
3344
|
+
{name: 'value', valueString: services.renderExpression(nf, SnomedServicesRenderOption.FillMissing)}
|
|
3345
|
+
]
|
|
3346
|
+
});
|
|
3347
|
+
}
|
|
3348
|
+
if (wantTerse) {
|
|
3349
|
+
params.push({
|
|
3350
|
+
name: 'property',
|
|
3351
|
+
part: [
|
|
3352
|
+
{name: 'code', valueCode: 'normalFormTerse'},
|
|
3353
|
+
{name: 'value', valueString: services.renderExpression(nf, SnomedServicesRenderOption.Minimal)}
|
|
3354
|
+
]
|
|
3355
|
+
});
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
|
|
2838
3359
|
/**
|
|
2839
3360
|
* The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
|
|
2840
3361
|
* Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
|
|
@@ -2878,15 +3399,16 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2878
3399
|
|
|
2879
3400
|
// Filter support
|
|
2880
3401
|
async doesFilter(prop, op, value) {
|
|
3402
|
+
prop = SnomedProvider.#filterProperty(prop);
|
|
2881
3403
|
if (prop === 'concept') {
|
|
2882
3404
|
const id = this.sct.stringToIdOrZero(value);
|
|
2883
|
-
if (id !== 0n && ['=', 'is-a', 'descendent-of', 'in', 'generalizes', 'child-of'].includes(op)) {
|
|
3405
|
+
if (id !== 0n && ['=', 'is-a', 'descendent-of', 'in', 'generalizes', 'child-of', 'descendent-leaf', 'is-not-a'].includes(op)) {
|
|
2884
3406
|
return this.sct.conceptExists(value);
|
|
2885
3407
|
}
|
|
2886
3408
|
if (op === 'in' && value.includes(',')) {
|
|
2887
3409
|
let ok = true;
|
|
2888
3410
|
for (const idStr of value.split(',')) {
|
|
2889
|
-
const id = this.sct.stringToIdOrZero(idStr);
|
|
3411
|
+
const id = this.sct.stringToIdOrZero(idStr.trim());
|
|
2890
3412
|
if (id === 0n) {
|
|
2891
3413
|
ok = false;
|
|
2892
3414
|
break;
|
|
@@ -2899,6 +3421,15 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2899
3421
|
return op === '=' && ['true', 'false'].includes(value);
|
|
2900
3422
|
}
|
|
2901
3423
|
|
|
3424
|
+
if (prop === 'display' && op === 'regex') {
|
|
3425
|
+
try {
|
|
3426
|
+
SnomedProvider.#displayRegex(value);
|
|
3427
|
+
return true;
|
|
3428
|
+
} catch (e) {
|
|
3429
|
+
return false;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
2902
3433
|
if (prop === 'moduleId') {
|
|
2903
3434
|
const id = this.sct.stringToIdOrZero(value);
|
|
2904
3435
|
return id !== 0n && op === '=';
|
|
@@ -2926,9 +3457,8 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2926
3457
|
}
|
|
2927
3458
|
|
|
2928
3459
|
// eslint-disable-next-line no-unused-vars
|
|
2929
|
-
async getPrepContext(iterate) {
|
|
2930
|
-
|
|
2931
|
-
return new SnomedPrep(); // Simple filter context
|
|
3460
|
+
async getPrepContext(iterate, languages = null) {
|
|
3461
|
+
return new SnomedPrep(languages);
|
|
2932
3462
|
}
|
|
2933
3463
|
|
|
2934
3464
|
// Cache key pinning resolved analysis to the SNOMED edition url + version.
|
|
@@ -2980,7 +3510,84 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2980
3510
|
});
|
|
2981
3511
|
}
|
|
2982
3512
|
|
|
3513
|
+
// Value sets in production say "code" where SNOMED defines "concept" (users don't tell
|
|
3514
|
+
// them apart), so the two are the same filter property here, as they are for code
|
|
3515
|
+
// systems defined as resources (cs-cs.js).
|
|
3516
|
+
static #filterProperty(prop) {
|
|
3517
|
+
return prop === 'code' ? 'concept' : prop;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
// display regex: matches a concept with an active description (FSN or synonym - a text
|
|
3521
|
+
// definition is not a display), in one language, whose whole term matches the pattern
|
|
3522
|
+
// (anchored, as for regex filters on code systems defined as resources), case-sensitively. RE2, so no catastrophic backtracking. The language is
|
|
3523
|
+
// the first one the request named that the edition has descriptions in, else the
|
|
3524
|
+
// edition's default.
|
|
3525
|
+
static #displayRegex(pattern) {
|
|
3526
|
+
return regexUtilities.compile('^(?:' + pattern + ')$');
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
#filterLanguage(languages) {
|
|
3530
|
+
if (!this.sct._activeDescLanguages) {
|
|
3531
|
+
this.sct._activeDescLanguages = this.sct.descriptions.activeLanguages();
|
|
3532
|
+
}
|
|
3533
|
+
const present = this.sct._activeDescLanguages;
|
|
3534
|
+
for (const lang of languages || []) {
|
|
3535
|
+
const code = SCT_LANGUAGE_BYTES[(lang.language || '').toLowerCase()];
|
|
3536
|
+
if (code !== undefined && present.has(code)) {
|
|
3537
|
+
return code;
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
return this.sct.defaultLanguage || 1;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
// the concept index of 900000000000550004 |Definition|, or -1
|
|
3544
|
+
#definitionKind() {
|
|
3545
|
+
if (this.sct._definitionKind === undefined) {
|
|
3546
|
+
const r = this.sct.concepts.findConcept(900000000000550004n);
|
|
3547
|
+
this.sct._definitionKind = r.found ? r.index : -1;
|
|
3548
|
+
}
|
|
3549
|
+
return this.sct._definitionKind;
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
#displayMatches(set, reference) {
|
|
3553
|
+
const definition = this.#definitionKind();
|
|
3554
|
+
const concept = this.sct.concepts.getConcept(reference);
|
|
3555
|
+
const descIndices = concept.descriptions ? (this.sct.refs.getReferences(concept.descriptions) || []) : [];
|
|
3556
|
+
for (const descIndex of descIndices) {
|
|
3557
|
+
const d = this.sct.descriptions.getDescription(descIndex);
|
|
3558
|
+
if (d.active && d.lang === set.displayLang && d.kind !== definition && set.displayRegex.test(this.sct.strings.getEntry(d.iDesc))) {
|
|
3559
|
+
return true;
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
return false;
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
// The scan for an iterated display regex: every active description in the language,
|
|
3566
|
+
// a chunk at a time, yielding between chunks so the server keeps serving meanwhile.
|
|
3567
|
+
async #populateDisplayRegex(set) {
|
|
3568
|
+
if (set.populationDone) {
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
const CHUNK = 10000;
|
|
3572
|
+
const found = new Set();
|
|
3573
|
+
const test = (term) => set.displayRegex.test(term);
|
|
3574
|
+
const definition = this.#definitionKind();
|
|
3575
|
+
let count = Infinity;
|
|
3576
|
+
for (let start = 0; start < count; start += CHUNK) {
|
|
3577
|
+
if (this.opContext) {
|
|
3578
|
+
await this.opContext.checkAndYield('snomed:displayRegex');
|
|
3579
|
+
}
|
|
3580
|
+
count = this.sct.descriptions.matchActiveTerms(start, start + CHUNK, set.displayLang, this.sct.strings, test, found, definition);
|
|
3581
|
+
}
|
|
3582
|
+
set.descendants = [...found].sort((a, b) => a - b);
|
|
3583
|
+
set.populationDone = true;
|
|
3584
|
+
if (set.displayCache) {
|
|
3585
|
+
set.displayCache.map.set(set.displayCache.key, set.descendants);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
|
|
2983
3589
|
async filter(filterContext, forIteration, prop, op, value, fc) {
|
|
3590
|
+
prop = SnomedProvider.#filterProperty(prop);
|
|
2984
3591
|
|
|
2985
3592
|
if (prop === 'concept') {
|
|
2986
3593
|
const id = this.sct.stringToIdOrZero(value);
|
|
@@ -3009,6 +3616,14 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3009
3616
|
filterContext.filters.push(this.sct.filterGeneralizes(id, false));
|
|
3010
3617
|
return null;
|
|
3011
3618
|
}
|
|
3619
|
+
case 'descendent-leaf': {
|
|
3620
|
+
filterContext.filters.push(this.sct.filterDescendentLeaf(id));
|
|
3621
|
+
return null;
|
|
3622
|
+
}
|
|
3623
|
+
case 'is-not-a': {
|
|
3624
|
+
filterContext.filters.push(this.sct.filterIsNotA(id, this.opContext));
|
|
3625
|
+
return null;
|
|
3626
|
+
}
|
|
3012
3627
|
case 'in': {
|
|
3013
3628
|
filterContext.filters.push(this.sct.filterIn(value));
|
|
3014
3629
|
return null;
|
|
@@ -3018,6 +3633,31 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3018
3633
|
}
|
|
3019
3634
|
}
|
|
3020
3635
|
|
|
3636
|
+
if (prop === 'display' && op === 'regex') {
|
|
3637
|
+
const result = new SnomedFilterContext();
|
|
3638
|
+
result.displayRegex = SnomedProvider.#displayRegex(value);
|
|
3639
|
+
result.displayLang = this.#filterLanguage(filterContext.languages);
|
|
3640
|
+
// Like ECL, a cached value set's filter keeps what it resolved to: here, per edition
|
|
3641
|
+
// and language, the concepts the scan found, so the scan is paid once.
|
|
3642
|
+
if (fc) {
|
|
3643
|
+
let map = fc[SNOMED_DISPLAY_REGEX];
|
|
3644
|
+
if (!map) {
|
|
3645
|
+
map = new Map();
|
|
3646
|
+
Object.defineProperty(fc, SNOMED_DISPLAY_REGEX, { value: map, enumerable: false, configurable: true, writable: true });
|
|
3647
|
+
}
|
|
3648
|
+
const key = this._snomedFilterCacheKey() + '|' + result.displayLang;
|
|
3649
|
+
const cached = map.get(key);
|
|
3650
|
+
if (cached) {
|
|
3651
|
+
result.descendants = cached;
|
|
3652
|
+
result.populationDone = true;
|
|
3653
|
+
} else {
|
|
3654
|
+
result.displayCache = { map, key };
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
filterContext.filters.push(result);
|
|
3658
|
+
return null;
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3021
3661
|
if (prop === 'inactive') {
|
|
3022
3662
|
if (value !== 'true' && value !== 'false') {
|
|
3023
3663
|
throw new Error(`Invalid filter value: ${value}`);
|
|
@@ -3130,6 +3770,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3130
3770
|
}
|
|
3131
3771
|
|
|
3132
3772
|
async filterSize(filterContext, set) {
|
|
3773
|
+
if (set.displayRegex) {
|
|
3774
|
+
await this.#populateDisplayRegex(set);
|
|
3775
|
+
}
|
|
3133
3776
|
// A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
|
|
3134
3777
|
// materialised descendant list - so count the active concepts it denotes.
|
|
3135
3778
|
if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
|
|
@@ -3148,6 +3791,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3148
3791
|
|
|
3149
3792
|
async filterMore(filterContext, set) {
|
|
3150
3793
|
set.cursor = set.cursor || 0;
|
|
3794
|
+
if (set.displayRegex) {
|
|
3795
|
+
await this.#populateDisplayRegex(set);
|
|
3796
|
+
}
|
|
3151
3797
|
this.#ensurePopulated(set);
|
|
3152
3798
|
const size = await this.filterSize(filterContext, set);
|
|
3153
3799
|
return set.cursor < size;
|
|
@@ -3206,6 +3852,8 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3206
3852
|
let concept = this.sct.concepts.getConcept(reference);
|
|
3207
3853
|
let moduleId = this.sct.concepts.getModuleId(concept.index);
|
|
3208
3854
|
found = moduleId === set.moduleId;
|
|
3855
|
+
} else if (set.displayRegex) {
|
|
3856
|
+
found = this.#displayMatches(set, reference);
|
|
3209
3857
|
} else if (set.propProp || set.propValue) {
|
|
3210
3858
|
found = false;
|
|
3211
3859
|
const relationships = this.sct.getConceptRelationships(reference);
|
|
@@ -3255,6 +3903,10 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3255
3903
|
return this.sct.concepts.getModuleId(reference) === set.moduleId;
|
|
3256
3904
|
}
|
|
3257
3905
|
|
|
3906
|
+
if (set.displayRegex) {
|
|
3907
|
+
return this.#displayMatches(set, reference);
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3258
3910
|
if (set.propProp || set.propValue) {
|
|
3259
3911
|
const relationships = this.sct.getConceptRelationships(reference);
|
|
3260
3912
|
for (let relationshipRef of relationships) {
|
|
@@ -3375,8 +4027,23 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3375
4027
|
// Search filter
|
|
3376
4028
|
// eslint-disable-next-line no-unused-vars
|
|
3377
4029
|
async searchFilter(filterContext, filter, sort) {
|
|
3378
|
-
// `sort` is not a match-mode flag: results are always ranked.
|
|
3379
|
-
|
|
4030
|
+
// `sort` is not a match-mode flag: results are always ranked. Only descriptions in one
|
|
4031
|
+
// language take part, chosen as for the display filter: the first language the request
|
|
4032
|
+
// names that the edition has descriptions in, else the edition's default; and the codes
|
|
4033
|
+
// whose designations in that language, in a supplement in use, match
|
|
4034
|
+
const lang = this.#filterLanguage(filterContext.languages);
|
|
4035
|
+
let f = await this.sct.searchFilter(filter, false, this.opContext, lang);
|
|
4036
|
+
if (this.supplements && this.supplements.length > 0) {
|
|
4037
|
+
const have = new Set(f.matches.map(m => m.index));
|
|
4038
|
+
for (const code of this._supplementSearchCodes(filter, new Language(SCT_LANGUAGE_CODES[lang] || 'en'))) {
|
|
4039
|
+
const id = this.sct.stringToIdOrZero(code);
|
|
4040
|
+
const r = id !== 0n ? this.sct.concepts.findConcept(id) : null;
|
|
4041
|
+
if (r && r.found && !have.has(r.index) && this.sct.isActive(r.index)) {
|
|
4042
|
+
f.matches.push({ index: r.index, term: this.sct.concepts.getConceptId(r.index), priority: 10 });
|
|
4043
|
+
have.add(r.index);
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
3380
4047
|
filterContext.filters.push(f);
|
|
3381
4048
|
return f;
|
|
3382
4049
|
}
|
|
@@ -3741,9 +4408,12 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3741
4408
|
* Factory for creating SNOMED services and providers
|
|
3742
4409
|
*/
|
|
3743
4410
|
class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
3744
|
-
constructor(i18n, filePath) {
|
|
4411
|
+
constructor(i18n, filePath, loadMode = 'read', log = null) {
|
|
3745
4412
|
super(i18n);
|
|
3746
4413
|
this.filePath = filePath;
|
|
4414
|
+
this.loadMode = loadMode; // 'read', 'mmap' or 'prewarm' - see SnomedFileReader
|
|
4415
|
+
this.log = log;
|
|
4416
|
+
this.mapped = false; // set by load(): whether the cache file really is memory-mapped
|
|
3747
4417
|
this.uses = 0;
|
|
3748
4418
|
this._loaded = false;
|
|
3749
4419
|
this._sharedData = null;
|
|
@@ -3757,6 +4427,26 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3757
4427
|
return this._sharedData.versionUri;
|
|
3758
4428
|
}
|
|
3759
4429
|
|
|
4430
|
+
/**
|
|
4431
|
+
* A concept links to snomed.info, which redirects to a browser: in its edition when that is
|
|
4432
|
+
* one snomed.info knows, otherwise without an edition. An expression links to the browser's
|
|
4433
|
+
* home page. The same links as SnomedUtilities.getSctLink in org.hl7.fhir.core.
|
|
4434
|
+
*/
|
|
4435
|
+
codeLink(context) {
|
|
4436
|
+
if (!context || !context.expression) {
|
|
4437
|
+
return undefined;
|
|
4438
|
+
}
|
|
4439
|
+
const code = context.isSimple() ? (context.source || context.expression.concepts[0].code) : null;
|
|
4440
|
+
if (!code || !/^\d+$/.test(code)) {
|
|
4441
|
+
return 'https://browser.ihtsdotools.org/';
|
|
4442
|
+
}
|
|
4443
|
+
const m = /^http:\/\/snomed\.info\/sct\/(\d+)/.exec(this.version() || '');
|
|
4444
|
+
if (m && SNOMED_LINKABLE_EDITIONS.has(m[1])) {
|
|
4445
|
+
return `http://snomed.info/sct/${m[1]}/id/${code}`;
|
|
4446
|
+
}
|
|
4447
|
+
return `http://snomed.info/id/${code}`;
|
|
4448
|
+
}
|
|
4449
|
+
|
|
3760
4450
|
getPartialVersion() {
|
|
3761
4451
|
let ver = this.version();
|
|
3762
4452
|
if (ver.includes("/version")) {
|
|
@@ -3775,39 +4465,86 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3775
4465
|
* http://snomed.info/sct?fhir_vs=refset – list of reference sets
|
|
3776
4466
|
* http://snomed.info/sct?fhir_vs=refset/<id> – members of a reference set
|
|
3777
4467
|
* http://snomed.info/sct?fhir_vs=isa/<id> – concept and descendants
|
|
4468
|
+
* http://snomed.info/sct?fhir_vs=ecl/<ecl> – the concepts an ECL expression selects
|
|
3778
4469
|
*
|
|
3779
4470
|
* The URL may optionally include edition and/or version segments:
|
|
3780
4471
|
* http://snomed.info/sct/<edition>?fhir_vs...
|
|
3781
4472
|
* http://snomed.info/sct/<edition>/version/<ver>?fhir_vs...
|
|
3782
4473
|
*
|
|
4474
|
+
* Which edition and version the value set is drawn from is the provider's choice, made
|
|
4475
|
+
* with implicitValueSetForm(): the generic form from the default edition, the edition
|
|
4476
|
+
* form from the latest version of that edition, the version form from that version.
|
|
4477
|
+
* For the edition and version forms, the value set's include names this version, so that
|
|
4478
|
+
* it is expanded against it; the generic form leaves the version to the request.
|
|
4479
|
+
*
|
|
3783
4480
|
* @param {string} url - The ValueSet URL to resolve
|
|
3784
4481
|
* @returns {object|null} A FHIR ValueSet JSON object, or null if the URL is not recognised
|
|
3785
4482
|
*/
|
|
3786
4483
|
async buildKnownValueSet(url, version) {
|
|
3787
|
-
if (!url.startsWith(
|
|
4484
|
+
if (!url.startsWith('http://snomed.info/')) {
|
|
3788
4485
|
return null;
|
|
3789
4486
|
}
|
|
4487
|
+
await this.#ensureLoaded();
|
|
3790
4488
|
if (version != null && !this.version().startsWith(version)) {
|
|
3791
4489
|
return null;
|
|
3792
4490
|
}
|
|
4491
|
+
const form = this.implicitValueSetForm(url);
|
|
4492
|
+
if (!form) {
|
|
4493
|
+
return null;
|
|
4494
|
+
}
|
|
4495
|
+
const id = form.query;
|
|
4496
|
+
const vs = await this.#buildImplicitValueSet(url, id);
|
|
4497
|
+
if (vs && form.form !== 'generic') {
|
|
4498
|
+
for (const include of vs.compose.include) {
|
|
4499
|
+
include.version = this.version();
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
return vs;
|
|
4503
|
+
}
|
|
3793
4504
|
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
4505
|
+
/**
|
|
4506
|
+
* Whether url is an implicit value set of this edition, and in which form:
|
|
4507
|
+
* generic - http://snomed.info/sct?fhir_vs... (any edition)
|
|
4508
|
+
* edition - http://snomed.info/sct/<edition>?fhir_vs... (this edition, no version given)
|
|
4509
|
+
* version - http://snomed.info/sct/<edition>/version/<date>?fhir_vs... (this version)
|
|
4510
|
+
* The edition and version forms are also recognised under this version's own base
|
|
4511
|
+
* (http://snomed.info/xsct/... for the test edition).
|
|
4512
|
+
*
|
|
4513
|
+
* @param {string} url
|
|
4514
|
+
* @returns {{form: string, query: string}|null}
|
|
4515
|
+
*/
|
|
4516
|
+
implicitValueSetForm(url) {
|
|
4517
|
+
if (!url || !this._sharedData) {
|
|
4518
|
+
return null;
|
|
4519
|
+
}
|
|
4520
|
+
const qIdx = url.indexOf('?fhir_vs');
|
|
3799
4521
|
if (qIdx === -1) {
|
|
3800
4522
|
return null;
|
|
3801
4523
|
}
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
|
|
3806
|
-
id = url.substring(qIdx);
|
|
3807
|
-
} else {
|
|
4524
|
+
const base = url.substring(0, qIdx);
|
|
4525
|
+
const query = url.substring(qIdx);
|
|
4526
|
+
if (query !== '?fhir_vs' && !query.startsWith('?fhir_vs=')) {
|
|
3808
4527
|
return null;
|
|
3809
4528
|
}
|
|
4529
|
+
const versionUri = this.version();
|
|
4530
|
+
const edition = this._sharedData.edition;
|
|
4531
|
+
const date = this._sharedData.version;
|
|
4532
|
+
const editionBases = new Set([`http://snomed.info/sct/${edition}`, this.getPartialVersion()]);
|
|
4533
|
+
const versionBases = new Set([versionUri, `http://snomed.info/sct/${edition}/version/${date}`]);
|
|
4534
|
+
if (base === 'http://snomed.info/sct') {
|
|
4535
|
+
return { form: 'generic', query };
|
|
4536
|
+
}
|
|
4537
|
+
if (editionBases.has(base)) {
|
|
4538
|
+
return { form: 'edition', query };
|
|
4539
|
+
}
|
|
4540
|
+
if (versionBases.has(base)) {
|
|
4541
|
+
return { form: 'version', query };
|
|
4542
|
+
}
|
|
4543
|
+
return null;
|
|
4544
|
+
}
|
|
3810
4545
|
|
|
4546
|
+
async #buildImplicitValueSet(url, id) {
|
|
4547
|
+
const URI_SNOMED = 'http://snomed.info/sct';
|
|
3811
4548
|
const now = new Date().toISOString();
|
|
3812
4549
|
|
|
3813
4550
|
if (id === '?fhir_vs=refset') {
|
|
@@ -3821,7 +4558,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3821
4558
|
resourceType: 'ValueSet',
|
|
3822
4559
|
url,
|
|
3823
4560
|
status: 'active',
|
|
3824
|
-
version: this.versionDate,
|
|
3825
4561
|
name: 'SNOMEDCTReferenceSetList',
|
|
3826
4562
|
title: 'SNOMED CT Reference Set List',
|
|
3827
4563
|
description: 'Reference Sets defined in this SNOMED-CT version',
|
|
@@ -3841,7 +4577,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3841
4577
|
resourceType: 'ValueSet',
|
|
3842
4578
|
url,
|
|
3843
4579
|
status: 'active',
|
|
3844
|
-
version: this.versionDate,
|
|
3845
4580
|
name: 'ALLSNOMEDCT',
|
|
3846
4581
|
title: 'SNOMED CT Reference Set (All of SNOMED CT)',
|
|
3847
4582
|
description: 'SNOMED CT Reference Set (All of SNOMED CT)',
|
|
@@ -3868,7 +4603,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3868
4603
|
resourceType: 'ValueSet',
|
|
3869
4604
|
url,
|
|
3870
4605
|
status: 'active',
|
|
3871
|
-
version: this.versionDate,
|
|
3872
4606
|
name: 'SNOMEDCTRefSet' + refsetId,
|
|
3873
4607
|
title: 'SNOMED CT Reference Set ' + refsetId,
|
|
3874
4608
|
description: this.snomedServices.getDisplayName(ref.index),
|
|
@@ -3886,6 +4620,38 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3886
4620
|
};
|
|
3887
4621
|
}
|
|
3888
4622
|
|
|
4623
|
+
if (id.startsWith('?fhir_vs=ecl/')) {
|
|
4624
|
+
// the ECL is URL-encoded in the value set URL; accept it already decoded as well
|
|
4625
|
+
let ecl = id.substring(13);
|
|
4626
|
+
try {
|
|
4627
|
+
ecl = decodeURIComponent(ecl);
|
|
4628
|
+
} catch (e) {
|
|
4629
|
+
// not encoded (or not validly): use it as it stands
|
|
4630
|
+
}
|
|
4631
|
+
if (!ecl.trim()) {
|
|
4632
|
+
return null;
|
|
4633
|
+
}
|
|
4634
|
+
return {
|
|
4635
|
+
resourceType: 'ValueSet',
|
|
4636
|
+
url,
|
|
4637
|
+
status: 'active',
|
|
4638
|
+
name: 'SNOMEDCTECL',
|
|
4639
|
+
title: 'SNOMED CT ECL ' + ecl,
|
|
4640
|
+
description: 'The SNOMED CT concepts selected by the expression constraint ' + ecl,
|
|
4641
|
+
date: now,
|
|
4642
|
+
compose: {
|
|
4643
|
+
include: [{
|
|
4644
|
+
system: URI_SNOMED,
|
|
4645
|
+
filter: [{
|
|
4646
|
+
property: 'constraint',
|
|
4647
|
+
op: '=',
|
|
4648
|
+
value: ecl,
|
|
4649
|
+
}],
|
|
4650
|
+
}],
|
|
4651
|
+
},
|
|
4652
|
+
};
|
|
4653
|
+
}
|
|
4654
|
+
|
|
3889
4655
|
if (id.startsWith('?fhir_vs=isa/')) {
|
|
3890
4656
|
const conceptId = id.substring(13);
|
|
3891
4657
|
let ref = this.snomedServices.concepts.findConcept(conceptId);
|
|
@@ -3896,7 +4662,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3896
4662
|
resourceType: 'ValueSet',
|
|
3897
4663
|
url,
|
|
3898
4664
|
status: 'active',
|
|
3899
|
-
version: this.versionDate,
|
|
3900
4665
|
name: 'SNOMEDCTConcept' + conceptId,
|
|
3901
4666
|
title: 'SNOMED CT Concept ' + conceptId + ' and descendants',
|
|
3902
4667
|
description: 'All Snomed CT concepts for ' + this.snomedServices.getDisplayName(ref.index),
|
|
@@ -3923,8 +4688,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3923
4688
|
}
|
|
3924
4689
|
|
|
3925
4690
|
async load() {
|
|
3926
|
-
const reader = new SnomedFileReader(this.filePath);
|
|
4691
|
+
const reader = new SnomedFileReader(this.filePath, this.loadMode, this.log);
|
|
3927
4692
|
this._sharedData = await reader.loadSnomedData();
|
|
4693
|
+
this.mapped = reader.mapped;
|
|
3928
4694
|
this.snomedServices = new SnomedServices(this._sharedData);
|
|
3929
4695
|
this._loaded = true;
|
|
3930
4696
|
}
|