fhirsmith 0.7.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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.7.1] - 2026-03-14
9
+
10
+ ### Added
11
+ - Add web interface for ConceptMap
12
+
13
+ ### Changed
14
+ - Change status out parameter on $validate-code from string -> code
15
+
16
+ ### Fixed
17
+ - Fix handling of markdown in release process
18
+ - OCL cache fixes
19
+ -
20
+ ### Tx Conformance Statement
21
+
22
+ FHIRsmith passed all 1452 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.8.2)
23
+
8
24
  ## [v0.7.0] - 2026-03-13
9
25
 
10
26
  ### Added
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # ![🔥](static/FHIRsmith64.png) FHIRsmith - FHIR Server toolkit
2
2
 
3
-
4
-
5
3
  This server provides a set of server-side services that are useful for the FHIR Community. The set of are two kinds of services:
6
4
 
7
5
  ## Modules useful to anyone in the community
@@ -31,17 +29,28 @@ in-build support for SSL, rate limiting etc.
31
29
 
32
30
  There are 4 executable programs:
33
31
  * the server (`node server`)
34
- * the test cases (`npm test`)
35
32
  * the terminology importer (`node --max-old-space-size=8192 tx/importers/tx-import XXX`) - see [Doco](tx/importers/readme.md)
33
+ * the test cases (`npm test`)
36
34
  * the test cases generater (`node tx/tests/testcases-generator.js`)
37
35
 
36
+ Unless you're developing, you only need the first two
37
+
38
+ ### Quick Start
39
+
40
+ * Install FHIRSmith (using docker, or an NPM release, or just get the code by git)
41
+ * Figure out the data directory
42
+ * Provide a configuration to tell the server what to run (see documentation below, or use a [prebuilt configuration]/configurations/readme.md)
43
+ * Run the server
44
+
45
+ For further details of these steps, read on
46
+
38
47
  ### Data Directory
39
48
 
40
49
  The server separates code from runtime data. All databases, caches, logs, and downloaded
41
50
  files are stored in a single data directory. The location is determined by:
42
51
 
43
52
  1. The `FHIRSMITH_DATA_DIR` environment variable (if set)
44
- 2. Otherwise, defaults to `./data` relative to the working directory
53
+ 2. Otherwise, defaults to `./data` relative to the working directory (development set up)
45
54
 
46
55
  The data directory contains (depending on which modules are in use):
47
56
  * `config.json` — server and module configuration
@@ -3,3 +3,4 @@ This folder contains some basic starter configurations:
3
3
  * Terminology server: see tx-config.json for a vanilla server that doesn't contain any licensed content
4
4
  * NPM web server: see projector.json for a basic configuration to make a package available online
5
5
 
6
+ To use these, copy the relevant file to your local data directory, and rename to config.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
5
5
  "main": "server.js",
6
6
  "engines": {
@@ -0,0 +1,19 @@
1
+
2
+ <div class="operation-form" style="margin-bottom: 15px;">
3
+ <strong>Translate</strong>
4
+ <form method="get" action="$translate" style="margin-left: 10px; margin-top: 5px;">
5
+ <input type="hidden" name="url" value="{{ url }}"/>
6
+ <table class="grid" cellpadding="0" cellspacing="0">
7
+ <tr>
8
+ <td>System:</td><td><select name="sourceSystem">{{ sources }}</select></td>
9
+ </tr>
10
+ <tr>
11
+ <td>Code:</td><td><select name="sourceCode">{{ codes }}</select></td>
12
+ </tr>
13
+ <tr>
14
+ <td>Target:</td><td><select name="targetSystem">{{ targets }}</select></td>
15
+ </tr>
16
+ </table>
17
+ <button type="submit" class="btn btn-sm btn-primary">Translate</button>
18
+ </form>
19
+ </div>
@@ -317,8 +317,8 @@ class Renderer {
317
317
  }
318
318
  }
319
319
 
320
- translate(msgId) {
321
- return this.opContext.i18n.formatPhrase(msgId, this.opContext.langs, []);
320
+ translate(msgId, params = []) {
321
+ return this.opContext.i18n.formatPhrase(msgId, this.opContext.langs, params);
322
322
  }
323
323
 
324
324
  translatePlural(num, msgId) {
@@ -1640,6 +1640,575 @@ class Renderer {
1640
1640
  }
1641
1641
  }
1642
1642
  }
