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
package/tx/vs/vs-vsac.js CHANGED
@@ -26,7 +26,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
26
26
  * @param {string} config.apiKey - API key for VSAC authentication
27
27
  * @param {string} config.cacheFolder - Local folder for cached database
28
28
  * @param {number} [config.refreshIntervalHours=24] - Hours between refresh scans
29
- * @param {string} [config.baseUrl='http://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server
29
+ * @param {string} [config.baseUrl='https://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server
30
30
  * @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds
31
31
  * @param {string} [config.resyncPassword] - If set, enables the operator "resync a ValueSet"
32
32
  * form on the /info page, gated by this password. If unset, the form is not offered.
@@ -43,7 +43,12 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
43
43
  // Optional operator password for the on-demand resync form (see info()).
44
44
  this.resyncPassword = config.resyncPassword || null;
45
45
  this.cacheFolder = folders.ensureFilePath("terminology-cache/vsac");
46
- this.baseUrl = config.baseUrl || 'http://cts.nlm.nih.gov/fhir';
46
+ // https: every request carries the API key. (VSAC redirects http to https, but only after
47
+ // the key has been sent in the clear)
48
+ this.baseUrl = (config.baseUrl || 'https://cts.nlm.nih.gov/fhir').replace(/\/+$/, '');
49
+ if (this.baseUrl.startsWith('http://')) {
50
+ console.warn(`VSAC: baseUrl ${this.baseUrl} is http, so the API key is sent unencrypted - use https`);
51
+ }
47
52
  this.refreshIntervalHours = config.refreshIntervalHours || 24;
48
53
 
49
54
  this.dbPath = path.join(this.cacheFolder, 'vsac-valuesets.db');
@@ -397,10 +402,24 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
397
402
  return null;
398
403
  }
399
404
 
400
- // Extract relative path from full URL
401
- let s = nextLink.url;
402
- s = s.replace(this.baseUrl, '');
403
- return s;
405
+ // Followed only on the VSAC server itself, as a path under baseUrl: the request carries the
406
+ // API key, so a next link must not be able to send it anywhere else. The scheme is ignored -
407
+ // VSAC's links can say http - and the request goes out on baseUrl's
408
+ let next;
409
+ let base;
410
+ try {
411
+ base = new URL(this.baseUrl);
412
+ next = new URL(nextLink.url, this.baseUrl + '/');
413
+ } catch (e) {
414
+ console.warn(`VSAC: ignoring next link ${String(nextLink.url).substring(0, 200)}: ${e.message}`);
415
+ return null;
416
+ }
417
+ const basePath = base.pathname.replace(/\/+$/, '');
418
+ if (next.host !== base.host || !(next.pathname === basePath || next.pathname.startsWith(basePath + '/'))) {
419
+ console.warn(`VSAC: ignoring next link ${String(nextLink.url).substring(0, 200)}: it is not on ${this.baseUrl}`);
420
+ return null;
421
+ }
422
+ return next.pathname.substring(basePath.length) + next.search;
404
423
  }
405
424
 
