fhirsmith 0.17.0 → 0.18.1

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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -31,6 +31,18 @@ const Logger = require("../../library/logger");
31
31
  // shared - it simply recomputes.
32
32
  const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
33
33
 
34
+ /**
35
+ * Alternate identifier schemes (sct2_Identifier) that FHIRsmith knows how to present:
36
+ * the code system the identifiers are codes in, and the $lookup property they are
37
+ * returned as. An identifier in an unknown scheme is kept but not reported.
38
+ */
39
+ const IDENTIFIER_SCHEMES = {
40
+ '30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
41
+ };
42
+
43
+ /** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
44
+ const MAPPED_FILTER_PREFIX = 'mapped:';
45
+
34
46
  // Description language index -> language code. The index is assigned at import
35
47
  // time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
36
48
  // be kept in step with it.
@@ -46,6 +58,24 @@ const SCT_LANGUAGE_CODES = {
46
58
  9: 'cs'
47
59
  };
48
60
 
61
+ // ECL dialect aliases (ECL specification, appendix "Dialect aliases") to the
62
+ // language reference set they denote. Used by {{ D dialect = en-gb }}.
63
+ const ECL_DIALECT_ALIASES = {
64
+ 'en-us': '900000000000509007',
65
+ 'en-gb': '900000000000508004',
66
+ 'en-au': '32570271000036106',
67
+ 'en-ca': '19491000087109',
68
+ 'en-nz': '271000210107',
69
+ 'en-ie': '21000220103',
70
+ 'fr-ca': '20581000087109',
71
+ 'fr-be': '21000172104',
72
+ 'nl-be': '31000172101',
73
+ 'nl-nl': '31000146106',
74
+ 'es-es': '448879004',
75
+ 'sv-se': '46011000052107',
76
+ 'da-dk': '554461000005103'
77
+ };
78
+
49
79
  // Context kinds matching Pascal enum
50
80
  const SnomedProviderContextKind = {
51
81
  CODE: 0,
@@ -174,6 +204,47 @@ class SnomedServices {
174
204
  // means "could not decide", and leaves the value unchecked.
175
205
  this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
176
206
  this._mrcmRangeCache = new Map();
207
+
208
+ this.#loadIdentifiers(sharedData.identifiers || []);
209
+ }
210
+
211
+ /**
212
+ * Index the alternate identifiers (sct2_Identifier rows, active only, as
213
+ * [scheme, module, code, conceptId]) both ways: concept -> identifiers, and
214
+ * scheme|code -> concepts. Also which modules carry identifiers, since each such
215
+ * module defines an implicit map (?fhir_cm=<module>).
216
+ */
217
+ #loadIdentifiers(rows) {
218
+ this.identifiersByConcept = new Map();
219
+ this.conceptsByIdentifier = new Map();
220
+ this.identifierModules = new Map(); // module -> Set of schemes
221
+ for (const [scheme, module, code, conceptId] of rows) {
222
+ const entry = { scheme, module, code };
223
+ if (!this.identifiersByConcept.has(conceptId)) {
224
+ this.identifiersByConcept.set(conceptId, []);
225
+ }
226
+ this.identifiersByConcept.get(conceptId).push(entry);
227
+ const key = scheme + '|' + code;
228
+ if (!this.conceptsByIdentifier.has(key)) {
229
+ this.conceptsByIdentifier.set(key, []);
230
+ }
231
+ this.conceptsByIdentifier.get(key).push(conceptId);
232
+ if (!this.identifierModules.has(module)) {
233
+ this.identifierModules.set(module, new Set());
234
+ }
235
+ this.identifierModules.get(module).add(scheme);
236
+ }
237
+ }
238
+
239
+ /** The alternate identifiers of a concept (by id), optionally only those of one scheme */
240
+ getIdentifiers(conceptId, scheme = null) {
241
+ const list = this.identifiersByConcept.get(String(conceptId)) || [];
242
+ return scheme ? list.filter(i => i.scheme === scheme) : list;
243
+ }
244
+
245
+ /** The concept ids that have the given code in the given scheme */
246
+ getConceptsForIdentifier(scheme, code) {
247
+ return this.conceptsByIdentifier.get(scheme + '|' + code) || [];
177
248
  }
178
249
 
