fhirsmith 0.13.2 → 0.13.3
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 +27 -0
- package/config-template.json +8 -1
- package/package.json +2 -2
- package/publisher/publisher.js +9 -2
- package/tx/cs/cs-snomed.js +103 -41
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [v0.13.3] - 2026-09-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
|
|
13
|
+
language reference set for only six editions (International, US, UK and the test edition);
|
|
14
|
+
every other edition fell through to "the first description marked Preferred in any language
|
|
15
|
+
reference set", which on a multi-language edition means whichever description happened to be
|
|
16
|
+
stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
|
|
17
|
+
(21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
|
|
18
|
+
no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
|
|
19
|
+
Editions not in the table now default to US then GB English, and the $lookup designation
|
|
20
|
+
order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
|
|
21
|
+
no longer depends on import order. An edition with no English language reference set at all
|
|
22
|
+
still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
|
|
23
|
+
`bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
|
|
24
|
+
is the GB English preferred term picked only because it is stored before the US one - the same
|
|
25
|
+
concept already expanded as "Counseling". The expectation is now "Counseling".
|
|
26
|
+
- Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
|
|
27
|
+
while its value came from the edition display, so on a multi-language edition a French or
|
|
28
|
+
Dutch term was published as English - which put it in an implementation guide's English
|
|
29
|
+
display column and made the real French designation read as a duplicate of it.
|
|
30
|
+
|
|
31
|
+
### Tx Conformance Statement
|
|
32
|
+
|
|
33
|
+
FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4
|
|
34
|
+
|
|
8
35
|
## [v0.13.2] - 2026-09-05
|
|
9
36
|
|
|
10
37
|
### Added
|
package/config-template.json
CHANGED
|
@@ -94,7 +94,14 @@
|
|
|
94
94
|
// (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
|
|
95
95
|
// Omit to leave them in place - the push will then fail. Relative paths resolve under the
|
|
96
96
|
// publisher data folder
|
|
97
|
-
"large-file-archive": "/absolute/path/to/large-file-archive"
|
|
97
|
+
"large-file-archive": "/absolute/path/to/large-file-archive",
|
|
98
|
+
// where to clone the IG registry from. Omit for the default,
|
|
99
|
+
// git@github.com:FHIR/ig-registry.git. Set this to an ssh_config Host alias when the
|
|
100
|
+
// server should push the registry with a repo-scoped deploy key instead of an account
|
|
101
|
+
// key - e.g. "git@github-ig-registry:FHIR/ig-registry.git", with a matching Host block
|
|
102
|
+
// in the service user's ~/.ssh/config carrying IdentityFile and IdentitiesOnly yes.
|
|
103
|
+
// The alias is what lets ssh tell this remote apart from the website remote.
|
|
104
|
+
"ig-registry-url": "git@github.com:FHIR/ig-registry.git"
|
|
98
105
|
},
|
|
99
106
|
"npmprojector": {
|
|
100
107
|
"enabled": true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"txVersion": "1.9.5-SNAPSHOT",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"express-rate-limit": "^7.4.1",
|
|
47
47
|
"express-session": "^1.19.0",
|
|
48
48
|
"fast-xml-parser": "^5.3.4",
|
|
49
|
-
"fhir-validator-wrapper": "1.
|
|
49
|
+
"fhir-validator-wrapper": "1.4.0",
|
|
50
50
|
"fhirpath": "^4.8.3",
|
|
51
51
|
"fs-extra": "^11.3.3",
|
|
52
52
|
"ini": "^6.0.0",
|
package/publisher/publisher.js
CHANGED
|
@@ -1117,8 +1117,15 @@ class PublisherModule {
|
|
|
1117
1117
|
// Step 1: Clone supporting repositories into the task directory
|
|
1118
1118
|
const registryDir = path.join(taskDir, 'ig-registry');
|
|
1119
1119
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1120
|
+
// The URL is configurable so that the credential arrangement can live in server config
|
|
1121
|
+
// rather than here. A deployment that pushes ig-registry with a repo-scoped deploy key
|
|
1122
|
+
// rather than an account key points this at an ssh_config Host alias carrying that key,
|
|
1123
|
+
// e.g. git@github-ig-registry:FHIR/ig-registry.git - the alias is the only way ssh can
|
|
1124
|
+
// tell this remote apart from the website remote, both being github.com.
|
|
1125
|
+
const igRegistryUrl = this.config['ig-registry-url'] || 'git@github.com:FHIR/ig-registry.git';
|
|
1126
|
+
|
|
1127
|
+
await this.runCommand('git', ['clone', igRegistryUrl, registryDir],
|
|
1128
|
+
{}, task.id, 'Cloning ig-registry from ' + igRegistryUrl);
|
|
1122
1129
|
|
|
1123
1130
|
// Use website-configured history templates path if provided, otherwise clone the default repo
|
|
1124
1131
|
let historyDir;
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -28,6 +28,21 @@ const {debugLog} = require("../operation-context");
|
|
|
28
28
|
// shared - it simply recomputes.
|
|
29
29
|
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
30
30
|
|
|
31
|
+
// Description language index -> language code. The index is assigned at import
|
|
32
|
+
// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
|
|
33
|
+
// be kept in step with it.
|
|
34
|
+
const SCT_LANGUAGE_CODES = {
|
|
35
|
+
1: 'en',
|
|
36
|
+
2: 'fr',
|
|
37
|
+
3: 'nl',
|
|
38
|
+
4: 'es',
|
|
39
|
+
5: 'sv',
|
|
40
|
+
6: 'da',
|
|
41
|
+
7: 'de',
|
|
42
|
+
8: 'it',
|
|
43
|
+
9: 'cs'
|
|
44
|
+
};
|
|
45
|
+
|
|
31
46
|
// Context kinds matching Pascal enum
|
|
32
47
|
const SnomedProviderContextKind = {
|
|
33
48
|
CODE: 0,
|
|
@@ -346,8 +361,20 @@ class SnomedServices {
|
|
|
346
361
|
// preferred synonym in more than one dialect at once (the International
|
|
347
362
|
// edition ships both US English 509007 and GB English 508004, each marking a
|
|
348
363
|
// different synonym Preferred). The edition default decides which term is
|
|
349
|
-
// shown.
|
|
350
|
-
//
|
|
364
|
+
// shown.
|
|
365
|
+
//
|
|
366
|
+
// Editions not listed here default to English (US then GB) rather than to
|
|
367
|
+
// "preferred synonym in any language refset". A national edition carries a
|
|
368
|
+
// Preferred synonym in each of its own language refsets as well as in English
|
|
369
|
+
// (e.g. the Belgian edition 11000172109 marks a French term Preferred in
|
|
370
|
+
// 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
|
|
371
|
+
// whichever description happened to be stored first - French for one concept,
|
|
372
|
+
// Dutch for the next, English for a third. A caller who asked for no
|
|
373
|
+
// particular language got an arbitrary mix. English is the only defensible
|
|
374
|
+
// default: it is the one language every edition has, and it is what a request
|
|
375
|
+
// with no displayLanguage is taken to mean everywhere else in the server. An
|
|
376
|
+
// edition with no English at all still falls through to the any-refset step
|
|
377
|
+
// below, so nothing regresses.
|
|
351
378
|
_displayRefsetOrder() {
|
|
352
379
|
if (this._dispOrder) return this._dispOrder;
|
|
353
380
|
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
@@ -361,7 +388,7 @@ class SnomedServices {
|
|
|
361
388
|
'83821000000107': [GB], // UK Edition
|
|
362
389
|
'999000021000000109': [GB] // UK Clinical Edition
|
|
363
390
|
};
|
|
364
|
-
const ids = byEdition[String(this.edition)] || [];
|
|
391
|
+
const ids = byEdition[String(this.edition)] || [US, GB];
|
|
365
392
|
this._dispOrder = ids.map(idx).filter((i) => i >= 0);
|
|
366
393
|
return this._dispOrder;
|
|
367
394
|
}
|
|
@@ -400,59 +427,80 @@ class SnomedServices {
|
|
|
400
427
|
return false;
|
|
401
428
|
}
|
|
402
429
|
|
|
403
|
-
// Return a concept's display
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
430
|
+
// Return a concept's display, together with the language it is actually in:
|
|
431
|
+
// the synonym marked Preferred in the edition's display language reference
|
|
432
|
+
// set; failing that a preferred synonym in any language refset; failing that
|
|
433
|
+
// the FSN; failing that the first active description. Previously this
|
|
434
|
+
// returned the first active description outright, which is only the preferred
|
|
435
|
+
// term by accident of import order.
|
|
436
|
+
//
|
|
437
|
+
// The language matters as much as the term: the display is emitted as a
|
|
438
|
+
// designation (DesignationUse.DISPLAY) during $expand, and tagging a French
|
|
439
|
+
// term as en-US puts it in an IG's English display column and makes the real
|
|
440
|
+
// French designation look like a duplicate.
|
|
441
|
+
//
|
|
442
|
+
// Returns {term, lang} - lang is a language code, or null when no description
|
|
443
|
+
// could be read.
|
|
444
|
+
getDisplayNameEx(reference = 0) {
|
|
445
|
+
const none = { term: '', lang: null };
|
|
408
446
|
try {
|
|
409
447
|
const concept = this.concepts.getConcept(reference);
|
|
410
448
|
const descriptionsRef = concept.descriptions;
|
|
411
449
|
|
|
412
450
|
if (descriptionsRef === 0) {
|
|
413
|
-
return
|
|
451
|
+
return none;
|
|
414
452
|
}
|
|
415
453
|
|
|
416
454
|
const descriptionIndices = this.refs.getReferences(descriptionsRef);
|
|
417
455
|
const K = this._displayConstants();
|
|
456
|
+
const pick = (description) => ({
|
|
457
|
+
term: this.strings.getEntry(description.iDesc).trim(),
|
|
458
|
+
lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
|
|
459
|
+
});
|
|
418
460
|
|
|
419
461
|
// 1. Preferred synonym in the edition's default display refset(s), tried
|
|
420
462
|
// in priority order. This is what disambiguates dialects: on the
|
|
421
463
|
// International edition a concept may be Preferred in both US and GB
|
|
422
|
-
// English, and the edition default (US English) must win.
|
|
464
|
+
// English, and the edition default (US English) must win. It is also
|
|
465
|
+
// what keeps a multi-language national edition from picking a
|
|
466
|
+
// different language for each concept.
|
|
423
467
|
for (const refsetIdx of this._displayRefsetOrder()) {
|
|
424
468
|
for (const descIndex of descriptionIndices) {
|
|
425
469
|
const description = this.descriptions.getDescription(descIndex);
|
|
426
470
|
if (!description.active || description.kind !== K.synonym) continue;
|
|
427
471
|
if (this._descAcceptability(description, refsetIdx) === K.preferred) {
|
|
428
|
-
return
|
|
472
|
+
return pick(description);
|
|
429
473
|
}
|
|
430
474
|
}
|
|
431
475
|
}
|
|
432
476
|
|
|
433
477
|
// 2. Fallback: preferred synonym in ANY language refset; then FSN; then
|
|
434
|
-
// the first active description.
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
let
|
|
478
|
+
// the first active description. Reached only by editions with no
|
|
479
|
+
// English language refset at all, and by concepts with no preferred
|
|
480
|
+
// synonym in the edition's display refsets.
|
|
481
|
+
let fsn = null;
|
|
482
|
+
let firstActive = null;
|
|
438
483
|
for (const descIndex of descriptionIndices) {
|
|
439
484
|
const description = this.descriptions.getDescription(descIndex);
|
|
440
485
|
if (!description.active) continue;
|
|
441
|
-
|
|
442
|
-
if (firstActive === '') firstActive = term;
|
|
486
|
+
if (firstActive === null) firstActive = pick(description);
|
|
443
487
|
if (this._synonymIsPreferred(description)) {
|
|
444
|
-
return
|
|
488
|
+
return pick(description); // preferred synonym (any language refset)
|
|
445
489
|
}
|
|
446
|
-
if (description.kind === K.fsn &&
|
|
447
|
-
|
|
490
|
+
if (description.kind === K.fsn && fsn === null) {
|
|
491
|
+
fsn = pick(description);
|
|
448
492
|
}
|
|
449
493
|
}
|
|
450
|
-
return
|
|
494
|
+
return fsn || firstActive || none;
|
|
451
495
|
} catch (error) {
|
|
452
|
-
return
|
|
496
|
+
return none;
|
|
453
497
|
}
|
|
454
498
|
}
|
|
455
499
|
|
|
500
|
+
getDisplayName(reference = 0) {
|
|
501
|
+
return this.getDisplayNameEx(reference).term;
|
|
502
|
+
}
|
|
503
|
+
|
|
456
504
|
getConceptDescendants(reference) {
|
|
457
505
|
try {
|
|
458
506
|
const allDescRef = this.concepts.getAllDesc(reference);
|
|
@@ -1687,8 +1735,16 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1687
1735
|
if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
|
|
1688
1736
|
}
|
|
1689
1737
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1738
|
+
// Tagged with the language the term is actually in, not a
|
|
1739
|
+
// hard-coded en-US: on a multi-language edition the edition
|
|
1740
|
+
// display can be French or Dutch, and mislabelling it as English
|
|
1741
|
+
// is what puts it in an IG's English display column and makes the
|
|
1742
|
+
// real French designation look like a duplicate of it. English
|
|
1743
|
+
// stays 'en-US' (the term comes from the US English language
|
|
1744
|
+
// reference set), so nothing changes for English editions.
|
|
1745
|
+
const display = this.sct.getDisplayNameEx(ctxt.getReference());
|
|
1746
|
+
const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
|
|
1747
|
+
if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
|
|
1692
1748
|
// FSNs before synonyms, matching the designation order the published
|
|
1693
1749
|
// tx-ecosystem expectations were written against (sct/expand-inactive).
|
|
1694
1750
|
// Which of the two is chosen as the display is settled by
|
|
@@ -1697,14 +1753,31 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1697
1753
|
for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1698
1754
|
for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1699
1755
|
} else {
|
|
1700
|
-
// $lookup: emit every description
|
|
1701
|
-
// display
|
|
1756
|
+
// $lookup: emit every description. Order matters only for the
|
|
1757
|
+
// display: a request with no displayLanguage takes the first
|
|
1758
|
+
// designation Designations._isPreferred() accepts, which is any
|
|
1759
|
+
// SNOMED synonym. Ranking by the edition's display language
|
|
1760
|
+
// reference set first (rather than by "preferred in any refset")
|
|
1761
|
+
// keeps that pick in one language - otherwise a national edition
|
|
1762
|
+
// answers $lookup in French for one concept and Dutch for the
|
|
1763
|
+
// next, depending on description storage order.
|
|
1764
|
+
const K2 = this.sct._displayConstants();
|
|
1765
|
+
const displayRefsets = this.sct._displayRefsetOrder();
|
|
1766
|
+
// Buckets: 0 = preferred in the edition's display refset, 1 =
|
|
1767
|
+
// preferred in some other language refset, 2 = everything else
|
|
1768
|
+
// (FSNs and acceptable synonyms, left in their original order -
|
|
1769
|
+
// Array.sort is stable, so nothing else about the output moves).
|
|
1770
|
+
const rank = (description) => {
|
|
1771
|
+
if (description.kind !== K2.synonym) return 2;
|
|
1772
|
+
for (const refsetIdx of displayRefsets) {
|
|
1773
|
+
if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
|
|
1774
|
+
}
|
|
1775
|
+
return this.sct._synonymIsPreferred(description) ? 1 : 2;
|
|
1776
|
+
};
|
|
1702
1777
|
const orderedIndices = descriptionIndices.slice().sort((a, b) => {
|
|
1703
1778
|
const da = this.sct.descriptions.getDescription(a);
|
|
1704
1779
|
const db = this.sct.descriptions.getDescription(b);
|
|
1705
|
-
|
|
1706
|
-
const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
|
|
1707
|
-
return pa - pb;
|
|
1780
|
+
return rank(da) - rank(db);
|
|
1708
1781
|
});
|
|
1709
1782
|
for (const descIndex of orderedIndices) {
|
|
1710
1783
|
const description = this.sct.descriptions.getDescription(descIndex);
|
|
@@ -1733,18 +1806,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1733
1806
|
}
|
|
1734
1807
|
|
|
1735
1808
|
getLanguageCode(langIndex) {
|
|
1736
|
-
|
|
1737
|
-
1: 'en',
|
|
1738
|
-
2: 'fr',
|
|
1739
|
-
3: 'nl',
|
|
1740
|
-
4: 'es',
|
|
1741
|
-
5: 'sv',
|
|
1742
|
-
6: 'da',
|
|
1743
|
-
7: 'de',
|
|
1744
|
-
8: 'it',
|
|
1745
|
-
9: 'cs'
|
|
1746
|
-
};
|
|
1747
|
-
return languageMap[langIndex] || 'en';
|
|
1809
|
+
return SCT_LANGUAGE_CODES[langIndex] || 'en';
|
|
1748
1810
|
}
|
|
1749
1811
|
|
|
1750
1812
|
// Lookup methods
|