406
425
  /**
@@ -17,6 +17,12 @@
17
17
  // already exists, unless reset = true, which wipes it
18
18
  // name + concept(s) add concepts; returns the entries they produce
19
19
  // name + version resync; returns every entry added after version
20
+ // name + dispose = true the client has finished with the table, so it is
21
+ // deleted. Not in the specification: the client is
22
+ // just telling the server it can let the table go.
23
+ // Disposing of a table that doesn't exist is fine
24
+ // (it may already have been pruned). It can't be
25
+ // combined with anything else
20
26
  //
21
27
  // The configuration is fixed when the table is initialised, and replayed on every later
22
28
  // call: tx-resource (the code systems it is built over, which the client does not send
@@ -147,6 +153,7 @@ class ClosureWorker extends TerminologyWorker {
147
153
  const config = [];
148
154
  let versionParam = null;
149
155
  let reset = false;
156
+ let dispose = false;
150
157
  const ignored = [];
151
158
  for (const p of list) {
152
159
  if (p.name === 'concept') {
@@ -157,6 +164,8 @@ class ClosureWorker extends TerminologyWorker {
157
164
  versionParam = p;
158
165
  } else if (p.name === 'reset') {
159
166
  reset = p.valueBoolean === true || p.valueString === 'true';
167
+ } else if (p.name === 'dispose') {
168
+ dispose = p.valueBoolean === true || p.valueString === 'true';
160
169
  } else if (p.name !== 'name') {
161
170
  // Ignored, as everywhere else in the server: a request carries parameters that
162
171
  // are not for the operation in hand. The tx-ecosystem runner, for one, adds a
@@ -167,6 +176,17 @@ class ClosureWorker extends TerminologyWorker {
167
176
  if (ignored.length > 0) {
168
177
  this.log.debug(`$closure: ignored parameter(s) ${ignored.map(safeText).join(', ')}`);
169
178
  }
179
+ if (dispose) {
180
+ if (concepts.length > 0 || versionParam || reset || config.length > 0) {
181
+ throw this.closureIssue('invalid', 'CLOSURE_DISPOSE_NOT_ALONE', [], 'invalid-data', 400);
182
+ }
183
+ return await this.lockTable(name, async () => {
184
+ if (this.store.dropTable(name)) {
185
+ this.log.info(`closure table '${name}' disposed`);
186
+ }
187
+ return await this.buildConceptMap(name, 0, [], fhirVersion);
188
+ });
189
+ }
170
190
  if (concepts.length > 0 && versionParam) {
171
191
  throw this.closureIssue('invalid', 'CLOSURE_CONCEPT_AND_VERSION', [], 'invalid-data', 400);
172
192
  }
@@ -439,8 +439,8 @@ class ValueSetExpander {
439
439
  if (!n.designation) {
440
440
  n.designation = [];
441
441
  }
442
- if (t.source) {
443
- this.reportedSupplements.add(t.source);
442
+ if (t.supplement && t.supplement.vurl) {
443
+ this.reportedSupplements.add(t.supplement.vurl);
444
444
  }
445
445
  n.designation.push(t.asObject());
446
446
  }
@@ -458,6 +458,19 @@ class ValueSetExpander {
458
458
  }
459
459
  }
460
460
 
461
+ // properties the code system always reports (ICD-11's classificationCode), unless
462
+ // the request asked for them anyway and the loops here add them
463
+ if (csProps != null && cs != null && !this.params.properties.includes('*')) {
464
+ for (const pn of cs.mandatoryExpansionProperties()) {
465
+ if (!this.params.properties.includes(pn)) {
466
+ for (const cp of csProps.filter(p => p.code === pn)) {
467
+ const vn = getValueName(cp);
468
+ this.defineProperty(expansion, n, this.getPropUrl(cs, pn, cp), pn, vn, cp[vn], true);
469
+ }
470
+ }
471
+ }
472
+ }
473
+
461
474
  for (const pn of this.params.properties) {
462
475
  if (pn === '*') {
463
476
  continue; // done above
@@ -890,8 +903,9 @@ class ValueSetExpander {
890
903
  const cctxt = await cs.locate(cc.code, this.allAltCodes);
891
904
  if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt.context)) && await this.passesFilters(cs, cctxt.context, prep, filters, 0)) {
892
905
  let added;
906
+ const ecode = await cs.expansionCode(cctxt.context, cc.code);
893
907
  if (this.noDetails) {
894
- added = await this.includeCode(cs, null, cs.system(), cs.version(), cc.code, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), null, null, cds,
908
+ added = await this.includeCode(cs, null, cs.system(), cs.version(), ecode, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), null, null, cds,
895
909
  null, null, expansion, valueSets, null, null, null, null, excludeInactive, vsSrc.url);
896
910
  } else {
897
911
  await this.listDisplaysFromProvider(cds, cs, cctxt.context);
@@ -901,7 +915,7 @@ class ValueSetExpander {
901
915
  if (!ov) {
902
916
  ov = await cs.itemWeight(cctxt.context);
903
917
  }
904
- added = await this.includeCode(cs, null, cs.system(), cs.version(), cc.code, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), await cs.isDeprecated(cctxt.context), await cs.getStatus(cctxt.context), cds,
918
+ added = await this.includeCode(cs, null, cs.system(), cs.version(), ecode, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), await cs.isDeprecated(cctxt.context), await cs.getStatus(cctxt.context), cds,
905
919
  await cs.definition(cctxt.context), ov, expansion, valueSets, await cs.extensions(cctxt.context), cc.extension, await cs.properties(cctxt.context), null, excludeInactive, vsSrc.url);
906
920
  }
907
921
  if (added) {
@@ -939,16 +953,29 @@ class ValueSetExpander {
939
953
  }
940
954
 
941
955
  const fset = await cs.executeFilters(prep);
942
- if (await cs.filtersNotClosed(prep)) {
956
+ const filtersOpen = await cs.filtersNotClosed(prep);
957
+ if (filtersOpen) {
943
958
  notClosed.value = true;
944
- } else if (fset.length === 1 && !excludeInactive && !this.params.activeOnly) {
945
- // this.addToTotal(await cs.filterSize(prep, fset[0]));
959
+ }
960
+
961
+ // count=0 asks only for the total. When this include is the whole value set and its one
962
+ // filter set is the whole answer - nothing else to add, overlap, exclude or skip - the
963
+ // total is the set's size, and walking it would only end in VALUESET_TOO_COSTLY once
964
+ // the set is bigger than the limit. Anything less certain is counted by walking, as
965
+ // before: an earlier version added filterSize *and* walked, and double counted (#91, #93).
966
+ const countOnly = this.count === 0 && !filtersOpen && fset.length === 1 && filter.isNull
967
+ && !excludeInactive && !this.params.activeOnly && !this.hasExclusions
968
+ && !(cset.concept && cset.concept.length) && !(cset.valueSet && cset.valueSet.length)
969
+ && (compose.include || []).length === 1 && (compose.exclude || []).length === 0;
970
+ if (countOnly) {
971
+ this.worker.opContext.log('count only: the filter size is the total');
972
+ this.addToTotal(await cs.filterSize(prep, fset[0]));
946
973
  }
947
974
 
948
975
  this.worker.opContext.log('iterate filters');
949
976
  this.addToTotal(0);
950
977
  const cds = new Designations(this.worker.i18n.languageDefinitions);
951
- while (await cs.filterMore(prep, fset[0])) {
978
+ while (!countOnly && await cs.filterMore(prep, fset[0])) {
952
979
  await this.worker.checkAndYield('processCodes#5');
953
980
  const c = await cs.filterConcept(prep, fset[0]);
954
981
  const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1));
@@ -15,6 +15,7 @@ const {Parameters} = require("../library/parameters");
15
15
  const { Issue, OperationOutcome, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
16
16
  const {debugLog} = require("../operation-context");
17
17
  const {checkAddedParameters} = require("../library/parameter-check");
18
+ const { Languages } = require("../../library/languages");
18
19
 
19
20
  class LookupWorker extends TerminologyWorker {
20
21
  /**
@@ -204,7 +205,7 @@ class LookupWorker extends TerminologyWorker {
204
205
  const supplements = this.loadSupplements(codeSystem.url, codeSystem.version, txp.supplements);
205
206
 
206
207
  // Create a FhirCodeSystemProvider for this CodeSystem
207
- const csProvider = new FhirCodeSystemProvider(this.opContext, codeSystem, supplements);
208
+ const csProvider = FhirCodeSystemProvider.create(this.opContext, codeSystem, supplements);
208
209
 
209
210
  // Perform the lookup
210
211
  const result = await this.doLookup(csProvider, code, txp);
@@ -247,6 +248,11 @@ class LookupWorker extends TerminologyWorker {
247
248
  );
248
249
  };
249
250
 
251
+ // Explicitly asked for: named in the property list, or '*'. An empty list is not asking for
252
+ // anything in particular (the server chooses), which is not the same thing for abstract and
253
+ // notSelectable - see below.
254
+ const asked = (name) => (params.properties || []).some(p => p === '*' || p.toLowerCase() === name.toLowerCase());
255
+
250
256
  // Locate the code in the code system
251
257
  const locateResult = await csProvider.locate(code);
252
258
 
@@ -271,7 +277,7 @@ class LookupWorker extends TerminologyWorker {
271
277
  // name (required)
272
278
  responseParams.push({
273
279
  name: 'name',
274
- valueString: csProvider.name(params.workingLanguages())
280
+ valueString: csProvider.lookupName(params.workingLanguages())
275
281
  });
276
282
  responseParams.push({
277
283
  name: 'code',
@@ -303,6 +309,32 @@ class LookupWorker extends TerminologyWorker {
303
309
  valueString: disp || display || code
304
310
  });
305
311
 
312
+ // A displayLanguage the code system has no display in, for this concept, still gets an
313
+ // answer - in the default language - but the client is told so: a display in a language it
314
+ // did not ask for, offered without comment, reads as an answer in the language it did.
315
+ // Only for an explicit displayLanguage: Accept-Language is a browser's standing preference,
316
+ // not a request about this code.
317
+ if (params.hasDisplayLanguages) {
318
+ // the languages actually asked for: displayLanguage is parsed with a wildcard appended,
319
+ // and the wildcard matches whatever display there is
320
+ const langs = new Languages(this.languages);
321
+ for (const l of params.workingLanguages()) {
322
+ if (l.code !== '*') langs.add(l);
323
+ }
324
+ const covered = langs.length === 0 || (designations.designations.length > 0
325
+ ? designations.hasAnyDisplays(langs)
326
+ : csProvider.hasAnyDisplays(langs));
327
+ if (!covered) {
328
+ const used = (pd && pd.language) ? String(pd.language) : (csProvider.defLang() || 'en');
329
+ const csDesc = version ? csProvider.system() + '|' + version : csProvider.system();
330
+ responseParams.push({
331
+ name: 'warning',
332
+ valueString: this.i18n.translate('LOOKUP_DISPLAY_LANGUAGE_FALLBACK', params.HTTPLanguages,
333
+ [langs.asString(false), code, csDesc, used])
334
+ });
335
+ }
336
+ }
337
+
306
338
  // definition (optional) - top-level parameter
307
339
  if (hasProp('definition', true)) {
308
340
  const definition = await csProvider.definition(ctxt);
@@ -314,9 +346,12 @@ class LookupWorker extends TerminologyWorker {
314
346
  }
315
347
  }
316
348
 
317
- // abstract property (optional)
318
- if (hasProp('abstract', true)) {
319
- const isAbstract = await csProvider.isAbstract(ctxt);
349
+ // abstract and notSelectable (agreed for the tx-ecosystem tests, October 2026): a concept that
350
+ // is not selectable always says so, both ways, whatever properties were asked for - that is
351
+ // the fact a client most needs about it. A selectable one says neither unless asked.
352
+ // notSelectable is reconciled after the provider has added its properties, below.
353
+ const isAbstract = await csProvider.isAbstract(ctxt);
354
+ if (isAbstract || asked('abstract')) {
320
355
  responseParams.push({
321
356
  name: 'abstract',
322
357
  valueBoolean: isAbstract
@@ -389,7 +424,7 @@ class LookupWorker extends TerminologyWorker {
389
424
  // primitive belongs is a provider bug, and shipping it would produce a
390
425
  // response that isn't valid FHIR (and, in XML, isn't even parseable).
391
426
  const providerParamsFrom = responseParams.length;
392
- await csProvider.extendLookup(ctxt, params.properties || [], responseParams);
427
+ await csProvider.extendLookup(ctxt, params.properties || [], responseParams, params);
393
428
  let providerName;
394
429
  try {
395
430
  providerName = csProvider.system();
@@ -398,6 +433,26 @@ class LookupWorker extends TerminologyWorker {
398
433
  }
399
434
  checkAddedParameters(responseParams, providerParamsFrom, providerName);
400
435
 
436
+ // notSelectable, the property: present (true) for every concept that is not selectable;
437
+ // for a selectable one only when asked for (false), and taken out if a provider added it
438
+ // anyway
439
+ const isNotSelectable = (p) => p.name === 'property' &&
440
+ (p.part || []).some(x => x.name === 'code' && x.valueCode === 'notSelectable');
441
+ const nsAt = responseParams.findIndex(isNotSelectable);
442
+ if (isAbstract) {
443
+ if (nsAt < 0) {
444
+ responseParams.push({ name: 'property', part: [
445
+ { name: 'code', valueCode: 'notSelectable' }, { name: 'value', valueBoolean: true }] });
446
+ }
447
+ } else if (asked('notSelectable')) {
448
+ if (nsAt < 0) {
449
+ responseParams.push({ name: 'property', part: [
450
+ { name: 'code', valueCode: 'notSelectable' }, { name: 'value', valueBoolean: false }] });
451
+ }
452
+ } else if (nsAt >= 0) {
453
+ responseParams.splice(nsAt, 1);
454
+ }
455
+
401
456
  if (reportedSupplements) {
402
457
  for (const supplement of reportedSupplements) {
403
458
  responseParams.push({
@@ -177,7 +177,7 @@ class SubsumesWorker extends TerminologyWorker {
177
177
  const supplements = this.loadSupplements(codeSystem.url, codeSystem.version, txp.supplements);
178
178
 
179
179
  // Create a FhirCodeSystemProvider for this CodeSystem
180
- const csProvider = new FhirCodeSystemProvider(this.opContext, codeSystem, supplements);
180
+ const csProvider = FhirCodeSystemProvider.create(this.opContext, codeSystem, supplements);
181
181
 
182
182
  // Get the codings
183
183
  let codingA, codingB;
@@ -26,6 +26,10 @@ function elementComment(em) {
26
26
  return em.comment || Extensions.readString(em, EXT_ELEMENT_COMMENT);
27
27
  }
28
28
 
29
+ // marks a match whose target came from the code system itself (translateUsingCodeSystem), so
30
+ // checkTargetConcepts leaves it alone. A symbol key, so it never reaches the JSON output
31
+ const TRUSTED_TARGET = Symbol('trustedTarget');
32
+
29
33
  class TranslateWorker extends TerminologyWorker {
30
34
  /**
31
35
  * @param {OperationContext} opContext - Operation context
@@ -281,10 +285,10 @@ class TranslateWorker extends TerminologyWorker {
281
285
  if (conceptMaps.length == 0) {
282
286
  if (reverse) {
283
287
  await this.findConceptMapsInAdditionalResources(conceptMaps,targetSystem, targetScope, sourceScope, coding.system);
284
- await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, targetSystem, targetScope, sourceScope, coding.system, coding.code);
288
+ await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, targetSystem, null, targetScope, sourceScope, coding.system, coding.code);
285
289
  } else {
286
290
  await this.findConceptMapsInAdditionalResources(conceptMaps, coding.system, sourceScope, targetScope, targetSystem);
287
- await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code);
291
+ await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, coding.version, sourceScope, targetScope, targetSystem, coding.code);
288
292
  }
289
293
  }
290
294
 
@@ -390,7 +394,7 @@ class TranslateWorker extends TerminologyWorker {
390
394
  }
391
395
 
392
396
  // Perform the translation
393
- const result = await this.doTranslate(scoped.maps, coding, targetScope, targetSystem, params);
397
+ const result = await this.doTranslate(scoped.maps, coding, targetScope, targetSystem, txp);
394
398
  return res.status(200).json(result);
395
399
  }
396
400
 
@@ -419,7 +423,11 @@ class TranslateWorker extends TerminologyWorker {
419
423
  // speak for another group, and does not suppress another group's fallback.
420
424
  for (const g of cm.listGroupsInScope(coding, targetScope, targetSystem)) {
421
425
  let groupResult = false;
422
- for (const em of (g.element || []).filter(e => e.code === coding.code)) {
426
+ // the code as given, then its normal form (see sourceCodes): an element written in the
427
+ // normal form matches a code given in another form, but not the other way round
428
+ const codes = this.sourceCodes(coding);
429
+ const elements = codes.flatMap(c => (g.element || []).filter(e => e.code === c));
430
+ for (const em of elements) {
423
431
  // ConceptMap.group.element.noMap says, positively, that this concept has no
424
432
  // mapping in this map - which is an answer, not the absence of one. Report it
425
433
  // as a match that states the source concept and the map it came from, with no
@@ -871,11 +879,19 @@ class TranslateWorker extends TerminologyWorker {
871
879
 
872
880
  let translations = await prov.getTranslations(cm, coding, target, reverse);
873
881
 
882
+ // The code system can say why there is no translation (an extension concept whose only
883
+ // International ancestor is a top-level hierarchy, say): that comes back as an entry
884
+ // with no code, and goes out as the message in place of the generic one
885
+ const explanations = translations.filter(t => !t.code && t.message);
886
+ translations = translations.filter(t => t.code);
887
+
874
888
  if (translations.length > 0) {
875
889
  result = true;
890
+ const usedMaps = new Set();
876
891
 
877
892
  for (const t of translations) {
878
- if (t.map) {
893
+ if (t.map && !usedMaps.has(t.map)) {
894
+ usedMaps.add(t.map);
879
895
  output.push({
880
896
  name: 'used-conceptmap',
881
897
  valueUri: t.map
@@ -889,6 +905,15 @@ class TranslateWorker extends TerminologyWorker {
889
905
  display: t.display
890
906
  };
891
907
 
908
+ const sourceConcept = {
909
+ system: coding.system,
910
+ code: coding.code
911
+ };
912
+ if (coding.version) {
913
+ // as given: the version says which edition the code came from, which is
914
+ // the point of an edition to edition translation
915
+ sourceConcept.version = coding.version;
916
+ }
892
917
  const matchParts = [];
893
918
  matchParts.push({
894
919
  name: 'concept',
@@ -896,10 +921,7 @@ class TranslateWorker extends TerminologyWorker {
896
921
  });
897
922
  matchParts.push({
898
923
  name: 'sourceConcept',
899
- valueCoding: {
900
- system: coding.system,
901
- code: coding.code
902
- }
924
+ valueCoding: sourceConcept
903
925
  });
904
926
  matchParts.push({
905
927
  name: 'relationship',
@@ -911,13 +933,26 @@ class TranslateWorker extends TerminologyWorker {
911
933
  valueString: t.message
912
934
  });
913
935
  }
914
- matchParts.push({
915
- name: 'originMap',
916
- valueCanonical: cm.vurl
917
- });
936
+ if (cm.url) {
937
+ matchParts.push({
938
+ name: 'originMap',
939
+ valueCanonical: cm.vurl
940
+ });
941
+ }
942
+ // the code system produced this target itself, from its own content (and for an
943
+ // edition to edition translation that content may be a version the server does not
944
+ // hold separately), so there is nothing to check it against: see checkTargetConcepts
918
945
  output.push({
919
946
  name: 'match',
920
- part: matchParts
947
+ part: matchParts,
948
+ [TRUSTED_TARGET]: true
949
+ });
950
+ }
951
+ } else {
952
+ for (const e of explanations) {
953
+ output.push({
954
+ name: 'message',
955
+ valueString: e.message
921
956
  });
922
957
  }
923
958
  }
@@ -1075,6 +1110,9 @@ class TranslateWorker extends TerminologyWorker {
1075
1110
  // across groups - one group having mapped the concept does not stop another group
1076
1111
  // from saying what it does with it.
1077
1112
  const maps = this.sortConceptMaps(conceptMaps);
1113
+ if (!reverse) {
1114
+ await this.noteSourceNormalForm(coding, params);
1115
+ }
1078
1116
  let added = false;
1079
1117
  for (const cm of maps) {
1080
1118
  if (cm.jsonObj.internalSource) {
@@ -1089,12 +1127,13 @@ class TranslateWorker extends TerminologyWorker {
1089
1127
  // or R4's unmatched/disjoint). That is still reported as a match - it is what the map
1090
1128
  // says - but it is not a translation: "The value can only be true if at least one
1091
1129
  // returned match has a relationship other than 'not-related-to'" (tx-ecosystem translate-2b)
1130
+ await this.checkTargetConcepts(result, params);
1092
1131
  const translated = added && result.some(p => p.name === 'match' && this.isPositiveMatch(p));
1093
1132
  result.push({
1094
1133
  name: 'result',
1095
1134
  valueBoolean: translated
1096
1135
  });
1097
- if (!added) {
1136
+ if (!added && !result.some(p => p.name === 'message')) {
1098
1137
  result.push({
1099
1138
  name: 'message',
1100
1139
  valueString: 'No translations found'
@@ -1125,6 +1164,118 @@ class TranslateWorker extends TerminologyWorker {
1125
1164
  };
1126
1165
  }
1127
1166
 
1167
+ /**
1168
+ * What the server can say about a code: whether its code system is known, whether the code
1169
+ * is valid there (undefined when the code system's content cannot tell - a fragment), and -
1170
+ * when it is - its normal form, as the code system has it (the same
1171
+ * normal form $validate-code reports as normalized-code) and its display. A code system
1172
+ * (or version) the server cannot resolve is not known.
1173
+ *
1174
+ * @returns {Promise<{known: boolean, valid?: boolean, normal?: string, display?: string}>}
1175
+ */
1176
+ async resolveCode(system, version, code, params) {
1177
+ if (!system || !code) {
1178
+ return { known: false };
1179
+ }
1180
+ this.deadCheck('translate-resolve');
1181
+ let cs;
1182
+ try {
1183
+ cs = await this.findCodeSystem(system, version || '', params, ['complete', 'fragment'], null, true);
1184
+ } catch (error) {
1185
+ if (error instanceof Issue) {
1186
+ return { known: false }; // an unknown version, say
1187
+ }
1188
+ throw error;
1189
+ }
1190
+ if (!cs) {
1191
+ return { known: false };
1192
+ }
1193
+ const loc = await cs.locate(code);
1194
+ if (!loc || !loc.context) {
1195
+ // a fragment, or a code system without its content, cannot say a code is not in it
1196
+ const mode = cs.contentMode ? String(cs.contentMode() || 'complete') : 'complete';
1197
+ return mode === 'complete' ? { known: true, valid: false } : { known: true };
1198
+ }
1199
+ const normal = await cs.code(loc.context);
1200
+ return { known: true, valid: true, normal: normal || code, display: await cs.display(loc.context) };
1201
+ }
1202
+
1203
+ /** The normal form of a code, with its display; null when it is the code as given, or unknown. */
1204
+ async normalForm(system, version, code, params) {
1205
+ const r = await this.resolveCode(system, version, code, params);
1206
+ if (!r.valid || r.normal === code) {
1207
+ return null;
1208
+ }
1209
+ return { code: r.normal, display: r.display };
1210
+ }
1211
+
1212
+ /**
1213
+ * The source code may not be in its code system's normal form (an ICD-11 expression written
1214
+ * with short codes, say). A map element written in the normal form still applies to it, so
1215
+ * translation looks for the code as given first, and then for its normal form. The map's own
1216
+ * codes are not normalised: an element written in some other form matches only that code.
1217
+ */
1218
+ async noteSourceNormalForm(coding, params) {
1219
+ const normal = await this.normalForm(coding.system, coding.version, coding.code, params);
1220
+ this.sourceNormal = normal ? { system: coding.system, code: coding.code, normal: normal.code } : null;
1221
+ }
1222
+
1223
+ /** The codes a map element may have for this source: as given, then the normal form. */
1224
+ sourceCodes(coding) {
1225
+ const n = this.sourceNormal;
1226
+ if (n && n.system === coding.system && n.code === coding.code) {
1227
+ return [coding.code, n.normal];
1228
+ }
1229
+ return [coding.code];
1230
+ }
1231
+
1232
+ /**
1233
+ * The target concepts the maps produced are checked against their code systems:
1234
+ *
1235
+ * - a target in a code system the server does not know gets a `note` part in its match,
1236
+ * saying that the code has not been validated
1237
+ * - a target that is not valid in its code system is still reported as the map has it, and
1238
+ * the response gets an `error` parameter for it (one per invalid target)
1239
+ * - a valid target that is not in its code system's normal form - an ICD-11 map written with
1240
+ * short codes, where the normal form is the entity id, say - also carries
1241
+ * `normalized-concept`: the same concept, as the code system has it
1242
+ *
1243
+ * This applies to any code system. `concept` is always the target as the map states it.
1244
+ */
1245
+ async checkTargetConcepts(result, params) {
1246
+ const errors = [];
1247
+ for (const match of result) {
1248
+ if (match.name !== 'match' || !Array.isArray(match.part) || match[TRUSTED_TARGET]) {
1249
+ continue;
1250
+ }
1251
+ const concept = match.part.find(p => p.name === 'concept')?.valueCoding;
1252
+ if (!concept || !concept.system || !concept.code) {
1253
+ continue;
1254
+ }
1255
+ const r = await this.resolveCode(concept.system, concept.version, concept.code, params);
1256
+ const at = match.part.findIndex(p => p.name === 'concept');
1257
+ const csName = concept.version ? `${concept.system}|${concept.version}` : concept.system;
1258
+ if (!r.known) {
1259
+ match.part.splice(at + 1, 0, { name: 'note', valueString:
1260
+ this.i18n.translate('TRANSLATE_TARGET_CS_UNKNOWN', params.FHTTPLanguages, [csName, concept.code]) });
1261
+ } else if (r.valid === false) {
1262
+ errors.push({ name: 'error', valueString:
1263
+ this.i18n.translate('CONCEPTMAP_GROUP_TARGET_CODE_INVALID', params.FHTTPLanguages, [concept.code, csName]) });
1264
+ } else if (r.valid && r.normal !== concept.code) {
1265
+ const coding = { system: concept.system };
1266
+ if (concept.version) {
1267
+ coding.version = concept.version;
1268
+ }
1269
+ coding.code = r.normal;
1270
+ if (r.display) {
1271
+ coding.display = r.display;
1272
+ }
1273
+ match.part.splice(at + 1, 0, { name: 'normalized-concept', valueCoding: coding });
1274
+ }
1275
+ }
1276
+ result.push(...errors);
1277
+ }
1278
+
1128
1279
  /**
1129
1280
  * Whether a match counts towards result=true. A noMap match says there is no mapping, and
1130
1281
  * an explicit negative relationship says the target is not related, so neither is a