fhirsmith 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- 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='
|
|
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
|
-
|
|
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
|
-
//
|
|
401
|
-
|
|
402
|
-
s
|
|
403
|
-
|
|
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
|
/**
|
package/tx/workers/expand.js
CHANGED
|
@@ -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(),
|
|
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(),
|
|
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
|
-
|
|
956
|
+
const filtersOpen = await cs.filtersNotClosed(prep);
|
|
957
|
+
if (filtersOpen) {
|
|
943
958
|
notClosed.value = true;
|
|
944
|
-
}
|
|
945
|
-
|
|
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));
|
package/tx/workers/lookup.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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({
|
package/tx/workers/subsumes.js
CHANGED
|
@@ -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 =
|
|
180
|
+
const csProvider = FhirCodeSystemProvider.create(this.opContext, codeSystem, supplements);
|
|
181
181
|
|
|
182
182
|
// Get the codings
|
|
183
183
|
let codingA, codingB;
|
package/tx/workers/translate.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|