fhirsmith 0.18.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
@@ -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:
@@ -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
@@ -659,6 +705,9 @@ class SnomedServices {
659
705
  }
660
706
  }
661
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.
662
711
  getConceptRelationships(reference) {
663
712
  try {
664
713
  const concept = this.concepts.getConcept(reference);
@@ -666,7 +715,7 @@ class SnomedServices {
666
715
 
667
716
  if (relRef === 0) return [];
668
717
 
669
- return this.refs.getReferences(relRef) || [];
718
+ return (this.refs.getReferences(relRef) || []).filter(relIdx => this.relationships.isActive(relIdx));
670
719
  } catch (error) {
671
720
  return [];
672
721
  }
@@ -839,6 +888,35 @@ class SnomedServices {
839
888
  return result;
840
889
  }
841
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
+
842
920
  filterChildOf(id = true) {
843
921
  const result = new SnomedFilterContext();
844
922
  const conceptResult = this.concepts.findConcept(id);
@@ -886,10 +964,25 @@ class SnomedServices {
886
964
  }
887
965
 
888
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.
889
971
  filterIn(idList) {
890
972
  const result = new SnomedFilterContext();
891
- let members = [];
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
+ };
892
981
  for (let id of idList.split(',')) {
982
+ id = id.trim();
983
+ if (!id) {
984
+ continue;
985
+ }
893
986
  const conceptResult = this.concepts.findConcept(id);
894
987
 
895
988
  if (!conceptResult.found) {
@@ -898,9 +991,11 @@ class SnomedServices {
898
991
 
899
992
  const refSetIndex = this.getConceptRefSet(conceptResult.index, false);
900
993
  if (refSetIndex === 0) {
901
- members.push(conceptResult.index);
994
+ add({ ref: conceptResult.index });
902
995
  } else {
903
- members.push(...this.refSetMembers.getMembers(refSetIndex));
996
+ for (const member of this.refSetMembers.getMembers(refSetIndex) || []) {
997
+ add(member);
998
+ }
904
999
  }
905
1000
  }
906
1001
  result.members = members;
@@ -1700,8 +1795,7 @@ class SnomedServices {
1700
1795
  */
1701
1796
  _eclTermMatcher = function (v) {
1702
1797
  if (v.kind === 'wild') {
1703
- const re = new RegExp('^' + v.value.split('*').map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
1704
- return (term) => re.test(term);
1798
+ return wildTermMatcher(v.value);
1705
1799
  }
1706
1800
  const words = v.value.toLowerCase().split(/\s+/).filter(w => w.length > 0);
1707
1801
  return (term) => {
@@ -2439,16 +2533,20 @@ class SnomedServices {
2439
2533
  * matches for a typical multi-word filter, with the event loop blocked for
2440
2534
  * the whole scan (tx.fhir.org incident 2026-09-17).
2441
2535
  *
2536
+ * Only descriptions in `lang` take part, when it is given (a text filter searches one
2537
+ * language: see SnomedProvider.searchFilter).
2538
+ *
2442
2539
  * @param {SearchFilterText} searchText
2443
2540
  * @param {boolean} includeInactive - include inactive concepts
2444
2541
  * @param {OperationContext|null} opContext - for yielding / deadline checks
2542
+ * @param {number|null} lang - the description language byte, or null for all
2445
2543
  * @returns {Promise<SnomedFilterContext>}
2446
2544
  */
2447
- async searchFilter(searchText, includeInactive = false, opContext = null) {
2545
+ async searchFilter(searchText, includeInactive = false, opContext = null, lang = null) {
2448
2546
  const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
2449
2547
  const tokens = filterTokens(filterText);
2450
2548
  if (tokens.length > 0) {
2451
- const index = await this.getTextIndex(opContext);
2549
+ const index = await this.getTextIndex(opContext, lang);
2452
2550
  if (index) {
2453
2551
  const result = new SnomedFilterContext();
2454
2552
  const ordinals = index.search(tokens, opContext);
@@ -2459,7 +2557,16 @@ class SnomedServices {
2459
2557
  return result;
2460
2558
  }
2461
2559
  }
2462
- 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);
2463
2570
  }
2464
2571
 
2465
2572
  /**
@@ -2470,28 +2577,53 @@ class SnomedServices {
2470
2577
  * @param {OperationContext|null} opContext
2471
2578
  * @returns {Promise<SnomedTextIndex|null>}
2472
2579
  */
2473
- async getTextIndex(opContext = null) {
2474
- if (this._textIndex) {
2475
- return this._textIndex;
2476
- }
2477
- if (!this._textIndexPromise) {
2478
- const started = Date.now();
2479
- // Logger fetched lazily: importers and tests require this module too
2480
- const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
2481
- this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
2482
- this._textIndex = index;
2483
- textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
2484
- return index;
2485
- }, error => {
2486
- this._textIndexPromise = null;
2487
- textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
2488
- return null;
2489
- });
2490
- }
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) {
2491
2623
  if (opContext && typeof opContext.waitFor === 'function') {
2492
- return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
2624
+ return await opContext.waitFor(promise, 'sct:textIndex');
2493
2625
  }
2494
- return await this._textIndexPromise;
2626
+ return await promise;
2495
2627
  }
2496
2628
 
2497
2629
  /**
@@ -2505,7 +2637,7 @@ class SnomedServices {
2505
2637
  * @param {OperationContext|null} opContext
2506
2638
  * @returns {Promise<SnomedFilterContext>}
2507
2639
  */
2508
- async scanSearch(filterText, includeInactive = false, opContext = null) {
2640
+ async scanSearch(filterText, includeInactive = false, opContext = null, lang = null) {
2509
2641
  const result = new SnomedFilterContext();
2510
2642
  const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
2511
2643
  const matches = [];
@@ -2537,7 +2669,7 @@ class SnomedServices {
2537
2669
 
2538
2670
  for (const descIndex of descriptionIndices) {
2539
2671
  const description = this.descriptions.getDescription(descIndex);
2540
- if (description.active) {
2672
+ if (description.active && (lang === null || description.lang === lang)) {
2541
2673
  const term = this.strings.getEntry(description.iDesc).toLowerCase();
2542
2674
  if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
2543
2675
  matchFound = true;
@@ -2691,6 +2823,10 @@ class SnomedProvider extends BaseCSServices {
2691
2823
  if (disp) return disp;
2692
2824
 
2693
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;
2694
2830
  return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing);
2695
2831
  } else {
2696
2832
  return this.sct.getDisplayName(ctxt.getReference(), this.sct.defaultLanguage);
@@ -2739,8 +2875,11 @@ class SnomedProvider extends BaseCSServices {
2739
2875
 
2740
2876
 
2741
2877
  if (ctxt.isComplex()) {
2742
- // For complex expressions, just add the display
2743
- const display = await this.display(context);
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);
2744
2883
  if (display) {
2745
2884
  displays.addDesignation(true, 'active', 'en-US', DesignationUse.PREFERRED, display);
2746
2885
  }
@@ -2857,6 +2996,30 @@ class SnomedProvider extends BaseCSServices {
2857
2996
  }
2858
2997
  }
2859
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
+
2860
3023
  getLanguageCode(langIndex) {
2861
3024
  return SCT_LANGUAGE_CODES[langIndex] || 'en';
2862
3025
  }
@@ -2978,15 +3141,50 @@ class SnomedProvider extends BaseCSServices {
2978
3141
  return SnomedExpressionContext.fromReference(key);
2979
3142
  }
2980
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
+
2981
3177
  async extendLookup(context, props, params) {
2982
3178
  const ctxt = await this.#ensureContext(context);
2983
3179
  if (ctxt) {
2984
3180
  if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
2985
- // Only what was asked for, if anything was (see _hasProp): effectiveTime, module,
3181
+ // Only what was asked for, if anything was (see _hasProp): effectiveTime, moduleId,
2986
3182
  // parent, child and the defining relationships are returned by default but are not
2987
3183
  // important enough to return when the client named the properties it wants.
2988
3184
  // The mappings (alternate identifiers such as the LOINC code) are returned by
2989
- // 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.
2990
3188
  if (this._hasProp(props, 'effectiveTime', true)) {
2991
3189
  const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2992
3190
  // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
@@ -3016,11 +3214,35 @@ class SnomedProvider extends BaseCSServices {
3016
3214
  }
3017
3215
  }
3018
3216
 
3019
- if (this._hasProp(props, 'module', true)) {
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')) {
3020
3219
  const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
3021
3220
  if (moduleId) {
3022
3221
  const code = this.sct.getConceptId(moduleId);
3023
- this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
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
+ });
3024
3246
  }
3025
3247
  }
3026
3248
 
@@ -3041,8 +3263,28 @@ class SnomedProvider extends BaseCSServices {
3041
3263
  }
3042
3264
  }
3043
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
+
3044
3284
  this.#addMappingProperties(ctxt, props, params);
3045
3285
  }
3286
+ // the normal forms: only when asked for by name (not by default, not by property=*)
3287
+ this.#addNormalFormProperties(ctxt, props, params);
3046
3288
  if (ctxt instanceof SnomedExpressionContext) {
3047
3289
  // ignore concepts for now, but list refinements and refinement groups
3048
3290
  for (const refinement of ctxt.expression.refinements) {
@@ -3057,6 +3299,63 @@ class SnomedProvider extends BaseCSServices {
3057
3299
  }
3058
3300
  }
3059
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
+
3060
3359
  /**
3061
3360
  * The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
3062
3361
  * Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
@@ -3100,15 +3399,16 @@ class SnomedProvider extends BaseCSServices {
3100
3399
 
3101
3400
  // Filter support
3102
3401
  async doesFilter(prop, op, value) {
3402
+ prop = SnomedProvider.#filterProperty(prop);
3103
3403
  if (prop === 'concept') {
3104
3404
  const id = this.sct.stringToIdOrZero(value);
3105
- 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)) {
3106
3406
  return this.sct.conceptExists(value);
3107
3407
  }
3108
3408
  if (op === 'in' && value.includes(',')) {
3109
3409
  let ok = true;
3110
3410
  for (const idStr of value.split(',')) {
3111
- const id = this.sct.stringToIdOrZero(idStr);
3411
+ const id = this.sct.stringToIdOrZero(idStr.trim());
3112
3412
  if (id === 0n) {
3113
3413
  ok = false;
3114
3414
  break;
@@ -3121,6 +3421,15 @@ class SnomedProvider extends BaseCSServices {
3121
3421
  return op === '=' && ['true', 'false'].includes(value);
3122
3422
  }
3123
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
+
3124
3433
  if (prop === 'moduleId') {
3125
3434
  const id = this.sct.stringToIdOrZero(value);
3126
3435
  return id !== 0n && op === '=';
@@ -3148,9 +3457,8 @@ class SnomedProvider extends BaseCSServices {
3148
3457
  }
3149
3458
 
3150
3459
  // eslint-disable-next-line no-unused-vars
3151
- async getPrepContext(iterate) {
3152
-
3153
- return new SnomedPrep(); // Simple filter context
3460
+ async getPrepContext(iterate, languages = null) {
3461
+ return new SnomedPrep(languages);
3154
3462
  }
3155
3463
 
3156
3464
  // Cache key pinning resolved analysis to the SNOMED edition url + version.
@@ -3202,7 +3510,84 @@ class SnomedProvider extends BaseCSServices {
3202
3510
  });
3203
3511
  }
3204
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
+
3205
3589
  async filter(filterContext, forIteration, prop, op, value, fc) {
3590
+ prop = SnomedProvider.#filterProperty(prop);
3206
3591
 
3207
3592
  if (prop === 'concept') {
3208
3593
  const id = this.sct.stringToIdOrZero(value);
@@ -3231,6 +3616,14 @@ class SnomedProvider extends BaseCSServices {
3231
3616
  filterContext.filters.push(this.sct.filterGeneralizes(id, false));
3232
3617
  return null;
3233
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
+ }
3234
3627
  case 'in': {
3235
3628
  filterContext.filters.push(this.sct.filterIn(value));
3236
3629
  return null;
@@ -3240,6 +3633,31 @@ class SnomedProvider extends BaseCSServices {
3240
3633
  }
3241
3634
  }
3242
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
+
3243
3661
  if (prop === 'inactive') {
3244
3662
  if (value !== 'true' && value !== 'false') {
3245
3663
  throw new Error(`Invalid filter value: ${value}`);
@@ -3352,6 +3770,9 @@ class SnomedProvider extends BaseCSServices {
3352
3770
  }
3353
3771
 
3354
3772
  async filterSize(filterContext, set) {
3773
+ if (set.displayRegex) {
3774
+ await this.#populateDisplayRegex(set);
3775
+ }
3355
3776
  // A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
3356
3777
  // materialised descendant list - so count the active concepts it denotes.
3357
3778
  if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
@@ -3370,6 +3791,9 @@ class SnomedProvider extends BaseCSServices {
3370
3791
 
3371
3792
  async filterMore(filterContext, set) {
3372
3793
  set.cursor = set.cursor || 0;
3794
+ if (set.displayRegex) {
3795
+ await this.#populateDisplayRegex(set);
3796
+ }
3373
3797
  this.#ensurePopulated(set);
3374
3798
  const size = await this.filterSize(filterContext, set);
3375
3799
  return set.cursor < size;
@@ -3428,6 +3852,8 @@ class SnomedProvider extends BaseCSServices {
3428
3852
  let concept = this.sct.concepts.getConcept(reference);
3429
3853
  let moduleId = this.sct.concepts.getModuleId(concept.index);
3430
3854
  found = moduleId === set.moduleId;
3855
+ } else if (set.displayRegex) {
3856
+ found = this.#displayMatches(set, reference);
3431
3857
  } else if (set.propProp || set.propValue) {
3432
3858
  found = false;
3433
3859
  const relationships = this.sct.getConceptRelationships(reference);
@@ -3477,6 +3903,10 @@ class SnomedProvider extends BaseCSServices {
3477
3903
  return this.sct.concepts.getModuleId(reference) === set.moduleId;
3478
3904
  }
3479
3905
 
3906
+ if (set.displayRegex) {
3907
+ return this.#displayMatches(set, reference);
3908
+ }
3909
+
3480
3910
  if (set.propProp || set.propValue) {
3481
3911
  const relationships = this.sct.getConceptRelationships(reference);
3482
3912
  for (let relationshipRef of relationships) {
@@ -3597,8 +4027,23 @@ class SnomedProvider extends BaseCSServices {
3597
4027
  // Search filter
3598
4028
  // eslint-disable-next-line no-unused-vars
3599
4029
  async searchFilter(filterContext, filter, sort) {
3600
- // `sort` is not a match-mode flag: results are always ranked.
3601
- let f = await this.sct.searchFilter(filter, false, this.opContext);
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
+ }
3602
4047
  filterContext.filters.push(f);
3603
4048
  return f;
3604
4049
  }
@@ -3963,9 +4408,12 @@ class SnomedProvider extends BaseCSServices {
3963
4408
  * Factory for creating SNOMED services and providers
3964
4409
  */
3965
4410
  class SnomedServicesFactory extends CodeSystemFactoryProvider {
3966
- constructor(i18n, filePath) {
4411
+ constructor(i18n, filePath, loadMode = 'read', log = null) {
3967
4412
  super(i18n);
3968
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
3969
4417
  this.uses = 0;
3970
4418
  this._loaded = false;
3971
4419
  this._sharedData = null;
@@ -3979,6 +4427,26 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3979
4427
  return this._sharedData.versionUri;
3980
4428
  }
3981
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
+
3982
4450
  getPartialVersion() {
3983
4451
  let ver = this.version();
3984
4452
  if (ver.includes("/version")) {
@@ -3997,39 +4465,86 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3997
4465
  * http://snomed.info/sct?fhir_vs=refset – list of reference sets
3998
4466
  * http://snomed.info/sct?fhir_vs=refset/<id> – members of a reference set
3999
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
4000
4469
  *
4001
4470
  * The URL may optionally include edition and/or version segments:
4002
4471
  * http://snomed.info/sct/<edition>?fhir_vs...
4003
4472
  * http://snomed.info/sct/<edition>/version/<ver>?fhir_vs...
4004
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
+ *
4005
4480
  * @param {string} url - The ValueSet URL to resolve
4006
4481
  * @returns {object|null} A FHIR ValueSet JSON object, or null if the URL is not recognised
4007
4482
  */
4008
4483
  async buildKnownValueSet(url, version) {
4009
- if (!url.startsWith("http://snomed.info/sct")) {
4484
+ if (!url.startsWith('http://snomed.info/')) {
4010
4485
  return null;
4011
4486
  }
4487
+ await this.#ensureLoaded();
4012
4488
  if (version != null && !this.version().startsWith(version)) {
4013
4489
  return null;
4014
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
+ }
4015
4504
 
4016
- const URI_SNOMED = 'http://snomed.info/sct';
4017
-
4018
- // Extract the query portion (?fhir_vs...) if this is a recognised SNOMED implicit VS URL
4019
- let id = null;
4020
- const qIdx = url.indexOf('?');
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');
4021
4521
  if (qIdx === -1) {
4022
4522
  return null;
4023
4523
  }
4024
-
4025
- if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
4026
- url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
4027
- url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
4028
- id = url.substring(qIdx);
4029
- } else {
4524
+ const base = url.substring(0, qIdx);
4525
+ const query = url.substring(qIdx);
4526
+ if (query !== '?fhir_vs' && !query.startsWith('?fhir_vs=')) {
4030
4527
  return null;
4031
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
+ }
4032
4545
 
4546
+ async #buildImplicitValueSet(url, id) {
4547
+ const URI_SNOMED = 'http://snomed.info/sct';
4033
4548
  const now = new Date().toISOString();
4034
4549
 
4035
4550
  if (id === '?fhir_vs=refset') {
@@ -4043,7 +4558,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
4043
4558
  resourceType: 'ValueSet',
4044
4559
  url,
4045
4560
  status: 'active',
4046
- version: this.versionDate,
4047
4561
  name: 'SNOMEDCTReferenceSetList',
4048
4562
  title: 'SNOMED CT Reference Set List',
4049
4563
  description: 'Reference Sets defined in this SNOMED-CT version',
@@ -4063,7 +4577,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
4063
4577
  resourceType: 'ValueSet',
4064
4578
  url,
4065
4579
  status: 'active',
4066
- version: this.versionDate,
4067
4580
  name: 'ALLSNOMEDCT',
4068
4581
  title: 'SNOMED CT Reference Set (All of SNOMED CT)',
4069
4582
  description: 'SNOMED CT Reference Set (All of SNOMED CT)',
@@ -4090,7 +4603,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
4090
4603
  resourceType: 'ValueSet',
4091
4604
  url,
4092
4605
  status: 'active',
4093
- version: this.versionDate,
4094
4606
  name: 'SNOMEDCTRefSet' + refsetId,
4095
4607
  title: 'SNOMED CT Reference Set ' + refsetId,
4096
4608
  description: this.snomedServices.getDisplayName(ref.index),
@@ -4108,6 +4620,38 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
4108
4620
  };
4109
4621
  }
4110
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
+
4111
4655
  if (id.startsWith('?fhir_vs=isa/')) {
4112
4656
  const conceptId = id.substring(13);
4113
4657
  let ref = this.snomedServices.concepts.findConcept(conceptId);
@@ -4118,7 +4662,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
4118
4662
  resourceType: 'ValueSet',
4119
4663
  url,
4120
4664
  status: 'active',
4121
- version: this.versionDate,
4122
4665
  name: 'SNOMEDCTConcept' + conceptId,
4123
4666
  title: 'SNOMED CT Concept ' + conceptId + ' and descendants',
4124
4667
  description: 'All Snomed CT concepts for ' + this.snomedServices.getDisplayName(ref.index),
@@ -4145,8 +4688,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
4145
4688
  }
4146
4689
 
4147
4690
  async load() {
4148
- const reader = new SnomedFileReader(this.filePath);
4691
+ const reader = new SnomedFileReader(this.filePath, this.loadMode, this.log);
4149
4692
  this._sharedData = await reader.loadSnomedData();
4693
+ this.mapped = reader.mapped;
4150
4694
  this.snomedServices = new SnomedServices(this._sharedData);
4151
4695
  this._loaded = true;
4152
4696
  }