179
250
  /**
@@ -568,6 +639,122 @@ class SnomedServices {
568
639
  }
569
640
  }
570
641
 
642
+ /**
643
+ * The module this edition is published under, as a concept index (0 if it cannot be found),
644
+ * taken from the edition URI http://snomed.info/[x]sct/<module>[/version/<date>]
645
+ */
646
+ editionModule() {
647
+ const m = /^http:\/\/snomed\.info\/x?sct\/(\d+)/.exec(this.getVersion() || '');
648
+ if (!m) return 0;
649
+ const r = this.concepts.findConcept(m[1]);
650
+ return r.found ? r.index : 0;
651
+ }
652
+
653
+ /**
654
+ * The module dependency reference set (900000000000534007). Found by its defining concept
655
+ * when that is present; a subontology extract may carry the members without the concept,
656
+ * so the filename-derived refset name is the fallback.
657
+ * @returns {object|null} the reference set record
658
+ */
659
+ moduleDependencyRefSet() {
660
+ if (this._mdrs !== undefined) return this._mdrs;
661
+ this._mdrs = null;
662
+ const r = this.concepts.findConcept('900000000000534007');
663
+ for (let i = 0; i < this.refSetIndex.count(); i++) {
664
+ const rs = this.refSetIndex.getReferenceSet(i);
665
+ if ((r.found && rs.definition === r.index) || this.strings.getEntry(rs.name).startsWith('ModuleDependency')) {
666
+ this._mdrs = rs;
667
+ break;
668
+ }
669
+ }
670
+ return this._mdrs;
671
+ }
672
+
673
+ /**
674
+ * The modules that the given module depends on, per the module dependency reference set:
675
+ * [{module: <concept index>, sourceEffectiveTime, targetEffectiveTime}]. Only the direct
676
+ * dependencies - the dependencies of those modules are their own rows.
677
+ */
678
+ moduleDependencies(moduleIndex) {
679
+ const rs = this.moduleDependencyRefSet();
680
+ if (!rs || !moduleIndex) return [];
681
+ const result = [];
682
+ for (const m of this.refSetMembers.getMembers(rs.membersByRef)) {
683
+ if (m.module !== moduleIndex || !m.ref) continue;
684
+ // values are [value, type, value, type...]; both fields here are strings
685
+ const vals = m.values ? this.refs.getReferences(m.values) || [] : [];
686
+ const str = (i) => vals.length > i && vals[i + 1] === 5 ? this.strings.getEntry(vals[i]) : null;
687
+ result.push({ module: m.ref, sourceEffectiveTime: str(0), targetEffectiveTime: str(2) });
688
+ }
689
+ return result;
690
+ }
691
+
692
+ /**
693
+ * The concept indexes of all the modules that make up the edition published under the
694
+ * given module: the module itself and, transitively, everything it depends on.
695
+ */
696
+ editionModules(moduleIndex) {
697
+ const result = new Set();
698
+ const todo = [moduleIndex];
699
+ while (todo.length) {
700
+ const m = todo.pop();
701
+ if (!m || result.has(m)) continue;
702
+ result.add(m);
703
+ for (const d of this.moduleDependencies(m)) todo.push(d.module);
704
+ }
705
+ return result;
706
+ }
707
+
708
+ /**
709
+ * Translation from this edition to another edition it depends on (see
710
+ * SnomedProvider.translateToEdition). The target is an edition URI,
711
+ * http://snomed.info/sct/<module>[/version/<date>]; the result says which module and
712
+ * version that is, and which modules' content belongs to it, or null if this edition
713
+ * does not depend on it (or not on that version of it).
714
+ */
715
+ editionDependency(targetEdition) {
716
+ const m = /^http:\/\/snomed\.info\/x?sct\/(\d+)(?:\/version\/(\d{8}))?$/.exec(targetEdition || '');
717
+ if (!m) return null;
718
+ const mine = this.editionModule();
719
+ const target = this.concepts.findConcept(m[1]);
720
+ if (!mine || !target.found) return null;
721
+ for (const d of this.moduleDependencies(mine)) {
722
+ if (d.module === target.index && (!m[2] || m[2] === d.targetEffectiveTime)) {
723
+ const version = d.targetEffectiveTime ? `http://snomed.info/sct/${m[1]}/version/${d.targetEffectiveTime}` : `http://snomed.info/sct/${m[1]}`;
724
+ return { moduleId: m[1], moduleIndex: target.index, version, modules: this.editionModules(target.index) };
725
+ }
726
+ }
727
+ return null;
728
+ }
729
+
730
+ /**
731
+ * The concept whose normal form is the given expression, if there is one. The index is
732
+ * built on first use: every defined concept's stored normal form, rendered in the minimal
733
+ * form so that two expressions compare as strings.
734
+ * @param {string} rendered - the expression, rendered with SnomedServicesRenderOption.Minimal
735
+ * @returns {number} the concept index, or -1
736
+ */
737
+ conceptWithNormalForm(rendered) {
738
+ if (!this._normalFormIndex) {
739
+ const index = new Map();
740
+ const total = this.concepts.count();
741
+ for (let ord = 0; ord < total; ord++) {
742
+ const c = this.concepts.getConceptByCount(ord);
743
+ if ((c.flags & 0x0F) !== 0 || this.concepts.getNormalForm(c.index) === 0) continue;
744
+ try {
745
+ const nf = this.expressionServices.createNormalForm(c.index);
746
+ const key = this.expressionServices.renderExpression(nf, SnomedServicesRenderOption.Minimal);
747
+ if (!index.has(key)) index.set(key, c.index);
748
+ } catch (e) {
749
+ // a normal form that cannot be built is simply not indexed
750
+ }
751
+ }
752
+ this._normalFormIndex = index;
753
+ }
754
+ const r = this._normalFormIndex.get(rendered);
755
+ return r === undefined ? -1 : r;
756
+ }
757
+
571
758
  getConceptRefSet(conceptIndex, byName = false) {
572
759
  for (let i = 0; i < this.refSetIndex.count(); i++) {
573
760
  const refSet = this.refSetIndex.getReferenceSet(i);
@@ -583,13 +770,8 @@ class SnomedServices {
583
770
  // members" (returns a numeric membersByRef, which may be 0 or the
584
771
  // MAGIC_NO_CHILDREN sentinel). Used by memberOf to reject non-refset operands.
585
772
  _findRefSetMembersRef(conceptIndex) {
586
- for (let i = 0; i < this.refSetIndex.count(); i++) {
587
- const refSet = this.refSetIndex.getReferenceSet(i);
588
- if (refSet.definition === conceptIndex) {
589
- return refSet.membersByRef;
590
- }
591
- }
592
- return null;
773
+ const refSet = this._findRefSetRecord(conceptIndex);
774
+ return refSet ? refSet.membersByRef : null;
593
775
  }
594
776
 
595
777
  // Filter support methods
@@ -724,12 +906,21 @@ class SnomedServices {
724
906
  * >> (ancestor-or-self-of)
725
907
  * >! (strict ancestor-of)
726
908
  * > (parent-of)
727
- * ^ (member-of refset) — refset must be a plain concept ID
909
+ * ^ (member-of refset) — a concept or any bracketed expression, with
910
+ * {{ M ... }} member filters
728
911
  * * (wildcard)
912
+ * hierarchy operators on *, ^ and bracketed sets (applied per member)
729
913
  * AND / OR / MINUS compound expressions
914
+ * !!> / !!< (top / bottom of set)
915
+ * refinements (= against an expression, attribute AND/OR/",", groups,
916
+ * attribute and group cardinality, attribute names that are expressions),
917
+ * dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
918
+ * filters, comments
730
919
  *
731
- * Everything else (refinements, dotted expressions, cardinality,
732
- * reverse attributes, numeric/string comparisons) throws an informative error.
920
+ * Everything else (reverse attributes, != and concrete (numeric/string/
921
+ * boolean) values in refinements, alternate identifiers, reference set field
922
+ * selection on ^, {{ + ... }} history supplements) throws an informative
923
+ * error which is reported as UNSUPPORTED_ECL.
733
924
  */
734
925
 
735
926
  /**
@@ -760,8 +951,11 @@ class SnomedServices {
760
951
  // be valid) from an *unsupported* one (a well-formed expression using a
761
952
  // feature this server does not implement). The tx-ecosystem test suite
762
953
  // expects INVALID_ECL for the former, UNSUPPORTED_ECL for the latter.
954
+ // The parser has accepted the expression by now, so an evaluation error
955
+ // means an unsupported feature - unless it is explicitly about a concept
956
+ // that does not exist (err.eclInvalid, or the legacy "is not known" text).
763
957
  const emsg = (err && err.message) ? err.message : String(err);
764
- const invalid = /is not known/.test(emsg) || /Cannot convert .* to a BigInt/i.test(emsg);
958
+ const invalid = (err && err.eclInvalid) || /is not known/.test(emsg);
765
959
  const msgId = invalid ? 'INVALID_ECL' : 'UNSUPPORTED_ECL';
766
960
  throw new Issue('error', 'invalid', null, msgId, opContext.i18n.translate(msgId, opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
767
961
  }
@@ -870,20 +1064,35 @@ class SnomedServices {
870
1064
  const operator = node.operator; // an ECLTokenType string, or null
871
1065
  const focus = node.focus;
872
1066
 
873
- // Wildcard
874
- if (focus.type === ECLNodeType.WILDCARD) {
875
- if (operator) {
876
- throw new Error('ECL hierarchy operators combined with wildcard (*) are not supported');
1067
+ // History supplements are parsed but not implemented. Member filters
1068
+ // ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
1069
+ // and description filters ({{ D ... }}) are applied to the result below.
1070
+ 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');
877
1073
  }
878
- return this._eclWildcard();
1074
+ const inner = { ...node, filters: undefined };
1075
+ let ctx = this._evalSubExpression(inner, opContext);
1076
+ const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
1077
+ if (conceptFilters.length > 0) {
1078
+ ctx = this._applyConceptFilters(ctx, conceptFilters, opContext);
1079
+ }
1080
+ for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
1081
+ ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
1082
+ }
1083
+ return ctx;
879
1084
  }
880
1085
 
881
- // Member-of (^)
882
- if (focus.type === ECLNodeType.MEMBER_OF) {
883
- if (operator) {
884
- throw new Error('ECL hierarchy operators combined with ^ (member-of) are not yet supported');
885
- }
886
- return this._evalMemberOf(focus, opContext);
1086
+ // Alternate identifiers (ECL 2.1 "scheme#code")
1087
+ if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
1088
+ throw new Error(`ECL alternate identifiers (${focus.scheme}#${focus.code}) are not supported - no alternate identifier reference sets are loaded`);
1089
+ }
1090
+
1091
+ // Top / bottom of set: the members of the focus set with no ancestor
1092
+ // (respectively descendant) in that set
1093
+ if (operator === ECLTokenType.TOP_OF_SET || operator === ECLTokenType.BOTTOM_OF_SET) {
1094
+ const set = this._eclResolveSet(this._evalECLNode(focus, opContext), opContext);
1095
+ return this._eclTopOrBottom(set, operator === ECLTokenType.TOP_OF_SET, opContext);
887
1096
  }
888
1097
 
889
1098
  // Plain concept reference
@@ -891,8 +1100,119 @@ class SnomedServices {
891
1100
  return this._evalConceptWithOperator(focus.conceptId, operator, opContext);
892
1101
  }
893
1102
 
894
- // Parenthesised sub-expression: focus is itself a full constraint node
895
- return this._evalECLNode(focus, opContext);
1103
+ // Wildcard, member-of, or a parenthesised sub-expression: evaluate the
1104
+ // focus to a set, then apply the operator to each member and union the
1105
+ // results ("< (A OR B)" is "< A OR < B"; "<< ^ refset" is the descendants
1106
+ // of every member). Without an operator the set is the answer.
1107
+ let base;
1108
+ if (focus.type === ECLNodeType.WILDCARD) {
1109
+ base = this._eclWildcard();
1110
+ } else if (focus.type === ECLNodeType.MEMBER_OF) {
1111
+ base = this._evalMemberOf(focus, opContext);
1112
+ } else {
1113
+ base = this._evalECLNode(focus, opContext);
1114
+ }
1115
+ if (!operator) {
1116
+ return base;
1117
+ }
1118
+ return this._eclApplyOperatorToSet(base, operator, opContext);
1119
+ };
1120
+
1121
+ /**
1122
+ * Apply a hierarchy operator to every concept in a set and union the results.
1123
+ * The wildcard is special-cased: its self-inclusive forms are the wildcard
1124
+ * again, and the others are "every concept that has a parent / child".
1125
+ */
1126
+ _eclApplyOperatorToSet = function (ctx, operator, opContext) {
1127
+ if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
1128
+ switch (operator) {
1129
+ case ECLTokenType.DESCENDANT_OR_SELF_OF:
1130
+ case ECLTokenType.ANCESTOR_OR_SELF_OF:
1131
+ case ECLTokenType.CHILD_OR_SELF_OF:
1132
+ case ECLTokenType.PARENT_OR_SELF_OF:
1133
+ return this._eclWildcard();
1134
+ case ECLTokenType.DESCENDANT_OF:
1135
+ case ECLTokenType.CHILD_OF: {
1136
+ const result = new SnomedFilterContext();
1137
+ result.descendants = this._eclEnumerateAllConcepts(opContext).filter(idx => this.getConceptParents(idx).length > 0);
1138
+ return result;
1139
+ }
1140
+ case ECLTokenType.ANCESTOR_OF:
1141
+ case ECLTokenType.PARENT_OF: {
1142
+ const result = new SnomedFilterContext();
1143
+ result.descendants = this._eclEnumerateAllConcepts(opContext).filter(idx => this.getConceptChildren(idx).length > 0);
1144
+ return result;
1145
+ }
1146
+ default:
1147
+ throw new Error(`Unsupported ECL hierarchy operator: ${operator}`);
1148
+ }
1149
+ }
1150
+ const union = new Set();
1151
+ for (const idx of this._eclToIndexArray(ctx)) {
1152
+ if (opContext) opContext.deadCheck('ecl:operatorOnSet');
1153
+ const part = this._evalConceptWithOperator(this.concepts.getConceptId(idx), operator, opContext);
1154
+ for (const r of this._eclToIndexArray(part)) {
1155
+ union.add(r);
1156
+ }
1157
+ }
1158
+ const result = new SnomedFilterContext();
1159
+ result.descendants = [...union];
1160
+ return result;
1161
+ };
1162
+
1163
+ /**
1164
+ * !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
1165
+ * descendant (bottom) that is also in `set`. Walks the stated parents of each
1166
+ * member within the set, so the cost is bounded by the set's own size.
1167
+ */
1168
+ _eclTopOrBottom = function (set, top, opContext) {
1169
+ const members = new Set(set);
1170
+ // a member is "covered" (not top) when some ancestor is in the set; for
1171
+ // bottom, the ancestor is the one that is covered
1172
+ const covered = new Set();
1173
+ for (const idx of members) {
1174
+ if (opContext) opContext.deadCheck('ecl:topBottom');
1175
+ const seen = new Set();
1176
+ const stack = [...this.getConceptParents(idx)];
1177
+ while (stack.length > 0) {
1178
+ const p = stack.pop();
1179
+ if (seen.has(p)) continue;
1180
+ seen.add(p);
1181
+ if (members.has(p)) {
1182
+ covered.add(top ? idx : p);
1183
+ if (top) break;
1184
+ }
1185
+ stack.push(...this.getConceptParents(p));
1186
+ }
1187
+ }
1188
+ const result = new SnomedFilterContext();
1189
+ result.descendants = [...members].filter(idx => !covered.has(idx));
1190
+ return result;
1191
+ };
1192
+
1193
+ /**
1194
+ * Resolve an attribute name (the left side of a refinement or a dotted step)
1195
+ * to the set of relationship type indexes it denotes: a bare concept gives one
1196
+ * index, `*` any type, and an expression (<< 762705008, (A OR B), ^ refset)
1197
+ * whatever it evaluates to.
1198
+ * @returns {{any: boolean, types: Set<number>|null}}
1199
+ */
1200
+ _eclAttributeTypes = function (nameNode, opContext) {
1201
+ if (nameNode.type === ECLNodeType.WILDCARD) {
1202
+ return { any: true, types: null };
1203
+ }
1204
+ if (nameNode.type === ECLNodeType.CONCEPT_REFERENCE) {
1205
+ const attrResult = this.concepts.findConcept(nameNode.conceptId);
1206
+ if (!attrResult.found) {
1207
+ throw new Error(`The SNOMED CT Concept ${nameNode.conceptId} is not known`);
1208
+ }
1209
+ return { any: false, types: new Set([attrResult.index]) };
1210
+ }
1211
+ const ctx = this._evalECLNode(nameNode, opContext);
1212
+ if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
1213
+ return { any: true, types: null };
1214
+ }
1215
+ return { any: false, types: new Set(this._eclToIndexArray(ctx)) };
896
1216
  };
897
1217
 
898
1218
  /**
@@ -988,12 +1308,21 @@ class SnomedServices {
988
1308
  * @returns {SnomedFilterContext}
989
1309
  */
990
1310
  _evalMemberOf = function (memberOfNode, opContext) {
1311
+ // ^ [field, ...] refset selects which field of the member rows to return
1312
+ // instead of the referenced component. Not supported: the referenced
1313
+ // components, and the fields, may be descriptions or other non-concept
1314
+ // components, which an expansion cannot carry.
1315
+ if (memberOfNode.fields) {
1316
+ throw new Error(`ECL reference set field selection (^ [${memberOfNode.fields.join(', ')}] ...) is not supported`);
1317
+ }
991
1318
  const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
1319
+ const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
992
1320
 
993
1321
  const members = new Set();
994
1322
  for (const refsetIdx of refsetConcepts) {
995
1323
  if (opContext) opContext.deadCheck('ecl:memberOf');
996
- const membersRef = this._findRefSetMembersRef(refsetIdx);
1324
+ const refSet = this._findRefSetRecord(refsetIdx);
1325
+ const membersRef = refSet ? refSet.membersByRef : null;
997
1326
  if (membersRef === null) {
998
1327
  // Operand concept is not a reference set: yield no members for it
999
1328
  // (empty result) rather than erroring. Applies to both a bare concept
@@ -1003,15 +1332,21 @@ class SnomedServices {
1003
1332
  if (membersRef === 0 || membersRef === 0xFFFFFFFF) {
1004
1333
  continue; // a reference set with no members
1005
1334
  }
1006
- const memberList = this.refSetMembers.getMembers(membersRef);
1007
- for (const m of memberList || []) {
1335
+ const memberList = this.refSetMembers.getMembers(membersRef) || [];
1336
+ // A reference set whose members are descriptions (a language reference
1337
+ // set, say) has no concept members to return. Say so rather than
1338
+ // answering with an empty set, which would read as "no members".
1339
+ if (memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
1340
+ throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
1341
+ }
1342
+ for (const m of memberList) {
1008
1343
  // Concept referenced components only (kind 0). Description/other members
1009
1344
  // are excluded, which also prevents the no-component sentinel (0xFFFFFFFF)
1010
1345
  // from leaking in. The referenced concept may itself be INACTIVE and is
1011
1346
  // still returned — "active" in the spec qualifies the membership row (and
1012
1347
  // inactive rows are already dropped at import), not the referenced
1013
1348
  // concept. The expansion marks/handles inactivity (e.g. activeOnly).
1014
- if (m.kind === 0) {
1349
+ if (m.kind === 0 && this._eclMemberMatches(m, refSet, memberFilters)) {
1015
1350
  members.add(m.ref);
1016
1351
  }
1017
1352
  }
@@ -1021,6 +1356,380 @@ class SnomedServices {
1021
1356
  return result;
1022
1357
  };
1023
1358
 
1359
+ /**
1360
+ * Apply {{ C ... }} concept filters to a result: each concept is tested on
1361
+ * its own active flag, module, effectiveTime and definition status. Several
1362
+ * filter constraints, and the comma-separated filters within one, all AND.
1363
+ */
1364
+ _applyConceptFilters = function (ctx, filterConstraints, opContext) {
1365
+ const filters = [];
1366
+ for (const fc of filterConstraints) {
1367
+ for (const f of fc.filters) {
1368
+ if (f.valueType === 'expression') {
1369
+ const vctx = this._evalECLNode(f.value, opContext);
1370
+ if (vctx.eclWildcard && (!vctx.descendants || vctx.descendants.length === 0)) {
1371
+ filters.push({ ...f, wildcard: true });
1372
+ } else {
1373
+ filters.push({ ...f, set: new Set(this._eclToIndexArray(vctx)) });
1374
+ }
1375
+ } else {
1376
+ filters.push(f);
1377
+ }
1378
+ }
1379
+ }
1380
+ // The definition status concepts, if this edition has them
1381
+ const statusIndex = (id) => {
1382
+ const r = this.concepts.findConcept(id);
1383
+ return r.found ? r.index : null;
1384
+ };
1385
+ const primitiveIdx = statusIndex('900000000000074008');
1386
+ const definedIdx = statusIndex('900000000000073002');
1387
+
1388
+ const matching = [];
1389
+ for (const idx of this._eclResolveSet(ctx, opContext)) {
1390
+ if (opContext) opContext.deadCheck('ecl:conceptFilter');
1391
+ let ok = true;
1392
+ for (const f of filters) {
1393
+ const field = f.field.toLowerCase();
1394
+ let r;
1395
+ if (field === 'active') {
1396
+ r = this.isActive(idx) === f.value;
1397
+ if (f.operator === '!=') r = !r;
1398
+ } else if (field === 'effectivetime') {
1399
+ const concept = this.concepts.getConcept(idx);
1400
+ r = this._eclCompareValues(this._snomedDateToString(concept.effectiveTime), f.operator, f.values, (a, b) => a.localeCompare(b));
1401
+ } else if (field === 'moduleid') {
1402
+ const inSet = f.wildcard || f.set.has(this.concepts.getModuleId(idx));
1403
+ r = f.operator === '=' ? inSet : !inSet;
1404
+ } else if (field === 'definitionstatusid') {
1405
+ const statusIdx = this.isPrimitive(idx) ? primitiveIdx : definedIdx;
1406
+ const inSet = f.wildcard || (statusIdx !== null && f.set.has(statusIdx));
1407
+ r = f.operator === '=' ? inSet : !inSet;
1408
+ } else if (field === 'definitionstatus') {
1409
+ const status = this.isPrimitive(idx) ? 'primitive' : 'defined';
1410
+ const inSet = f.values.includes(status);
1411
+ r = f.operator === '=' ? inSet : !inSet;
1412
+ } else {
1413
+ throw new Error(`Unsupported concept filter ${f.field}`);
1414
+ }
1415
+ if (!r) {
1416
+ ok = false;
1417
+ break;
1418
+ }
1419
+ }
1420
+ if (ok) {
1421
+ matching.push(idx);
1422
+ }
1423
+ }
1424
+ const result = new SnomedFilterContext();
1425
+ result.descendants = matching;
1426
+ return result;
1427
+ };
1428
+
1429
+ /**
1430
+ * Apply one {{ D ... }} block: a concept is kept when at least one of its
1431
+ * descriptions satisfies every filter in the block. Only active descriptions
1432
+ * are considered unless the block has an `active` filter. Several blocks
1433
+ * AND at the concept level (each may be satisfied by a different description).
1434
+ */
1435
+ _applyDescriptionFilter = function (ctx, filters, opContext) {
1436
+ const K = this._displayConstants();
1437
+ const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
1438
+ const typeTokens = { syn: K.synonym, fsn: K.fsn, def: idx('900000000000550004') };
1439
+ const acceptabilityIdx = { preferred: K.preferred, acceptable: idx('900000000000549004') };
1440
+ const resolveSet = (expr) => {
1441
+ const vctx = this._evalECLNode(expr, opContext);
1442
+ if (vctx.eclWildcard && (!vctx.descendants || vctx.descendants.length === 0)) return null; // any
1443
+ return new Set(this._eclToIndexArray(vctx));
1444
+ };
1445
+ let activeFilter = null;
1446
+ const prepared = filters.map(f => {
1447
+ const field = f.field.toLowerCase();
1448
+ switch (f.valueType) {
1449
+ case 'term':
1450
+ return { ...f, field, matchers: f.values.map(v => this._eclTermMatcher(v)) };
1451
+ case 'expression':
1452
+ return { ...f, field, set: resolveSet(f.value) };
1453
+ case 'type':
1454
+ return { ...f, field, set: new Set(f.values.map(t => typeTokens[t]).filter(i => i >= 0)) };
1455
+ case 'dialectId':
1456
+ case 'dialect':
1457
+ return { ...f, field, dialects: f.dialects.map(d => {
1458
+ let set;
1459
+ if (d.alias) {
1460
+ const id = ECL_DIALECT_ALIASES[d.alias];
1461
+ if (!id) throw new Error(`Unknown dialect alias ${d.alias} in description filter`);
1462
+ const i = idx(id);
1463
+ set = new Set(i >= 0 ? [i] : []);
1464
+ } else {
1465
+ set = resolveSet(d.expression);
1466
+ }
1467
+ const acceptability = d.acceptability
1468
+ ? new Set(d.acceptability.map(a => acceptabilityIdx[a] !== undefined ? acceptabilityIdx[a] : idx(a)))
1469
+ : null;
1470
+ return { set, acceptability };
1471
+ }) };
1472
+ case 'boolean':
1473
+ activeFilter = f;
1474
+ return { ...f, field };
1475
+ case 'id':
1476
+ return { ...f, field, ids: new Set(f.values.map(v => BigInt(v))) };
1477
+ default:
1478
+ return { ...f, field };
1479
+ }
1480
+ });
1481
+
1482
+ const matching = [];
1483
+ for (const conceptIdx of this._eclResolveSet(ctx, opContext)) {
1484
+ if (opContext) opContext.deadCheck('ecl:descriptionFilter');
1485
+ const concept = this.concepts.getConcept(conceptIdx);
1486
+ const descIndices = concept.descriptions ? (this.refs.getReferences(concept.descriptions) || []) : [];
1487
+ let found = false;
1488
+ for (const descIndex of descIndices) {
1489
+ const d = this.descriptions.getDescription(descIndex);
1490
+ if (!activeFilter && !d.active) continue;
1491
+ if (prepared.every(f => this._eclDescriptionMatches(d, f))) {
1492
+ found = true;
1493
+ break;
1494
+ }
1495
+ }
1496
+ if (found) matching.push(conceptIdx);
1497
+ }
1498
+ const result = new SnomedFilterContext();
1499
+ result.descendants = matching;
1500
+ return result;
1501
+ };
1502
+
1503
+ /**
1504
+ * Does one description satisfy one prepared description filter?
1505
+ */
1506
+ _eclDescriptionMatches = function (d, f) {
1507
+ const neg = f.operator === '!=';
1508
+ const eq = (r) => neg ? !r : r;
1509
+ switch (f.field) {
1510
+ case 'term':
1511
+ return eq(f.matchers.some(m => m(this.strings.getEntry(d.iDesc))));
1512
+ case 'language':
1513
+ return eq(f.values.includes(SCT_LANGUAGE_CODES[d.lang] || 'en'));
1514
+ case 'typeid':
1515
+ return eq(f.set === null || f.set.has(d.kind));
1516
+ case 'type':
1517
+ return eq(f.set.has(d.kind));
1518
+ case 'moduleid':
1519
+ return eq(f.set === null || f.set.has(d.module));
1520
+ case 'dialectid':
1521
+ case 'dialect': {
1522
+ const refsets = d.refsets ? (this.refs.getReferences(d.refsets) || []) : [];
1523
+ const inDialect = f.dialects.some(dl => refsets.some(r => {
1524
+ if (dl.set !== null && !dl.set.has(r)) return false;
1525
+ if (!dl.acceptability) return true;
1526
+ return dl.acceptability.has(this._descAcceptability(d, r));
1527
+ }));
1528
+ return eq(inDialect);
1529
+ }
1530
+ case 'effectivetime':
1531
+ return this._eclCompareValues(this._snomedDateToString(d.date), f.operator, f.values, (a, b) => a.localeCompare(b));
1532
+ case 'active':
1533
+ return eq(d.active === f.value);
1534
+ case 'id':
1535
+ return eq(f.ids.has(d.id));
1536
+ default:
1537
+ throw new Error(`Unsupported description filter ${f.field}`);
1538
+ }
1539
+ };
1540
+
1541
+ /**
1542
+ * A matcher for one typed search term. "match" semantics (the default): every
1543
+ * word of the search term is a prefix of some word of the description, case
1544
+ * insensitively. "wild" semantics: the whole description matches the pattern,
1545
+ * with * standing for any characters.
1546
+ */
1547
+ _eclTermMatcher = function (v) {
1548
+ if (v.kind === 'wild') {
1549
+ const re = new RegExp('^' + v.value.split('*').map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
1550
+ return (term) => re.test(term);
1551
+ }
1552
+ const words = v.value.toLowerCase().split(/\s+/).filter(w => w.length > 0);
1553
+ return (term) => {
1554
+ const termWords = term.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 0);
1555
+ return words.every(w => termWords.some(tw => tw.startsWith(w)));
1556
+ };
1557
+ };
1558
+
1559
+ /**
1560
+ * Resolve the expression-valued member filters (moduleId = ..., <conceptField> = ...)
1561
+ * once, before the members are walked. Returns the filters with a `set` of
1562
+ * concept indexes attached (or `wildcard: true`), or null when there are none.
1563
+ */
1564
+ _eclPrepareMemberFilters = function (filters, opContext) {
1565
+ if (!filters || filters.length === 0) {
1566
+ return null;
1567
+ }
1568
+ return filters.map(f => {
1569
+ if (f.valueType !== 'expression') {
1570
+ return f;
1571
+ }
1572
+ const ctx = this._evalECLNode(f.value, opContext);
1573
+ if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
1574
+ return { ...f, wildcard: true };
1575
+ }
1576
+ return { ...f, set: new Set(this._eclToIndexArray(ctx)) };
1577
+ });
1578
+ };
1579
+
1580
+ /**
1581
+ * Does a reference set member row satisfy every member filter?
1582
+ *
1583
+ * Member rows are only stored when active, so `active = false` matches
1584
+ * nothing. effectiveTime and moduleId come from the row itself (present when
1585
+ * the reference set stores ids); any other field name is looked up in the
1586
+ * reference set's additional fields (e.g. targetComponentId, mapTarget,
1587
+ * acceptabilityId), typed by the refset file name (c / i / s).
1588
+ */
1589
+ _eclMemberMatches = function (m, refSet, filters) {
1590
+ if (!filters) {
1591
+ return true;
1592
+ }
1593
+ for (const f of filters) {
1594
+ const field = f.field.toLowerCase();
1595
+ let ok;
1596
+ if (field === 'active') {
1597
+ ok = (f.operator === '=') === f.value;
1598
+ } else if (field === 'effectivetime') {
1599
+ if (m.date == null) {
1600
+ return false;
1601
+ }
1602
+ ok = this._eclCompareValues(this._snomedDateToString(m.date), f.operator, f.values, (a, b) => a.localeCompare(b));
1603
+ } else if (field === 'moduleid') {
1604
+ if (m.module == null) {
1605
+ return false;
1606
+ }
1607
+ const inSet = f.wildcard || f.set.has(m.module);
1608
+ ok = f.operator === '=' ? inSet : !inSet;
1609
+ } else {
1610
+ const fv = this._eclMemberFieldValue(m, refSet, f.field);
1611
+ if (fv === undefined) {
1612
+ throw new Error(`Reference set ${this.concepts.getConceptId(refSet.definition)} has no field named ${f.field}`);
1613
+ }
1614
+ if (fv === null) {
1615
+ return false;
1616
+ }
1617
+ switch (f.valueType) {
1618
+ case 'expression': {
1619
+ if (fv.type !== 'concept') {
1620
+ throw new Error(`Reference set field ${f.field} is not a concept field`);
1621
+ }
1622
+ const inSet = f.wildcard || f.set.has(fv.value);
1623
+ ok = f.operator === '=' ? inSet : !inSet;
1624
+ break;
1625
+ }
1626
+ case 'numeric': {
1627
+ if (fv.type !== 'integer') {
1628
+ throw new Error(`Reference set field ${f.field} is not a numeric field`);
1629
+ }
1630
+ ok = this._eclCompareValues(Number(fv.value), f.operator, [Number(f.value)], (a, b) => a - b);
1631
+ break;
1632
+ }
1633
+ case 'string': {
1634
+ if (fv.type !== 'string') {
1635
+ throw new Error(`Reference set field ${f.field} is not a string field`);
1636
+ }
1637
+ ok = this._eclCompareValues(fv.value, f.operator, f.values, (a, b) => a.localeCompare(b));
1638
+ break;
1639
+ }
1640
+ case 'time': {
1641
+ if (fv.type !== 'string') {
1642
+ throw new Error(`Reference set field ${f.field} is not a time field`);
1643
+ }
1644
+ ok = this._eclCompareValues(fv.value, f.operator, f.values, (a, b) => a.localeCompare(b));
1645
+ break;
1646
+ }
1647
+ case 'boolean': {
1648
+ if (fv.type !== 'string' && fv.type !== 'integer') {
1649
+ throw new Error(`Reference set field ${f.field} is not a boolean field`);
1650
+ }
1651
+ const b = String(fv.value) === '1' || String(fv.value).toLowerCase() === 'true';
1652
+ ok = (f.operator === '=') === (b === f.value);
1653
+ break;
1654
+ }
1655
+ default:
1656
+ throw new Error(`Unsupported member filter on ${f.field}`);
1657
+ }
1658
+ }
1659
+ if (!ok) {
1660
+ return false;
1661
+ }
1662
+ }
1663
+ return true;
1664
+ };
1665
+
1666
+ /**
1667
+ * Compare one value against a value set with an ECL comparison operator.
1668
+ * "=" is true when any set value matches, "!=" when none does; the ordering
1669
+ * operators compare against the single value.
1670
+ */
1671
+ _eclCompareValues = function (actual, operator, values, cmp) {
1672
+ switch (operator) {
1673
+ case '=': return values.some(v => cmp(actual, v) === 0);
1674
+ case '!=': return !values.some(v => cmp(actual, v) === 0);
1675
+ case '<': return cmp(actual, values[0]) < 0;
1676
+ case '<=': return cmp(actual, values[0]) <= 0;
1677
+ case '>': return cmp(actual, values[0]) > 0;
1678
+ case '>=': return cmp(actual, values[0]) >= 0;
1679
+ default: throw new Error(`Unsupported comparison operator ${operator}`);
1680
+ }
1681
+ };
1682
+
1683
+ /**
1684
+ * Read a named additional field from a member row. Returns undefined when the
1685
+ * reference set has no such field, null when the row has no value for it, and
1686
+ * otherwise { type: 'concept'|'description'|'integer'|'string', value }.
1687
+ */
1688
+ _eclMemberFieldValue = function (m, refSet, fieldName) {
1689
+ if (!refSet.fieldNames) {
1690
+ return undefined;
1691
+ }
1692
+ const names = this.refs.getReferences(refSet.fieldNames) || [];
1693
+ const idx = names.findIndex(n => this.strings.getEntry(n).trim().toLowerCase() === fieldName.toLowerCase());
1694
+ if (idx < 0) {
1695
+ return undefined;
1696
+ }
1697
+ const values = m.values ? this.refs.getReferences(m.values) : null;
1698
+ if (!values || values.length < (idx + 1) * 2) {
1699
+ return null;
1700
+ }
1701
+ const value = values[idx * 2];
1702
+ const type = values[idx * 2 + 1];
1703
+ switch (type) {
1704
+ case 1: return { type: 'concept', value };
1705
+ case 2: return { type: 'description', value };
1706
+ case 4: return { type: 'integer', value };
1707
+ case 5: return { type: 'string', value: this.strings.getEntry(value) };
1708
+ default: return null;
1709
+ }
1710
+ };
1711
+
1712
+ /**
1713
+ * Stored SNOMED dates are days since the Pascal TDateTime epoch (1899-12-30);
1714
+ * render as YYYYMMDD for comparison with an ECL time value.
1715
+ */
1716
+ _snomedDateToString = function (days) {
1717
+ const d = new Date(Date.UTC(1899, 11, 30) + days * 86400000);
1718
+ return d.toISOString().slice(0, 10).replace(/-/g, '');
1719
+ };
1720
+
1721
+ _findRefSetRecord = function (conceptIndex) {
1722
+ for (let i = 0; i < this.refSetIndex.count(); i++) {
1723
+ const refSet = this.refSetIndex.getReferenceSet(i);
1724
+ // definition 0 means the importer could not resolve the reference set's
1725
+ // concept (it is outside the subset) - not "the concept at offset 0"
1726
+ if (refSet.definition === conceptIndex && refSet.definition !== 0) {
1727
+ return refSet;
1728
+ }
1729
+ }
1730
+ return null;
1731
+ };
1732
+
1024
1733
  /**
1025
1734
  * Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
1026
1735
  * filterLocate to accept every active concept without enumeration.
@@ -1046,14 +1755,7 @@ class SnomedServices {
1046
1755
  let current = this._eclResolveSet(this._evalECLNode(node.base, opContext), opContext);
1047
1756
 
1048
1757
  for (const attr of node.attributes || []) {
1049
- if (attr.type !== ECLNodeType.CONCEPT_REFERENCE) {
1050
- throw new Error('ECL dotted expressions only support plain concept-reference attribute names');
1051
- }
1052
- const attrResult = this.concepts.findConcept(attr.conceptId);
1053
- if (!attrResult.found) {
1054
- throw new Error(`The SNOMED CT Concept ${attr.conceptId} is not known`);
1055
- }
1056
- const attrTypeIdx = attrResult.index;
1758
+ const types = this._eclAttributeTypes(attr, opContext);
1057
1759
 
1058
1760
  const next = new Set();
1059
1761
  for (const conceptIdx of current) {
@@ -1061,7 +1763,7 @@ class SnomedServices {
1061
1763
  const relIdxs = this.getConceptRelationships(conceptIdx);
1062
1764
  for (const relIdx of relIdxs) {
1063
1765
  const rel = this.relationships.getRelationship(relIdx);
1064
- if (rel.active && rel.relType === attrTypeIdx) {
1766
+ if (rel.active && (types.any || types.types.has(rel.relType))) {
1065
1767
  next.add(rel.target);
1066
1768
  }
1067
1769
  }
@@ -1080,7 +1782,7 @@ class SnomedServices {
1080
1782
  * Evaluate a refined expression: `<baseConstraint> : <refinement>`.
1081
1783
  * Supported refinement shapes:
1082
1784
  * - ATTRIBUTE attr = valueExpr
1083
- * - ATTRIBUTE_SET attr1 = v1, attr2 = v2 (conjunction)
1785
+ * - ATTRIBUTE_SET attr1 = v1, attr2 = v2 / attr1 = v1 AND|OR attr2 = v2 (operator 'AND'|'OR')
1084
1786
  * - ATTRIBUTE_GROUP { attr1 = v1, attr2 = v2 } (same relationship group)
1085
1787
  * Reverse attributes, cardinality, `!=`, and non-concept attribute names
1086
1788
  * throw informative errors.
@@ -1113,10 +1815,11 @@ class SnomedServices {
1113
1815
  case ECLNodeType.ATTRIBUTE:
1114
1816
  return this._attributeMatches(rels, refinement, null, opContext);
1115
1817
  case ECLNodeType.ATTRIBUTE_SET:
1116
- for (const a of refinement.attributes) {
1117
- if (!this._refinementMatches(rels, a, opContext)) return false;
1818
+ // operator is 'AND' (also for ",") or 'OR'
1819
+ if (refinement.operator === 'OR') {
1820
+ return refinement.attributes.some(a => this._refinementMatches(rels, a, opContext));
1118
1821
  }
1119
- return true;
1822
+ return refinement.attributes.every(a => this._refinementMatches(rels, a, opContext));
1120
1823
  case ECLNodeType.ATTRIBUTE_GROUP:
1121
1824
  return this._attributeGroupMatches(rels, refinement, opContext);
1122
1825
  default:
@@ -1124,6 +1827,23 @@ class SnomedServices {
1124
1827
  }
1125
1828
  };
1126
1829
 
1830
+ /**
1831
+ * The same, restricted to the relationships in one role group: an attribute
1832
+ * set inside { } may itself be an AND/OR tree of attributes.
1833
+ */
1834
+ _refinementMatchesInGroup = function (rels, node, g, opContext) {
1835
+ if (node.type === ECLNodeType.ATTRIBUTE_SET) {
1836
+ if (node.operator === 'OR') {
1837
+ return node.attributes.some(a => this._refinementMatchesInGroup(rels, a, g, opContext));
1838
+ }
1839
+ return node.attributes.every(a => this._refinementMatchesInGroup(rels, a, g, opContext));
1840
+ }
1841
+ if (node.type !== ECLNodeType.ATTRIBUTE) {
1842
+ throw new Error(`Unsupported node type in attribute group: ${node.type}`);
1843
+ }
1844
+ return this._attributeMatches(rels, node, g, opContext);
1845
+ };
1846
+
1127
1847
  /**
1128
1848
  * Check whether a concept has at least one active relationship whose
1129
1849
  * `relType` matches the attribute name and whose `target` is in the value
@@ -1142,14 +1862,11 @@ class SnomedServices {
1142
1862
  throw new Error('ECL attribute without a comparison is not supported');
1143
1863
  }
1144
1864
  if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
1145
- throw new Error(`ECL ${attr.comparison.type} in refinements is not yet supported`);
1865
+ throw new Error('ECL concrete values (numeric, string and boolean attribute values) are not supported - concrete value relationships are not loaded');
1146
1866
  }
1147
1867
  if (attr.comparison.operator !== ECLTokenType.EQUALS) {
1148
1868
  throw new Error('ECL != in refinements is not yet supported');
1149
1869
  }
1150
- if (attr.name.type !== ECLNodeType.CONCEPT_REFERENCE) {
1151
- throw new Error('ECL refinements only support plain concept-reference attribute names');
1152
- }
1153
1870
 
1154
1871
  const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
1155
1872
 
@@ -1178,17 +1895,13 @@ class SnomedServices {
1178
1895
  // fresh per request, so memoising on the node is safe within a request.
1179
1896
  let resolved = attr._eclResolved;
1180
1897
  if (!resolved) {
1181
- const attrResult = this.concepts.findConcept(attr.name.conceptId);
1182
- if (!attrResult.found) {
1183
- throw new Error(`The SNOMED CT Concept ${attr.name.conceptId} is not known`);
1184
- }
1185
1898
  resolved = {
1186
- attrTypeIdx: attrResult.index,
1899
+ types: this._eclAttributeTypes(attr.name, opContext),
1187
1900
  valueSet: new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext))
1188
1901
  };
1189
1902
  attr._eclResolved = resolved;
1190
1903
  }
1191
- const attrTypeIdx = resolved.attrTypeIdx;
1904
+ const types = resolved.types;
1192
1905
  const valueSet = resolved.valueSet;
1193
1906
 
1194
1907
  // ECL cardinality counts OCCURRENCES of a matching attribute, per role group
@@ -1203,7 +1916,7 @@ class SnomedServices {
1203
1916
  for (const rel of rels) {
1204
1917
  if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
1205
1918
  if (!rel.active) continue;
1206
- if (rel.relType !== attrTypeIdx) continue;
1919
+ if (!types.any && !types.types.has(rel.relType)) continue;
1207
1920
  if (groupFilter !== null && rel.group !== groupFilter) continue;
1208
1921
  if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
1209
1922
  }
@@ -1248,7 +1961,7 @@ class SnomedServices {
1248
1961
  if (opContext) opContext.deadCheck('ecl:attributeGroup');
1249
1962
  let allMatch = true;
1250
1963
  for (const attr of group.attributes) {
1251
- if (!this._attributeMatches(rels, attr, g, opContext)) {
1964
+ if (!this._refinementMatchesInGroup(rels, attr, g, opContext)) {
1252
1965
  allMatch = false;
1253
1966
  break;
1254
1967
  }
@@ -1690,6 +2403,15 @@ class SnomedProvider extends BaseCSServices {
1690
2403
  return this.sct.getDescription();
1691
2404
  }
1692
2405
 
2406
+ /**
2407
+ * $lookup's display name: the edition description (e.g. "SNOMED CT International
2408
+ * Edition"), which says more than the bare code system title.
2409
+ */
2410
+ // eslint-disable-next-line no-unused-vars
2411
+ lookupName(languages) {
2412
+ return this.sct.getDescription();
2413
+ }
2414
+
1693
2415
  description() {
1694
2416
  return this.sct.getDescription();
1695
2417
  }
@@ -2038,34 +2760,46 @@ class SnomedProvider extends BaseCSServices {
2038
2760
  const ctxt = await this.#ensureContext(context);
2039
2761
  if (ctxt) {
2040
2762
  if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
2041
- const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2042
- // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
2043
- // date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
2044
- // which in zones ahead of UTC rolls the ISO date back by one day.
2045
- const pascalEpochUTC = Date.UTC(1899, 11, 30);
2046
- const date = new Date(pascalEpochUTC + time * 86400000);
2047
- const dateStr = date.toISOString().slice(0, 10);
2048
- this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
2049
-
2763
+ // Only what was asked for, if anything was (see _hasProp): effectiveTime, module,
2764
+ // parent, child and the defining relationships are returned by default but are not
2765
+ // important enough to return when the client named the properties it wants.
2766
+ // The mappings (alternate identifiers such as the LOINC code) are returned by
2767
+ // default too, and when asked for by name.
2768
+ if (this._hasProp(props, 'effectiveTime', true)) {
2769
+ const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2770
+ // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
2771
+ // date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
2772
+ // which in zones ahead of UTC rolls the ISO date back by one day.
2773
+ const pascalEpochUTC = Date.UTC(1899, 11, 30);
2774
+ const date = new Date(pascalEpochUTC + time * 86400000);
2775
+ const dateStr = date.toISOString().slice(0, 10);
2776
+ this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
2777
+ }
2050
2778
 
2051
- const parents = this.sct.getConceptParents(ctxt.getReference());
2052
- for (let parentRef of parents) {
2053
- const code = this.sct.getConceptId(parentRef);
2054
- const description = this.sct.getDisplayName(parentRef);
2055
- this._addCodeProperty(params, 'property', 'parent', code, null, description);
2779
+ if (this._hasProp(props, 'parent', true)) {
2780
+ const parents = this.sct.getConceptParents(ctxt.getReference());
2781
+ for (let parentRef of parents) {
2782
+ const code = this.sct.getConceptId(parentRef);
2783
+ const description = this.sct.getDisplayName(parentRef);
2784
+ this._addCodeProperty(params, 'property', 'parent', code, null, description);
2785
+ }
2056
2786
  }
2057
2787
 
2058
- const children = this.sct.getConceptChildren(ctxt.getReference());
2059
- for (let childRef of children) {
2060
- const code = this.sct.getConceptId(childRef);
2061
- const description = this.sct.getDisplayName(childRef);
2062
- this._addCodeProperty(params, 'property', 'child', code, null, description);
2788
+ if (this._hasProp(props, 'child', true)) {
2789
+ const children = this.sct.getConceptChildren(ctxt.getReference());
2790
+ for (let childRef of children) {
2791
+ const code = this.sct.getConceptId(childRef);
2792
+ const description = this.sct.getDisplayName(childRef);
2793
+ this._addCodeProperty(params, 'property', 'child', code, null, description);
2794
+ }
2063
2795
  }
2064
2796
 
2065
- const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
2066
- if (moduleId) {
2067
- const code = this.sct.getConceptId(moduleId);
2068
- this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
2797
+ if (this._hasProp(props, 'module', true)) {
2798
+ const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
2799
+ if (moduleId) {
2800
+ const code = this.sct.getConceptId(moduleId);
2801
+ this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
2802
+ }
2069
2803
  }
2070
2804
 
2071
2805
  const relationships = this.sct.getConceptRelationships(ctxt.getReference());
@@ -2073,7 +2807,7 @@ class SnomedProvider extends BaseCSServices {
2073
2807
  for (let relationshipRef of relationships) {
2074
2808
  const relationship = this.sct.relationships.getRelationship(relationshipRef);
2075
2809
  const relType = this.sct.getConceptId(relationship.relType);
2076
- if (relType != '116680003') {
2810
+ if (relType != '116680003' && this._hasProp(props, relType, true)) {
2077
2811
  const relTypeD = this.sct.getDisplayName(relationship.relType);
2078
2812
  const code = this.sct.getConceptId(relationship.target);
2079
2813
  const description = this.sct.getDisplayName(relationship.target);
@@ -2084,6 +2818,8 @@ class SnomedProvider extends BaseCSServices {
2084
2818
  }
2085
2819
  }
2086
2820
  }
2821
+
2822
+ this.#addMappingProperties(ctxt, props, params);
2087
2823
  }
2088
2824
  if (ctxt instanceof SnomedExpressionContext) {
2089
2825
  // ignore concepts for now, but list refinements and refinement groups
@@ -2099,6 +2835,27 @@ class SnomedProvider extends BaseCSServices {
2099
2835
  }
2100
2836
  }
2101
2837
 
2838
+ /**
2839
+ * The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
2840
+ * Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
2841
+ * Returned by default, and when asked for by name.
2842
+ */
2843
+ #addMappingProperties(ctxt, props, params) {
2844
+ const conceptId = this.sct.getConceptId(ctxt.getReference());
2845
+ for (const ident of this.sct.getIdentifiers(conceptId)) {
2846
+ const scheme = IDENTIFIER_SCHEMES[ident.scheme];
2847
+ if (scheme && this._hasProp(props, scheme.property, true)) {
2848
+ params.push({
2849
+ name: 'property',
2850
+ part: [
2851
+ { name: 'code', valueCode: scheme.property },
2852
+ { name: 'value', valueCoding: { system: scheme.system, code: ident.code } }
2853
+ ]
2854
+ });
2855
+ }
2856
+ }
2857
+ }
2858
+
2102
2859
  /**
2103
2860
  * Report one refinement of a post-coordinated expression as a lookup property.
2104
2861
  *
@@ -2154,6 +2911,11 @@ class SnomedProvider extends BaseCSServices {
2154
2911
  return true;
2155
2912
  }
2156
2913
 
2914
+ if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
2915
+ return op === 'exists' && ['true', 'false'].includes(value) &&
2916
+ this.sct.stringToIdOrZero(prop.substring(MAPPED_FILTER_PREFIX.length)) !== 0n;
2917
+ }
2918
+
2157
2919
  const cid = this.sct.stringToIdOrZero(prop);
2158
2920
  if (cid != 0) {
2159
2921
  const id = this.sct.stringToIdOrZero(value);
@@ -2310,6 +3072,27 @@ class SnomedProvider extends BaseCSServices {
2310
3072
  return null;
2311
3073
  }
2312
3074
 
3075
+ // mapped:<identifier scheme> exists true|false - concepts that do (or don't) have an
3076
+ // alternate identifier in that scheme (e.g. mapped:30051010000102: has a LOINC code).
3077
+ // A prefix, so as not to be confused with the attribute filters (<attribute> = <value>)
3078
+ if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
3079
+ const scheme = prop.substring(MAPPED_FILTER_PREFIX.length);
3080
+ if (this.sct.stringToIdOrZero(scheme) === 0n) {
3081
+ throw new Error(`Invalid identifier scheme: ${scheme}`);
3082
+ }
3083
+ if (op !== 'exists') {
3084
+ throw new Error(`Unsupported filter operation: ${prop} ${op} ${value}`);
3085
+ }
3086
+ if (value !== 'true' && value !== 'false') {
3087
+ throw new Error(`Invalid filter value: ${value}`);
3088
+ }
3089
+ const filter = new SnomedFilterContext();
3090
+ filter.mappedScheme = scheme;
3091
+ filter.mappedExists = value === 'true';
3092
+ filterContext.filters.push(filter);
3093
+ return null;
3094
+ }
3095
+
2313
3096
  const cid = this.sct.stringToIdOrZero(prop);
2314
3097
  if (cid != 0) {
2315
3098
 
@@ -2413,7 +3196,9 @@ class SnomedProvider extends BaseCSServices {
2413
3196
  }
2414
3197
  let found = false;
2415
3198
 
2416
- if (set.inactive !== undefined) {
3199
+ if (set.mappedScheme !== undefined) {
3200
+ found = this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
3201
+ } else if (set.inactive !== undefined) {
2417
3202
  let concept = this.sct.concepts.getConcept(reference);
2418
3203
  let active = (concept.flags & 0x0F) === 0;
2419
3204
  found = active !== set.inactive
@@ -2459,6 +3244,9 @@ class SnomedProvider extends BaseCSServices {
2459
3244
  }
2460
3245
 
2461
3246
  const reference = concept.getReference();
3247
+ if (set.mappedScheme !== undefined) {
3248
+ return this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
3249
+ }
2462
3250
  if (set.inactive !== undefined) {
2463
3251
  return this.sct.isActive(reference) !== set.inactive;
2464
3252
  }
@@ -2518,10 +3306,39 @@ class SnomedProvider extends BaseCSServices {
2518
3306
  return set._matchSet.has(reference);
2519
3307
  }
2520
3308
 
3309
+ /** Whether the concept (by reference) has an alternate identifier in the scheme */
3310
+ #isMapped(reference, scheme) {
3311
+ return this.sct.getIdentifiers(this.sct.getConceptId(reference), scheme).length > 0;
3312
+ }
3313
+
2521
3314
  #ensurePopulated(set) {
2522
3315
  if (set.populationDone) {
2523
3316
  return;
2524
3317
  }
3318
+ if (set.mappedScheme !== undefined) {
3319
+ if (set.mappedExists) {
3320
+ // straight from the identifier index, in concept order
3321
+ const refs = new Set();
3322
+ for (const [conceptId, idents] of this.sct.identifiersByConcept) {
3323
+ if (idents.some(i => i.scheme === set.mappedScheme)) {
3324
+ const r = this.sct.concepts.findConcept(BigInt(conceptId));
3325
+ if (r.found) {
3326
+ refs.add(r.index);
3327
+ }
3328
+ }
3329
+ }
3330
+ set.descendants = [...refs].sort((a, b) => a - b);
3331
+ } else {
3332
+ for (let i = 0; i < this.sct.concepts.count(); i++) {
3333
+ const concept = this.sct.concepts.getConceptByCount(i);
3334
+ if (!this.#isMapped(concept.index, set.mappedScheme)) {
3335
+ set.descendants.push(concept.index);
3336
+ }
3337
+ }
3338
+ }
3339
+ set.populationDone = true;
3340
+ return;
3341
+ }
2525
3342
  if (set.inactive !== undefined && set.descendants.length === 0) {
2526
3343
  for (let i = 0; i < this.sct.concepts.count(); i++) {
2527
3344
  let concept = this.sct.concepts.getConceptByCount(i);
@@ -2673,7 +3490,16 @@ class SnomedProvider extends BaseCSServices {
2673
3490
  }
2674
3491
 
2675
3492
  async getTranslations(map, coding, target, reverse) {
2676
- if (!map || (target && target !== this.system()) || reverse) {
3493
+ if (map && map.jsonObj.identifierMap) {
3494
+ return this.translateByIdentifier(map, coding, target);
3495
+ }
3496
+ if (!map || reverse) {
3497
+ return [];
3498
+ }
3499
+ if (map.jsonObj.editionTranslation) {
3500
+ return this.translateToEdition(map, coding);
3501
+ }
3502
+ if (target && target !== this.system()) {
2677
3503
  return [];
2678
3504
  }
2679
3505
  let ref = this.sct.concepts.findConcept(map.id);
@@ -2730,6 +3556,181 @@ class SnomedProvider extends BaseCSServices {
2730
3556
  return result;
2731
3557
  }
2732
3558
 
3559
+ /**
3560
+ * Translate using a module's alternate identifiers (the implicit map ?fhir_cm=<module>,
3561
+ * e.g. the LOINC Extension's LOINC codes). The identifiers say the concept and the code
3562
+ * are the same thing, so the map works the same in both directions: a SNOMED CT concept
3563
+ * translates to its codes, and a code in the identifier's system to its concepts. Either
3564
+ * way the relationship is equivalent.
3565
+ */
3566
+ async translateByIdentifier(map, coding, target) {
3567
+ const { schemes } = map.jsonObj.identifierMap;
3568
+ const targetSystem = target ? target.split('|')[0] : null;
3569
+ const result = [];
3570
+ if (coding.system === this.system()) {
3571
+ const ctxt = await this.locate(coding.code);
3572
+ if (!ctxt.context) {
3573
+ return [];
3574
+ }
3575
+ const conceptId = this.sct.getConceptId(ctxt.context.getReference());
3576
+ for (const ident of this.sct.getIdentifiers(conceptId)) {
3577
+ const scheme = IDENTIFIER_SCHEMES[ident.scheme];
3578
+ if (scheme && schemes.includes(ident.scheme) && (!targetSystem || targetSystem === scheme.system)) {
3579
+ result.push({
3580
+ map: map.vurl,
3581
+ system: scheme.system,
3582
+ code: ident.code,
3583
+ relationship: 'equivalent'
3584
+ });
3585
+ }
3586
+ }
3587
+ } else if (!targetSystem || targetSystem === this.system()) {
3588
+ for (const schemeId of schemes) {
3589
+ const scheme = IDENTIFIER_SCHEMES[schemeId];
3590
+ if (scheme && scheme.system === coding.system) {
3591
+ for (const conceptId of this.sct.getConceptsForIdentifier(schemeId, coding.code)) {
3592
+ result.push({
3593
+ map: map.vurl,
3594
+ system: this.system(),
3595
+ version: this.version(),
3596
+ code: conceptId,
3597
+ display: await this.display(conceptId),
3598
+ relationship: 'equivalent'
3599
+ });
3600
+ }
3601
+ }
3602
+ }
3603
+ }
3604
+ return result;
3605
+ }
3606
+
3607
+ /**
3608
+ * Translate a concept of this edition to the edition it depends on (typically a national
3609
+ * extension to the International Edition). There is no map: the answer comes from the
3610
+ * definitions.
3611
+ * - a concept in one of the target edition's modules is its own translation
3612
+ * - an inactive concept translates via its historical associations (REPLACED BY, SAME AS,
3613
+ * POSSIBLY EQUIVALENT TO, ALTERNATIVE) to concepts in the target edition
3614
+ * - a fully defined extension concept translates to its normal form, provided every concept
3615
+ * in it belongs to the target edition; if the target edition has a concept with that same
3616
+ * normal form, to that concept
3617
+ * - otherwise (a primitive extension concept, or a definition that uses other extension
3618
+ * content) to its proximal ancestors in the target edition, which it is narrower than -
3619
+ * unless the only such ancestors are top-level hierarchies (or the root), which carry no
3620
+ * information: that is reported as a reason rather than a translation
3621
+ */
3622
+ async translateToEdition(map, coding) {
3623
+ const ed = map.jsonObj.editionTranslation;
3624
+ const sct = this.sct;
3625
+ const src = sct.concepts.findConcept(coding.code);
3626
+ if (!src.found) {
3627
+ return [];
3628
+ }
3629
+ const inTarget = (ref) => ed.modules.has(sct.concepts.getModuleId(ref));
3630
+ const mk = (code, display, relationship) => ({
3631
+ map: map.vurl, system: this.system(), version: ed.version, code, display, relationship
3632
+ });
3633
+ const named = (ref) => `${sct.getConceptId(ref)} |${sct.getDisplayName(ref, sct.defaultLanguage)}|`;
3634
+
3635
+ if (inTarget(src.index)) {
3636
+ return [mk(sct.getConceptId(src.index), sct.getDisplayName(src.index, sct.defaultLanguage), 'equivalent')];
3637
+ }
3638
+
3639
+ if (!sct.isActive(src.index)) {
3640
+ const result = [];
3641
+ for (const [refset, relationship] of [['900000000000527005', 'equivalent'], ['900000000000526001', 'equivalent'],
3642
+ ['900000000000523009', 'related-to'], ['900000000000530003', 'related-to']]) {
3643
+ for (const tgt of this.#historicalAssociations(refset, src.index)) {
3644
+ if (inTarget(tgt)) {
3645
+ result.push(mk(sct.getConceptId(tgt), sct.getDisplayName(tgt, sct.defaultLanguage), relationship));
3646
+ }
3647
+ }
3648
+ }
3649
+ return result;
3650
+ }
3651
+
3652
+ if (!sct.isPrimitive(src.index)) {
3653
+ const nf = sct.expressionServices.createNormalForm(src.index);
3654
+ if (this.#expressionConcepts(nf).every(inTarget)) {
3655
+ const same = sct.conceptWithNormalForm(sct.expressionServices.renderExpression(nf, SnomedServicesRenderOption.Minimal));
3656
+ if (same >= 0 && same !== src.index && inTarget(same)) {
3657
+ return [mk(sct.getConceptId(same), sct.getDisplayName(same, sct.defaultLanguage), 'equivalent')];
3658
+ }
3659
+ // the canonical form self-groups every attribute; for output, never-grouped ones go back ungrouped
3660
+ const out = sct.expressionServices.ungroupNeverGroupedRefinements(nf);
3661
+ return [mk(sct.expressionServices.renderExpression(out, SnomedServicesRenderOption.Minimal),
3662
+ sct.expressionServices.renderExpression(out, SnomedServicesRenderOption.FillMissing), 'equivalent')];
3663
+ }
3664
+ }
3665
+
3666
+ const ancestors = this.#proximalAncestorsIn(src.index, inTarget);
3667
+ if (ancestors.length === 0) {
3668
+ return [];
3669
+ }
3670
+ const useful = ancestors.filter(a => sct.concepts.getDepth(a) >= 2);
3671
+ if (useful.length === 0) {
3672
+ const list = ancestors.map(named);
3673
+ const msgId = list.length === 1 ? 'TRANSLATE_SCT_EDITION_TOP_LEVEL_ONE' : 'TRANSLATE_SCT_EDITION_TOP_LEVEL_MANY';
3674
+ return [{ message: this.opContext.i18n.translate(msgId, this.opContext.langs,
3675
+ [getEditionName(ed.moduleId), named(src.index), list.join(', ')]) }];
3676
+ }
3677
+ return useful.map(a => mk(sct.getConceptId(a), sct.getDisplayName(a, sct.defaultLanguage), 'source-is-narrower-than-target'));
3678
+ }
3679
+
3680
+ /** The target components of the given association reference set for a concept */
3681
+ #historicalAssociations(refsetId, conceptIndex) {
3682
+ const sct = this.sct;
3683
+ const rs = sct.concepts.findConcept(refsetId);
3684
+ if (!rs.found) return [];
3685
+ const rref = sct.refSetIndex.getRefSetByConcept(rs.index);
3686
+ if (rref === -1) return [];
3687
+ const members = sct.refSetMembers.getMembers(sct.refSetIndex.getReferenceSet(rref).membersByRef);
3688
+ const result = [];
3689
+ for (const m of members) {
3690
+ if (m.ref !== conceptIndex) continue;
3691
+ const values = m.values ? sct.refs.getReferences(m.values) : null;
3692
+ if (values && values.length >= 1 && values[0]) {
3693
+ result.push(values[0]);
3694
+ }
3695
+ }
3696
+ return result;
3697
+ }
3698
+
3699
+ /** Every concept reference used anywhere in an expression */
3700
+ #expressionConcepts(exp, into = []) {
3701
+ for (const c of exp.concepts) {
3702
+ if (c.reference !== NO_REFERENCE) into.push(c.reference);
3703
+ }
3704
+ const refinements = [...exp.refinements, ...exp.refinementGroups.flatMap(g => g.refinements)];
3705
+ for (const r of refinements) {
3706
+ if (r.name && r.name.reference !== NO_REFERENCE) into.push(r.name.reference);
3707
+ if (r.value) this.#expressionConcepts(r.value, into);
3708
+ }
3709
+ return into;
3710
+ }
3711
+
3712
+ /**
3713
+ * The nearest ancestors of a concept that satisfy the test: a parent that does is taken
3714
+ * and not looked beyond; one that does not is replaced by its own parents.
3715
+ */
3716
+ #proximalAncestorsIn(conceptIndex, test) {
3717
+ const result = [];
3718
+ const seen = new Set();
3719
+ const todo = [...this.sct.expressionServices.getConceptParents(conceptIndex)];
3720
+ while (todo.length) {
3721
+ const p = todo.shift();
3722
+ if (seen.has(p)) continue;
3723
+ seen.add(p);
3724
+ if (test(p)) {
3725
+ result.push(p);
3726
+ } else {
3727
+ todo.push(...this.sct.expressionServices.getConceptParents(p));
3728
+ }
3729
+ }
3730
+ // an ancestor that is itself subsumed by another in the list is not proximal
3731
+ return result.filter(a => !result.some(b => b !== a && this.sct.subsumes(a, b)));
3732
+ }
3733
+
2733
3734
  hasMultiHierarchy() {
2734
3735
  return true;
2735
3736
  }
@@ -2979,11 +3980,140 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
2979
3980
  return edition + ' ' + formatDateMMDDYYYY(match[2].substring(4, 6) + match[2].substring(6, 8) + match[2].substring(0, 4));
2980
3981
  }
2981
3982
 
3983
+ /**
3984
+ * A translation from this edition to an edition it depends on needs no map: the answer
3985
+ * comes from the definitions (see SnomedProvider.translateToEdition). The request names
3986
+ * the source by its edition URI (with or without a version) and the target as
3987
+ * system|edition URI (with or without a version); the map's url is the versioned source
3988
+ * edition URI plus ?fhir_cm=edition/<target edition module>
3989
+ */
3990
+ async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest) {
3991
+ if (dest) {
3992
+ this.#findIdentifierMaps(conceptMaps, source, sourceVersion, dest);
3993
+ }
3994
+ if (source !== this.system() || !sourceVersion || !dest || !this.isMyEdition(sourceVersion)) {
3995
+ return;
3996
+ }
3997
+ const bar = dest.indexOf('|');
3998
+ if (bar === -1 || dest.substring(0, bar) !== this.system()) {
3999
+ return;
4000
+ }
4001
+ const dep = this.snomedServices.editionDependency(dest.substring(bar + 1));
4002
+ if (dep) {
4003
+ conceptMaps.push(this.editionTranslationMap(dep));
4004
+ }
4005
+ }
4006
+
4007
+ /**
4008
+ * The implicit map from this edition to an edition it depends on. Its url is the one
4009
+ * agreed with SNOMED International: <source edition>/version/<date>?fhir_cm=edition/<target module>
4010
+ * (the source always versioned, whatever the request said)
4011
+ */
4012
+ editionTranslationMap(dep) {
4013
+ return new ConceptMap({
4014
+ resourceType: 'ConceptMap',
4015
+ internalSource: this,
4016
+ editionTranslation: dep,
4017
+ url: `${this.version()}?fhir_cm=edition/${dep.moduleId}`,
4018
+ name: `SNOMED CT ${this.describeVersion(this.version())} to ${getEditionName(dep.moduleId)} Concept Map`,
4019
+ description: `The translation implied by the concept definitions of the edition ${this.version()} to the ${getEditionName(dep.moduleId)} that it depends on`,
4020
+ status: 'active',
4021
+ group: [{
4022
+ source: this.system(),
4023
+ target: this.system()
4024
+ }]
4025
+ });
4026
+ }
4027
+
4028
+ /**
4029
+ * Maps implied by alternate identifiers, found from the systems rather than nominated:
4030
+ * from this edition (source system|version naming it) to an identifier system (LOINC,
4031
+ * say), or from an identifier system to this edition (the target given as
4032
+ * system|edition URI, with or without the version)
4033
+ */
4034
+ #findIdentifierMaps(conceptMaps, source, sourceVersion, dest) {
4035
+ const bar = dest.indexOf('|');
4036
+ const destSystem = bar === -1 ? dest : dest.substring(0, bar);
4037
+ const destVersion = bar === -1 ? null : dest.substring(bar + 1);
4038
+ let identSystem;
4039
+ if (source === this.system() && sourceVersion && this.isMyEdition(sourceVersion)) {
4040
+ identSystem = destSystem;
4041
+ } else if (destSystem === this.system() && destVersion && this.isMyEdition(destVersion)) {
4042
+ identSystem = source;
4043
+ } else {
4044
+ return;
4045
+ }
4046
+ for (const [moduleId, schemes] of this.snomedServices.identifierModules) {
4047
+ if ([...schemes].some(s => IDENTIFIER_SCHEMES[s] && IDENTIFIER_SCHEMES[s].system === identSystem)) {
4048
+ const cm = this.identifierMap(moduleId);
4049
+ if (cm && !conceptMaps.some(m => m.url === cm.url)) {
4050
+ conceptMaps.push(cm);
4051
+ }
4052
+ }
4053
+ }
4054
+ }
4055
+
4056
+ /**
4057
+ * The implicit map defined by a module's alternate identifiers, if the module has any
4058
+ * in a scheme we know. Its url follows the edition maps: <source edition>/version/<date>?fhir_cm=<module>
4059
+ */
4060
+ identifierMap(moduleId) {
4061
+ const schemes = [...(this.snomedServices.identifierModules.get(moduleId) || [])].filter(s => IDENTIFIER_SCHEMES[s]);
4062
+ if (schemes.length === 0) {
4063
+ return null;
4064
+ }
4065
+ const systems = schemes.map(s => IDENTIFIER_SCHEMES[s].system);
4066
+ const moduleName = this.snomedServices.conceptExists(moduleId)
4067
+ ? this.snomedServices.getDisplayName(this.snomedServices.concepts.findConcept(BigInt(moduleId)).index) : moduleId;
4068
+ return new ConceptMap({
4069
+ resourceType: 'ConceptMap',
4070
+ internalSource: this,
4071
+ identifierMap: { module: moduleId, schemes: schemes },
4072
+ url: `${this.version()}?fhir_cm=${moduleId}`,
4073
+ name: `SNOMED CT ${moduleName} Concept Map`,
4074
+ description: `The concept map implied by the alternate identifiers (${systems.join(', ')}) of the module ${moduleId} |${moduleName}| in ${this.version()}`,
4075
+ status: 'active',
4076
+ group: [
4077
+ ...systems.map(s => ({ source: this.system(), target: s })),
4078
+ ...systems.map(s => ({ source: s, target: this.system() }))
4079
+ ]
4080
+ });
4081
+ }
4082
+
4083
+ /** Whether a source version names this edition: exactly, or the edition without a version */
4084
+ isMyEdition(version) {
4085
+ const mine = this.version();
4086
+ return version === mine || mine.startsWith(version + '/version/');
4087
+ }
4088
+
2982
4089
  async findImplicitConceptMap(url, version) {
2983
4090
  if (version && (version !== this.version())) {
2984
4091
  return null;
2985
4092
  }
2986
- if (!url || !url.startsWith(this.system()+"?fhir_cm=")) {
4093
+ if (!url) {
4094
+ return null;
4095
+ }
4096
+ // <edition[/version/date]>?fhir_cm=edition/<module>: this edition's definition-based
4097
+ // translation to an edition it depends on, nominated by url
4098
+ const q = url.indexOf('?fhir_cm=edition/');
4099
+ if (q !== -1) {
4100
+ if (!this.isMyEdition(url.substring(0, q))) {
4101
+ return null;
4102
+ }
4103
+ const dep = this.snomedServices.editionDependency(`http://snomed.info/sct/${url.substring(q + '?fhir_cm=edition/'.length)}`);
4104
+ return dep ? this.editionTranslationMap(dep) : null;
4105
+ }
4106
+ // <edition[/version/date]>?fhir_cm=<module>: the map implied by a module's alternate
4107
+ // identifiers (e.g. 11010000107, the LOINC Extension's LOINC codes)
4108
+ const qm = url.indexOf('?fhir_cm=');
4109
+ if (qm !== -1 && url.substring(0, qm) !== this.system()) {
4110
+ if (!this.isMyEdition(url.substring(0, qm))) {
4111
+ return null;
4112
+ }
4113
+ await this.#ensureLoaded();
4114
+ return this.identifierMap(url.substring(qm + '?fhir_cm='.length));
4115
+ }
4116
+ if (!url.startsWith(this.system()+"?fhir_cm=")) {
2987
4117
  return null;
2988
4118
  }
2989
4119
  let id = url.substring(url.indexOf("=")+1);