1643
+
1644
+ // Methods to add to the Renderer class in renderer.js for ConceptMap rendering.
1645
+ // These follow the Java ConceptMapRenderer logic and use the same translated strings.
1646
+
1647
+ // ---- Add these methods to the Renderer class ----
1648
+
1649
+ /**
1650
+ * Render a ConceptMap resource to HTML.
1651
+ * Follows the same pattern as renderValueSet/renderCodeSystem:
1652
+ * metadata table (reusing renderMetadataTable), then group-by-group rendering.
1653
+ */
1654
+ async renderConceptMap(cm) {
1655
+ if (cm.json) {
1656
+ cm = cm.json;
1657
+ }
1658
+
1659
+ let div_ = div();
1660
+
1661
+ // Metadata table
1662
+ div_.h3().tx("Properties");
1663
+ await this.renderMetadataTable(cm, div_.table("grid"));
1664
+
1665
+ div_.h3("Mapping Details");
1666
+ // Source/Target scope line (mirrors Java: CONC_MAP_FROM / CONC_MAP_TO)
1667
+ const p = div_.para();
1668
+ p.tx(this.translate('CONC_MAP_FROM') + " ");
1669
+ const sourceScope = cm.sourceScope || cm.sourceCanonical || cm.sourceUri;
1670
+ if (sourceScope) {
1671
+ await this.renderLink(p, sourceScope);
1672
+ } else {
1673
+ p.tx(this.translate('CONC_MAP_NOT_SPEC'));
1674
+ }
1675
+ p.tx(" " + this.translate('CONC_MAP_TO') + " ");
1676
+ const targetScope = cm.targetScope || cm.targetCanonical || cm.targetUri;
1677
+ if (targetScope) {
1678
+ await this.renderLink(p, targetScope);
1679
+ } else {
1680
+ p.tx(this.translate('CONC_MAP_NOT_SPEC'));
1681
+ }
1682
+
1683
+ div_.br();
1684
+
1685
+ // Render each group
1686
+ let gc = 0;
1687
+ for (const grp of cm.group || []) {
1688
+ gc++;
1689
+ if (gc > 1) {
1690
+ div_.hr();
1691
+ }
1692
+ await this.renderConceptMapGroup(div_, cm, grp, gc);
1693
+ }
1694
+
1695
+ return div_.toString();
1696
+ }
1697
+
1698
+ /**
1699
+ * Render a single ConceptMap group.
1700
+ * Determines whether this is a "simple" group (1:1 mappings, no dependsOn/product)
1701
+ * or a "complex" group, and delegates accordingly.
1702
+ */
1703
+ async renderConceptMapGroup(x, cm, grp, gc) {
1704
+ // Analyze the group to determine rendering mode
1705
+ let hasComment = false;
1706
+ let hasProperties = false;
1707
+ let ok = true; // true = simple rendering
1708
+
1709
+ const props = {}; // property code -> Set of systems
1710
+ const sources = { code: new Set() };
1711
+ const targets = { code: new Set() };
1712
+
1713
+ if (grp.source) sources.code.add(grp.source);
1714
+ if (grp.target) targets.code.add(grp.target);
1715
+
1716
+ for (const elem of grp.element || []) {
1717
+ const isSimple = elem.noMap ||
1718
+ (elem.target && elem.target.length === 1 &&
1719
+ (!elem.target[0].dependsOn || elem.target[0].dependsOn.length === 0) &&
1720
+ (!elem.target[0].product || elem.target[0].product.length === 0));
1721
+ ok = ok && isSimple;
1722
+
1723
+ if (Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment')) {
1724
+ hasComment = true;
1725
+ }
1726
+
1727
+ for (const tgt of elem.target || []) {
1728
+ if (tgt.comment) {
1729
+ hasComment = true;
1730
+ }
1731
+ for (const pp of tgt.property || []) {
1732
+ if (!props[pp.code]) {
1733
+ props[pp.code] = new Set();
1734
+ }
1735
+ }
1736
+ for (const d of tgt.dependsOn || []) {
1737
+ if (!sources[d.attribute]) {
1738
+ sources[d.attribute] = new Set();
1739
+ }
1740
+ }
1741
+ for (const d of tgt.product || []) {
1742
+ if (!targets[d.attribute]) {
1743
+ targets[d.attribute] = new Set();
1744
+ }
1745
+ }
1746
+ }
1747
+ }
1748
+
1749
+ if (Object.keys(props).length > 0) {
1750
+ hasProperties = true;
1751
+ }
1752
+
1753
+ // Group header
1754
+ const pp = x.para();
1755
+ pp.b().tx(this.translate('CONC_MAP_GRP', [gc])+ " ");
1756
+ pp.tx(this.translate('CONC_MAP_FROM') + " ");
1757
+ if (grp.source) {
1758
+ await this.renderLink(pp, grp.source);
1759
+ } else {
1760
+ pp.code().tx(this.translate('CONC_MAP_CODE_SYS_UNSPEC'));
1761
+ }
1762
+ pp.tx(" to ");
1763
+ if (grp.target) {
1764
+ await this.renderLink(pp, grp.target);
1765
+ } else {
1766
+ pp.code().tx(this.translate('CONC_MAP_CODE_SYS_UNSPEC'));
1767
+ }
1768
+
1769
+ if (ok) {
1770
+ await this.renderSimpleConceptMapGroup(x, grp, hasComment);
1771
+ } else {
1772
+ await this.renderComplexConceptMapGroup(x, grp, hasComment, hasProperties, props, sources, targets);
1773
+ }
1774
+ }
1775
+
1776
+ /**
1777
+ * Render a simple ConceptMap group: Source | Relationship | Target | Comment
1778
+ * This is the "ok" path from the Java code where all elements have at most
1779
+ * one target and no dependsOn/product.
1780
+ */
1781
+ async renderSimpleConceptMapGroup(x, grp, hasComment) {
1782
+ const tbl = x.table("grid");
1783
+ let tr = tbl.tr();
1784
+ tr.td().b().tx(this.translate('CONC_MAP_SOURCE'));
1785
+ tr.td().b().tx(this.translate('CONC_MAP_REL'));
1786
+ tr.td().b().tx(this.translate('CONC_MAP_TRGT'));
1787
+ if (hasComment) {
1788
+ tr.td().b().tx(this.translate('GENERAL_COMMENT'));
1789
+ }
1790
+
1791
+ for (const elem of grp.element || []) {
1792
+ tr = tbl.tr();
1793
+ const td = tr.td();
1794
+ td.tx(elem.code);
1795
+ const display = elem.display || await this.getDisplayForConcept(grp.source, elem.code);
1796
+ if (display && !this.isSameCodeAndDisplay(elem.code, display)) {
1797
+ td.tx(" (" + display + ")");
1798
+ }
1799
+
1800
+ if (elem.noMap) {
1801
+ const nomapComment = Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment');
1802
+ if (!hasComment) {
1803
+ tr.td().colspan("2").style("background-color: #efefef").tx("(not mapped)");
1804
+ } else if (nomapComment) {
1805
+ tr.td().colspan("2").style("background-color: #efefef").tx("(not mapped)");
1806
+ tr.td().style("background-color: #efefef").tx(nomapComment);
1807
+ } else {
1808
+ tr.td().colspan("3").style("background-color: #efefef").tx("(not mapped)");
1809
+ }
1810
+ } else {
1811
+ let first = true;
1812
+ for (const tgt of elem.target || []) {
1813
+ if (first) {
1814
+ first = false;
1815
+ } else {
1816
+ tr = tbl.tr();
1817
+ tr.td().style("opacity: 0.5").tx('"');
1818
+ }
1819
+
1820
+ // Relationship cell
1821
+ this.renderConceptMapRelationship(tr, tgt);
1822
+
1823
+ // Target code cell
1824
+ const tgtTd = tr.td();
1825
+ tgtTd.tx(tgt.code || '');
1826
+ const tgtDisplay = tgt.display || await this.getDisplayForConcept(grp.target, tgt.code);
1827
+ if (tgtDisplay && !this.isSameCodeAndDisplay(tgt.code, tgtDisplay)) {
1828
+ tgtTd.tx(" (" + tgtDisplay + ")");
1829
+ }
1830
+
1831
+ if (hasComment) {
1832
+ tr.td().tx(tgt.comment || '');
1833
+ }
1834
+ }
1835
+ }
1836
+ }
1837
+ this.addUnmapped(tbl, grp);
1838
+ }
1839
+
1840
+ /**
1841
+ * Render a complex ConceptMap group with dependsOn, product, and/or property columns.
1842
+ * This is the "!ok" path from the Java code.
1843
+ */
1844
+ async renderComplexConceptMapGroup(x, grp, hasComment, hasProperties, props, sources, targets) {
1845
+ // Check if any targets have relationships
1846
+ let hasRelationships = false;
1847
+ for (const elem of grp.element || []) {
1848
+ for (const tgt of elem.target || []) {
1849
+ if (tgt.relationship) {
1850
+ hasRelationships = true;
1851
+ }
1852
+ }
1853
+ }
1854
+
1855
+ const tbl = x.table("grid");
1856
+
1857
+ // First header row: Source Details | Relationship | Target Details | Comment | Properties
1858
+ let tr = tbl.tr();
1859
+ const sourceColCount = 1 + Object.keys(sources).length - 1; // code + dependsOn attributes
1860
+ const targetColCount = 1 + Object.keys(targets).length - 1; // code + product attributes
1861
+ tr.td().colspan(String(sourceColCount + 1)).b().tx(this.translate('CONC_MAP_SRC_DET'));
1862
+ if (hasRelationships) {
1863
+ tr.td().b().tx(this.translate('CONC_MAP_REL'));
1864
+ }
1865
+ tr.td().colspan(String(targetColCount + 1)).b().tx(this.translate('CONC_MAP_TRGT_DET'));
1866
+ if (hasComment) {
1867
+ tr.td().b().tx(this.translate('GENERAL_COMMENT'));
1868
+ }
1869
+ if (hasProperties) {
1870
+ tr.td().colspan(String(Object.keys(props).length)).b().tx(this.translate('GENERAL_PROPS'));
1871
+ }
1872
+
1873
+ // Second header row: actual column headers
1874
+ tr = tbl.tr();
1875
+
1876
+ // Source code column
1877
+ if (sources.code.size === 1) {
1878
+ const url = [...sources.code][0];
1879
+ await this.renderCSDetailsLink(tr, url, true);
1880
+ } else {
1881
+ tr.td().b().tx(this.translate('GENERAL_CODE'));
1882
+ }
1883
+ // Source dependsOn attribute columns
1884
+ for (const s of Object.keys(sources)) {
1885
+ if (s !== 'code') {
1886
+ if (sources[s].size === 1) {
1887
+ const url = [...sources[s]][0];
1888
+ await this.renderCSDetailsLink(tr, url, false);
1889
+ } else {
1890
+ tr.td().b().tx(this.getDescForConcept(s));
1891
+ }
1892
+ }
1893
+ }
1894
+ // Relationship column
1895
+ if (hasRelationships) {
1896
+ tr.td();
1897
+ }
1898
+ // Target code column
1899
+ if (targets.code.size === 1) {
1900
+ const url = [...targets.code][0];
1901
+ await this.renderCSDetailsLink(tr, url, true);
1902
+ } else {
1903
+ tr.td().b().tx(this.translate('GENERAL_CODE'));
1904
+ }
1905
+ // Target product attribute columns
1906
+ for (const s of Object.keys(targets)) {
1907
+ if (s !== 'code') {
1908
+ if (targets[s].size === 1) {
1909
+ const url = [...targets[s]][0];
1910
+ await this.renderCSDetailsLink(tr, url, false);
1911
+ } else {
1912
+ tr.td().b().tx(this.getDescForConcept(s));
1913
+ }
1914
+ }
1915
+ }
1916
+ // Comment column header
1917
+ if (hasComment) {
1918
+ tr.td();
1919
+ }
1920
+ // Property column headers
1921
+ if (hasProperties) {
1922
+ for (const s of Object.keys(props)) {
1923
+ if (props[s].size === 1) {
1924
+ const url = [...props[s]][0];
1925
+ await this.renderCSDetailsLink(tr, url, false);
1926
+ } else {
1927
+ tr.td().b().tx(this.getDescForConcept(s));
1928
+ }
1929
+ }
1930
+ }
1931
+
1932
+ // Data rows
1933
+ for (const elem of grp.element || []) {
1934
+ if (elem.noMap) {
1935
+ tr = tbl.tr();
1936
+ const td = tr.td().style("border-right-width: 0px");
1937
+ if (sources.code.size === 1) {
1938
+ td.tx(elem.code);
1939
+ } else {
1940
+ td.tx(grp.source + " / " + elem.code);
1941
+ }
1942
+ const display = elem.display || await this.getDisplayForConcept(grp.source, elem.code);
1943
+ tr.td().style("border-left-width: 0px").tx(display || '');
1944
+
1945
+ const nomapComment = Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment');
1946
+ if (nomapComment) {
1947
+ tr.td().colspan("3").style("background-color: #efefef").tx("(not mapped)");
1948
+ tr.td().style("background-color: #efefef").tx(nomapComment);
1949
+ } else {
1950
+ tr.td().colspan("4").style("background-color: #efefef").tx("(not mapped)");
1951
+ }
1952
+ } else {
1953
+ let first = true;
1954
+ for (let ti = 0; ti < (elem.target || []).length; ti++) {
1955
+ const tgt = elem.target[ti];
1956
+ const last = ti === elem.target.length - 1;
1957
+ tr = tbl.tr();
1958
+
1959
+ // Source code cell
1960
+ const td = tr.td().style("border-right-width: 0px");
1961
+ if (!first && !last) {
1962
+ td.style("border-top-style: none; border-bottom-style: none");
1963
+ } else if (!first) {
1964
+ td.style("border-top-style: none");
1965
+ } else if (!last) {
1966
+ td.style("border-bottom-style: none");
1967
+ }
1968
+
1969
+ if (first) {
1970
+ if (sources.code.size === 1) {
1971
+ td.tx(elem.code);
1972
+ } else {
1973
+ td.tx(grp.source + " / " + elem.code);
1974
+ }
1975
+ const display = elem.display || await this.getDisplayForConcept(grp.source, elem.code);
1976
+ const dispTd = tr.td();
1977
+ if (!last) {
1978
+ dispTd.style("border-left-width: 0px; border-bottom-style: none");
1979
+ } else {
1980
+ dispTd.style("border-left-width: 0px");
1981
+ }
1982
+ dispTd.tx(display || '');
1983
+ } else {
1984
+ // Empty display cell for subsequent targets
1985
+ const dispTd = tr.td();
1986
+ if (!last) {
1987
+ dispTd.style("border-left-width: 0px; border-top-style: none; border-bottom-style: none");
1988
+ } else {
1989
+ dispTd.style("border-top-style: none; border-left-width: 0px");
1990
+ }
1991
+ }
1992
+
1993
+ // Source dependsOn columns
1994
+ for (const s of Object.keys(sources)) {
1995
+ if (s !== 'code') {
1996
+ const depTd = tr.td();
1997
+ const val = this.getDependsOnValue(tgt.dependsOn, s, sources[s].size !== 1);
1998
+ depTd.tx(val || '');
1999
+ const depDisplay = this.getDependsOnDisplay(tgt.dependsOn, s);
2000
+ if (depDisplay) {
2001
+ depTd.tx(" (" + depDisplay + ")");
2002
+ }
2003
+ }
2004
+ }
2005
+
2006
+ first = false;
2007
+
2008
+ // Relationship cell
2009
+ if (hasRelationships) {
2010
+ this.renderConceptMapRelationship(tr, tgt);
2011
+ }
2012
+
2013
+ // Target code cell
2014
+ const tgtTd = tr.td().style("border-right-width: 0px");
2015
+ if (targets.code.size === 1) {
2016
+ tgtTd.tx(tgt.code || '');
2017
+ } else {
2018
+ tgtTd.tx((grp.target || '') + " / " + (tgt.code || ''));
2019
+ }
2020
+ const tgtDisplay = tgt.display || await this.getDisplayForConcept(grp.target, tgt.code);
2021
+ tr.td().style("border-left-width: 0px").tx(tgtDisplay || '');
2022
+
2023
+ // Target product columns
2024
+ for (const s of Object.keys(targets)) {
2025
+ if (s !== 'code') {
2026
+ const prodTd = tr.td();
2027
+ const val = this.getDependsOnValue(tgt.product, s, targets[s].size !== 1);
2028
+ prodTd.tx(val || '');
2029
+ const prodDisplay = this.getDependsOnDisplay(tgt.product, s);
2030
+ if (prodDisplay) {
2031
+ prodTd.tx(" (" + prodDisplay + ")");
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ // Comment cell
2037
+ if (hasComment) {
2038
+ tr.td().tx(tgt.comment || '');
2039
+ }
2040
+
2041
+ // Property cells
2042
+ if (hasProperties) {
2043
+ for (const s of Object.keys(props)) {
2044
+ const propTd = tr.td();
2045
+ propTd.tx(this.getPropertyValueFromList(tgt.property, s));
2046
+ }
2047
+ }
2048
+ }
2049
+ }
2050
+ }
2051
+ this.addUnmapped(tbl, grp);
2052
+ }
2053
+
2054
+ /**
2055
+ * Render the relationship cell for a target element.
2056
+ * Handles both R5 relationship codes and legacy R4 equivalence codes via extension.
2057
+ */
2058
+ renderConceptMapRelationship(tr, tgt) {
2059
+ if (tgt.relationship) {
2060
+ tr.td().tx(this.presentRelationshipCode(tgt.relationship));
2061
+ } else if (tgt.equivalence) {
2062
+ tr.td().tx(this.presentEquivalenceCode(tgt.equivalence));
2063
+ } else {
2064
+ tr.td().tx("(" + "equivalent" + ")");
2065
+ }
2066
+ }
2067
+
2068
+ /**
2069
+ * Render a code system details link in a header cell.
2070
+ * Mirrors Java renderCSDetailsLink.
2071
+ */
2072
+ async renderCSDetailsLink(tr, url, span2) {
2073
+ const td = tr.td();
2074
+ if (span2) {
2075
+ td.colspan("2");
2076
+ }
2077
+ td.b().tx(this.translate('CONC_MAP_CODES'));
2078
+ td.tx(" " + this.translate('CONC_MAP_FRM') + " ");
2079
+ const linkinfo = this.linkResolver ? await this.linkResolver.resolveURL(this.opContext, url) : null;
2080
+ if (linkinfo) {
2081
+ td.ah(linkinfo.link).tx(linkinfo.description);
2082
+ } else {
2083
+ td.tx(url);
2084
+ }
2085
+ }
2086
+
2087
+ /**
2088
+ * Translate a FHIR R5 ConceptMap relationship code to a human-readable string.
2089
+ * Uses the same strings as the Java renderer.
2090
+ */
2091
+ presentRelationshipCode(code) {
2092
+ switch (code) {
2093
+ case 'related-to': return 'is related to';
2094
+ case 'equivalent': return 'is equivalent to';
2095
+ case 'source-is-narrower-than-target': return 'is narrower than';
2096
+ case 'source-is-broader-than-target': return 'is broader than';
2097
+ case 'not-related-to': return 'is not related to';
2098
+ default: return code;
2099
+ }
2100
+ }
2101
+
2102
+ /**
2103
+ * Translate a legacy (R2/R3/R4) ConceptMap equivalence code to a human-readable string.
2104
+ * Uses the same strings as the Java renderer.
2105
+ */
2106
+ presentEquivalenceCode(code) {
2107
+ switch (code) {
2108
+ case 'relatedto': return 'is related to';
2109
+ case 'equivalent': return 'is equivalent to';
2110
+ case 'equal': return 'is equal to';
2111
+ case 'wider': return 'maps to wider concept';
2112
+ case 'subsumes': return 'is subsumed by';
2113
+ case 'narrower': return 'maps to narrower concept';
2114
+ case 'specializes': return 'has specialization';
2115
+ case 'inexact': return 'maps loosely to';
2116
+ case 'unmatched': return 'has no match';
2117
+ case 'disjoint': return 'is not related to';
2118
+ default: return code;
2119
+ }
2120
+ }
2121
+
2122
+ /**
2123
+ * Check if a code and its display text are essentially the same
2124
+ * (ignoring spaces, hyphens, and case).
2125
+ */
2126
+ isSameCodeAndDisplay(code, display) {
2127
+ if (!code || !display) return false;
2128
+ const c = code.replace(/[ -]/g, '').toLowerCase();
2129
+ const d = display.replace(/[ -]/g, '').toLowerCase();
2130
+ return c === d;
2131
+ }
2132
+
2133
+ /**
2134
+ * Look up a display string for a concept. Delegates to the linkResolver if available.
2135
+ */
2136
+ async getDisplayForConcept(system, code) {
2137
+ if (!system || !code) return null;
2138
+ if (!this.linkResolver) return null;
2139
+ const result = await this.linkResolver.resolveCode(this.opContext, system, null, code);
2140
+ return result ? result.description : null;
2141
+ }
2142
+
2143
+ /**
2144
+ * Get a description for a concept attribute code (used in complex table headers).
2145
+ * Mirrors Java getDescForConcept.
2146
+ */
2147
+ getDescForConcept(s) {
2148
+ if (s.startsWith('http://hl7.org/fhir/v2/element/')) {
2149
+ return 'v2 ' + s.substring('http://hl7.org/fhir/v2/element/'.length);
2150
+ }
2151
+ return s;
2152
+ }
2153
+
2154
+ /**
2155
+ * Extract a value from a dependsOn or product list by attribute name.
2156
+ */
2157
+ getDependsOnValue(list, attribute) {
2158
+ if (!list) return null;
2159
+ for (const item of list) {
2160
+ if (item.attribute === attribute) {
2161
+ // R5 uses value[x], try common types
2162
+ if (item.valueCode) return item.valueCode;
2163
+ if (item.valueString) return item.valueString;
2164
+ if (item.valueCoding) return item.valueCoding.code || '';
2165
+ if (item.value) return String(item.value);
2166
+ }
2167
+ }
2168
+ return null;
2169
+ }
2170
+
2171
+ /**
2172
+ * Extract a display from a dependsOn or product list by attribute name.
2173
+ */
2174
+ // eslint-disable-next-line no-unused-vars
2175
+ getDependsOnDisplay(list, attribute) {
2176
+ // In current FHIR, dependsOn display is not directly available;
2177
+ // would require a lookup. Return null for now (matches Java which also returns null).
2178
+ return null;
2179
+ }
2180
+
2181
+ /**
2182
+ * Extract a property value from a target's property list by code.
2183
+ */
2184
+ getPropertyValueFromList(list, code) {
2185
+ if (!list) return '';
2186
+ const results = [];
2187
+ for (const item of list) {
2188
+ if (item.code === code) {
2189
+ // R5 MappingPropertyComponent uses value[x]
2190
+ if (item.valueCode !== undefined) results.push(item.valueCode);
2191
+ else if (item.valueString !== undefined) results.push(item.valueString);
2192
+ else if (item.valueCoding !== undefined) results.push(item.valueCoding.code || '');
2193
+ else if (item.valueBoolean !== undefined) results.push(String(item.valueBoolean));
2194
+ else if (item.valueInteger !== undefined) results.push(String(item.valueInteger));
2195
+ else if (item.valueDecimal !== undefined) results.push(String(item.valueDecimal));
2196
+ else if (item.valueDateTime !== undefined) results.push(item.valueDateTime);
2197
+ }
2198
+ }
2199
+ return results.join(', ');
2200
+ }
2201
+
2202
+ /**
2203
+ * Render the unmapped section for a group, if present.
2204
+ * Currently a stub matching the Java implementation.
2205
+ */
2206
+ addUnmapped(tbl, grp) {
2207
+ if (grp.unmapped) {
2208
+ // TODO: render unmapped mode/code/url when needed
2209
+ }
2210
+ }
2211
+
1643
2212
  }
1644
2213
 
1645
2214
  module.exports = { Renderer };
package/tx/tx-html.js CHANGED
@@ -18,6 +18,7 @@ const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml");
18
18
  const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml");
19
19
  const {ParametersXML} = require("./xml/parameters-xml");
20
20
  const {OperationOutcomeXML} = require("./xml/operationoutcome-xml");
21
+ const {debugLog} = require("./operation-context");
21
22
 
22
23
  const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' });
23
24
 
@@ -312,7 +313,7 @@ class TxHtmlRenderer {
312
313
  return await this.renderValueSet(json, inBundle, _fmt, op, exp);
313
314
  }
314
315
  case 'ConceptMap':
315
- return await this.renderConceptMap(json, inBundle);
316
+ return await this.renderConceptMap(json, inBundle, _fmt, op);
316
317
  case 'CapabilityStatement':
317
318
  return await this.renderCapabilityStatement(json, inBundle);
318
319
  case 'TerminologyCapabilities':
@@ -327,6 +328,7 @@ class TxHtmlRenderer {
327
328
  return await this.renderGeneric(json, inBundle);
328
329
  }
329
330
  } catch (error) {
331
+ debugLog(error);
330
332
  console.error(error);
331
333
  throw error;
332
334
  }
@@ -698,8 +700,51 @@ class TxHtmlRenderer {
698
700
  * Render ConceptMap resource
699
701
  */
700
702
  // eslint-disable-next-line no-unused-vars
701
- async renderConceptMap(json, inBundle) {
702
- return this.renderResourceWithNarrative(json);
703
+ async renderConceptMap(json, inBundle, _fmt, op) {
704
+ if (inBundle || op) {
705
+ return await this.renderResourceWithNarrative(json, await this.renderer.renderConceptMap(json));
706
+ } else {
707
+ let html = `<ul class="nav nav-tabs">`;
708
+ html += this.tab(!_fmt || _fmt == 'html', json.resourceType, json.resourceType, 'html', json.id);
709
+ html += this.tab(_fmt && _fmt == 'html/json', 'JSON', json.resourceType, 'html/json', json.id);
710
+ html += this.tab(_fmt && _fmt == 'html/narrative', 'Original Narrative', json.resourceType, 'html/narrative', json.id);
711
+ html += this.tab(_fmt && _fmt == 'html/ops', 'Translate', json.resourceType, 'html/ops', json.id);
712
+ html += `</ul>`;
713
+
714
+ if (!_fmt || _fmt == 'html') {
715
+ html += await this.renderResourceWithNarrative(json, await this.renderer.renderConceptMap(json));
716
+ } else if (_fmt == "html/json") {
717
+ html += await this.renderResourceJson(json);
718
+ } else if (_fmt == "html/narrative") {
719
+ html += await this.renderResourceWithNarrative(json, json.text?.div);
720
+ } else if (_fmt == "html/ops") {
721
+ const sourceSet = new Set();
722
+ const codeSet = new Set();
723
+ const targetSet = new Set();
724
+
725
+ for (const grp of json.group || []) {
726
+ if (grp.source) sourceSet.add(`<option value="${escape(grp.source)}">${escape(grp.source)}</option>`);
727
+ if (grp.target) targetSet.add(`<option value="${escape(grp.target)}">${escape(grp.target)}</option>`);
728
+ for (const elem of grp.element || []) {
729
+ if (elem.code) codeSet.add(`<option value="${escape(elem.code)}">${escape(elem.code)}</option>`);
730
+ }
731
+ }
732
+ const sources = [...sourceSet];
733
+ const codes = [...codeSet];
734
+ const targets = [...targetSet];
735
+
736
+ html += await this.liquid.renderFile('conceptmap-operations', {
737
+ opsId: this.generateResourceId(),
738
+ cmSystemId: this.generateResourceId(),
739
+ inferSystemId: this.generateResourceId(),
740
+ sources: sources,
741
+ codes: codes,
742
+ targets: targets,
743
+ url: escape(json.url || '')
744
+ });
745
+ }
746
+ return html;
747
+ }
703
748
  }
704
749
 
705
750
  /**
@@ -47,14 +47,7 @@ class ReadWorker extends TerminologyWorker {
47
47
  return await this.handleValueSet(req, res, id);
48
48
 
49
49
  case 'ConceptMap':
50
- return res.status(501).json({
51
- resourceType: 'OperationOutcome',
52
- issue: [{
53
- severity: 'error',
54
- code: 'not-supported',
55
- diagnostics: 'ConceptMap read not yet implemented'
56
- }]
57
- });
50
+ return await this.handleConceptMap(req, res, id);
58
51
 
59
52
  default:
60
53
  return res.status(404).json({
@@ -152,6 +145,28 @@ class ReadWorker extends TerminologyWorker {
152
145
  }
153
146
  }
154
147
 
148
+ return res.status(404).json({
149
+ resourceType: 'OperationOutcome',
150
+ issue: [{
151
+ severity: 'error',
152
+ code: 'not-found',
153
+ diagnostics: `ValueSet/${id} not found`
154
+ }]
155
+ });
156
+ }
157
+ /**
158
+ * Handle ConceptMap read
159
+ */
160
+ async handleConceptMap(req, res, id) {
161
+ // Iterate through valueSetProviders in order
162
+ for (const cmsp of this.provider.conceptMapProviders) {
163
+ this.deadCheck('handleConceptMap-loop');
164
+ const vs = await cmsp.fetchConceptMapById(id);
165
+ if (vs) {
166
+ return res.json(vs.jsonObj);
167
+ }
168
+ }
169
+
155
170
  return res.status(404).json({
156
171
  resourceType: 'OperationOutcome',
157
172
  issue: [{
@@ -96,7 +96,7 @@ class SearchWorker extends TerminologyWorker {
96
96
 
97
97
  case 'ConceptMap':
98
98
  // Not implemented yet - return empty set
99
- matches = [];
99
+ matches = await this.searchConceptMaps(params, elements);
100
100
  break;
101
101
 
102
102
  default:
@@ -235,6 +235,41 @@ class SearchWorker extends TerminologyWorker {
235
235
  return allMatches;
236
236
  }
237
237
 
238
+ /**
239
+ * Search ConceptMaps by delegating to providers
240
+ */
241
+ async searchConceptMaps(params, elements) {
242
+ const allMatches = [];
243
+
244
+ // Convert params object to array format expected by ValueSet providers
245
+ // Exclude control params (_offset, _count, _elements, _sort)
246
+ const searchParams = [];
247
+ let source = null;
248
+ for (const [key, value] of Object.entries(params)) {
249
+ if (!key.startsWith('_') && value && SearchWorker.ALLOWED_PARAMS.includes(key)) {
250
+ searchParams.push({ name: key, value: value });
251
+ }
252
+ if (key == 'source') {
253
+ source = value;
254
+ }
255
+ }
256
+
257
+ for (const cmsp of this.provider.conceptMapProviders) {
258
+ if (!source || source == cmsp.sourcePackage()) {
259
+ this.deadCheck('searchConceptMaps-providers');
260
+ const results = await cmsp.searchConceptMaps(searchParams, elements);
261
+ if (results && Array.isArray(results)) {
262
+ for (const vs of results) {
263
+ this.deadCheck('searchConceptMaps-results');
264
+ allMatches.push(vs.jsonObj || vs);
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ return allMatches;
271
+ }
272
+
238
273
  /**
239
274
  * Check if a value matches the search term (partial, case-insensitive)
240
275
  */
@@ -944,13 +944,13 @@ class ValueSetChecker {
944
944
  if (inactive.value) {
945
945
  result.AddParamBool('inactive', inactive.value);
946
946
  if (vstatus.value && vstatus.value !== 'inactive') {
947
- result.addParamStr('status', vstatus.value);
947
+ result.addParamCode('status', vstatus.value);
948
948
  }
949
949
  let msg = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, coding.code]);
950
950
  messages.push(msg);
951
951
  op.addIssue(new Issue('warning', 'business-rule', path, 'INACTIVE_CONCEPT_FOUND', msg, 'code-comment'));
952
952
  } else if (vstatus.value.toLowerCase() === 'deprecated') {
953
- result.addParamStr('status', vstatus.value);
953
+ result.addParamCode('status', vstatus.value);
954
954
  let msg = this.worker.i18n.translate('DEPRECATED_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, coding.code]);
955
955
  messages.push(msg);
956
956
  op.addIssue(new Issue('warning', 'business-rule', path, 'DEPRECATED_CONCEPT_FOUND', msg, 'code-comment'));
@@ -1348,13 +1348,18 @@ class ValueSetChecker {
1348
1348
  if (inactive.value) {
1349
1349
  result.addParamBool('inactive', inactive.value);
1350
1350
  if (vstatus.value && vstatus.value !== 'inactive') {
1351
- result.addParamStr('status', vstatus.value);
1351
+ result.addParamCode('status', vstatus.value);
1352
+ }
1353
+ if (!['inactive', 'DISCOURAGED'].includes(vstatus.value)) {
1354
+ let m = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, ['inactive', tcode]);
1355
+ msg(m);
1356
+ op.addIssue(new Issue('warning', 'business-rule', inactive.path, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment'));
1352
1357
  }
1353
1358
  let m = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, tcode]);
1354
1359
  msg(m);
1355
1360
  op.addIssue(new Issue('warning', 'business-rule', inactive.path, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment'));
1356
1361
  } else if (vstatus.value && vstatus.value.toLowerCase() === 'deprecated') {
1357
- result.addParamStr('status', 'deprecated');
1362
+ result.addParamCode('status', 'deprecated');
1358
1363
  let m = this.worker.i18n.translate('DEPRECATED_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, tcode]);
1359
1364
  msg(m);
1360
1365
  op.addIssue(new Issue('warning', 'business-rule', issuePath, 'DEPRECATED_CONCEPT_FOUND', m, 'code-comment'));
@@ -1494,13 +1499,13 @@ class ValueSetChecker {
1494
1499
  if (inactive.value) {
1495
1500
  result.addParamBool('inactive', inactive.value);
1496
1501
  if (vstatus.value && vstatus.value !== 'inactive') {
1497
- result.addParamStr('status', vstatus.value);
1502
+ result.addParamCode('status', vstatus.value);
1498
1503
  }
1499
1504
  let msg = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, code]);
1500
1505
  messages.push(msg);
1501
1506
  op.addIssue(new Issue('warning', 'business-rule', 'code', 'INACTIVE_CONCEPT_FOUND', msg, 'code-comment'));
1502
1507
  } else if (vstatus.value.toLowerCase() === 'deprecated') {
1503
- result.addParamStr('status', vstatus.value);
1508
+ result.addParamCode('status', vstatus.value);
1504
1509
  let msg = this.worker.i18n.translate('DEPRECATED_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, code]);
1505
1510
  messages.push(msg);
1506
1511
  op.addIssue(new Issue('warning', 'business-rule', 'code', 'DEPRECATED_CONCEPT_FOUND', msg, 'code-comment'));