From 54c98e00c2d12828266547063f8259d41c0360ce Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 10 Apr 2026 12:56:18 +1000 Subject: [PATCH 1/5] fix handling of child-of filters, and fix support for child-of in R4/R3 --- translations/Messages.properties | 4 ++-- tx/cs/cs-cs.js | 37 +++++++++++++++++++++++++++----- tx/library/extensions.js | 8 ++++++- tx/library/renderer.js | 13 +++++++++-- tx/xversion/xv-valueset.js | 36 ++++++++++++++++++++++++------- 5 files changed, 80 insertions(+), 18 deletions(-) diff --git a/translations/Messages.properties b/translations/Messages.properties index 326f90c1..9f8314fb 100644 --- a/translations/Messages.properties +++ b/translations/Messages.properties @@ -1298,7 +1298,7 @@ VIEWDEFINITION_SHOULD_HAVE_NAME = No name provided. A name is required in many c VIEWDEFINITION_TYPE_MISMATCH = The path expression ''{0}'' does not return a value of the type ''{1}'' - found ''{2}''{3} VIEWDEFINITION_UNABLE_TO_TYPE = Unable to determine a type (found ''{0}''){1} VIEWDEFINITION_UNKNOWN_RESOURCE = The name ''{0}'' is not a valid resource{1} -VS_EXP_FILTER_UNK = ValueSet ''{0}'' Filter by property ''{1}'' and op ''{2}'' is not supported yet +VS_EXP_FILTER_UNK = ValueSet ''{0}'' Filter by property ''{1}'' and op ''{2}'' is not supported (yet?) VS_EXP_IMPORT_CS = Cannot include value set ''{0}'' because it''s actually a code system VS_EXP_IMPORT_CS_PINNED = Cannot include value set ''{0}'' version ''{1}'' because it''s actually a code system VS_EXP_IMPORT_CS_PINNED_X = Cannot exclude value set ''{0}'' version ''{1}'' because it''s actually a code system @@ -1400,7 +1400,7 @@ TEXT_LINK_NO_DATA = No data element was found in the textLink extension TEXT_LINK_SELECTOR_INVALID = The textLink selector ''{0}'' is invalid: {1} SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND = The element {0} is not valid SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND_VER = The element {0} is not valid in version {1} -FILTER_NOT_UNDERSTOOD = The filter "{0} {1} {2}" from the value set {3} was not understood in the context of {4} +FILTER_NOT_UNDERSTOOD = The filter "{0} {1} {2}" is not understood or supported XHTML_CONTROL_NO_SOURCE = The xhtml node at ''{0}'' does not have a class attribute to indicate its source (boilerplate, generated, or original) and this is required by the profile {1} XHTML_XHTML_MIXED_LANG = The xhtml has some language sections ({0}), and also has content that is not in a language section XHTML_CONTROL_NO_LANGS = The xhtml has some language sections ({0}), but language sections are prohibited in this context by the profile {1} diff --git a/tx/cs/cs-cs.js b/tx/cs/cs-cs.js index 3a9721c6..20d3e5ce 100644 --- a/tx/cs/cs-cs.js +++ b/tx/cs/cs-cs.js @@ -1224,7 +1224,7 @@ class FhirCodeSystemProvider extends BaseCSServices { // Handle concept/code hierarchy filters if ((prop === 'concept' || prop === 'code')) { - results = await this._handleConceptFilter(filterContext, op, value); + results = await this._handleConceptFilter(filterContext, prop, op, value); } // Handle child existence filter @@ -1248,7 +1248,8 @@ class FhirCodeSystemProvider extends BaseCSServices { } if (!results) { - throw new Error(`The filter ${prop} ${op} ${value} was not understood`) + throw new Issue('error', 'exception', null, 'FILTER_NOT_UNDERSTOOD', + this.opContext.i18n.translate('FILTER_NOT_UNDERSTOOD', this.opContext.langs, [prop, op, value]), 'vs-invalid', 422); } // Add to filter context if (!filterContext.filters) { @@ -1267,15 +1268,17 @@ class FhirCodeSystemProvider extends BaseCSServices { * @returns {Promise} Filter results * @private */ - async _handleConceptFilter(filterContext, op, value) { + async _handleConceptFilter(filterContext, prop, op, value) { const results = new FhirCodeSystemProviderFilterContext(); if (op === 'is-a' || op === 'descendent-of') { // Find all descendants of the specified code const includeRoot = (op === 'is-a'); await this._addDescendants(results, value, includeRoot); - } - else if (op === 'is-not-a') { + } else if (op === 'child-of') { + // Find all descendants of the specified code + await this._addChildren(results, value); + } else if (op === 'is-not-a') { // Find all concepts that are NOT descendants of the specified code const excludeDescendants = this.codeSystem.getDescendants(value); const excludeSet = new Set([value, ...excludeDescendants]); @@ -1323,6 +1326,9 @@ class FhirCodeSystemProvider extends BaseCSServices { } catch (error) { throw new Issue('error', 'exception', null, 'INVALID_REGEX', this.opContext.i18n.translate('INVALID_REGEX', this.opContext.langs, [value, error.message]), 'vs-invalid', 422); } + } else { + throw new Issue('error', 'exception', null, 'FILTER_NOT_UNDERSTOOD', + this.opContext.i18n.translate('FILTER_NOT_UNDERSTOOD', this.opContext.langs, [prop, op, value]), 'vs-invalid', 422); } return results; @@ -1353,6 +1359,27 @@ class FhirCodeSystemProvider extends BaseCSServices { } } + /** + * Add immediate children of a code to the results + * @param {FhirCodeSystemProviderFilterContext} results - Results to add to + * @param {string} ancestorCode - The parent code + * @private + */ + async _addChildren(results, parentCode) { + const concept = this.codeSystem.getConceptByCode(parentCode); + if (concept) { + const descendants = this.codeSystem.getChildren(parentCode); + for (const code of descendants) { + if (code !== parentCode) { // should not be + const concept = this.codeSystem.getConceptByCode(code); + if (concept) { + results.add(concept, 0); + } + } + } + } + } + /** * Handle child exists filter * @param {FilterExecutionContext} filterContext - Filter context diff --git a/tx/library/extensions.js b/tx/library/extensions.js index 82d1385c..f863f4f5 100644 --- a/tx/library/extensions.js +++ b/tx/library/extensions.js @@ -56,7 +56,13 @@ const Extensions = { if (!resource) { return undefined; } - const extensions = Array.isArray(resource) ? resource : (resource.extension || []); + let extensions = Array.isArray(resource) ? resource : (resource.extension || []); + for (let ext of extensions || []) { + if (ext.url === url) { + return getValuePrimitive(ext); + } + } + extensions = Array.isArray(resource) ? resource : (resource.modifierExtension || []); for (let ext of extensions || []) { if (ext.url === url) { return getValuePrimitive(ext); diff --git a/tx/library/renderer.js b/tx/library/renderer.js index 9b0c69d8..f7512995 100644 --- a/tx/library/renderer.js +++ b/tx/library/renderer.js @@ -466,14 +466,15 @@ class Renderer { li.tx(" "+ this.translate('VALUE_SET_WHERE')+" "); li.startCommaList("and"); for (let f of inc.filter) { - if (f.op == 'exists') { + let op = this.readFilterOp(f); + if (op == 'exists') { if (f.value == "true") { li.commaItem(f.property+" "+ this.translate('VALUE_SET_EXISTS')); } else { li.commaItem(f.property+" "+ this.translate('VALUE_SET_DOESNT_EXIST')); } } else { - li.commaItem(f.property + " " + f.op + " "); + li.commaItem(f.property + " " + op + " "); const loc = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, f.value) : null; if (loc) { li.ah(loc.link).tx(loc.description); @@ -2243,6 +2244,14 @@ class Renderer { return defn+' = ' + value; } } + + readFilterOp(f) { + if (f._op) { + return Extensions.readString(f._op, 'http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op'); + } else { + return f.op; + } + } } module.exports = { Renderer }; diff --git a/tx/xversion/xv-valueset.js b/tx/xversion/xv-valueset.js index b6d1300a..7d6b8d1c 100644 --- a/tx/xversion/xv-valueset.js +++ b/tx/xversion/xv-valueset.js @@ -1,5 +1,6 @@ const {VersionUtilities} = require("../../library/version-utilities"); const {getValueName} = require("../../library/utilities"); +const {Extensions} = require("../library/extensions"); /** * Converts input ValueSet to R5 format (modifies input object for performance) @@ -13,6 +14,12 @@ function valueSetToR5(jsonObj, sourceVersion) { if (VersionUtilities.isR5Ver(sourceVersion)) { return jsonObj; // No conversion needed } + for (const inc of jsonObj.compose.include || []) { + valueSetIncludeToR5(inc); + } + for (const inc of jsonObj.compose.exclude || []) { + valueSetIncludeToR5(inc); + } if (VersionUtilities.isR4Ver(sourceVersion)) { return jsonObj; // No conversion needed } @@ -26,6 +33,19 @@ function valueSetToR5(jsonObj, sourceVersion) { throw new Error(`Unsupported FHIR version: ${sourceVersion}`); } +function valueSetIncludeToR5(inc) { + for (const filter of inc.filter || []) { + if (filter._op) { + let code = Extensions.readString(filter._op, 'http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op'); + if (code) { + filter.op = code; + delete filter._op; + } + } + } +} + + /** * Converts R5 ValueSet to target version format (clones object first) * @param {Object} r5Obj - The R5 format ValueSet object @@ -70,8 +90,8 @@ function valueSetR5ToR4(r5Obj) { if (include.filter && Array.isArray(include.filter)) { include.filter = include.filter.map(filter => { if (filter.op && isR5OnlyFilterOperator(filter.op)) { - // Remove R5-only operators - return null; + filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op} + delete filter.op; } return filter; }).filter(filter => filter !== null); @@ -85,8 +105,8 @@ function valueSetR5ToR4(r5Obj) { if (exclude.filter && Array.isArray(exclude.filter)) { exclude.filter = exclude.filter.map(filter => { if (filter.op && isR5OnlyFilterOperator(filter.op)) { - // Remove R5-only operators - return null; + filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op} + delete filter.op; } return filter; }).filter(filter => filter !== null); @@ -135,8 +155,8 @@ function valueSetR5ToR3(r5Obj) { if (include.filter && Array.isArray(include.filter)) { include.filter = include.filter.map(filter => { if (filter.op && !isR3CompatibleFilterOperator(filter.op)) { - // Remove non-R3-compatible operators - return null; + filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op} + delete filter.op; } return filter; }).filter(filter => filter !== null); @@ -150,8 +170,8 @@ function valueSetR5ToR3(r5Obj) { if (exclude.filter && Array.isArray(exclude.filter)) { exclude.filter = exclude.filter.map(filter => { if (filter.op && !isR3CompatibleFilterOperator(filter.op)) { - // Remove non-R3-compatible operators - return null; + filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op} + delete filter.op; } return filter; }).filter(filter => filter !== null); From 239d7df8887fcf845409bf7b8863d58567416918 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 10 Apr 2026 12:56:32 +1000 Subject: [PATCH 2/5] fix bug listing versions when validating --- tx/workers/validate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tx/workers/validate.js b/tx/workers/validate.js index 74b4818b..aded791e 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -839,7 +839,7 @@ class ValueSetChecker { } else { bAdd = !unknownSystems.has(system + '|' + version); if (bAdd) { - let vl = await this.listVersions(system); + let vl = await this.worker.listVersions(system); if (vl.length == 0) { mid = 'UNKNOWN_CODESYSTEM_VERSION_NONE'; vn = system; From 9a91cba0b8c5db86f1c5b47be8271e15ad0be831 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 10 Apr 2026 13:15:25 +1000 Subject: [PATCH 3/5] increase vsac timeout --- tx/vs/vs-vsac.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tx/vs/vs-vsac.js b/tx/vs/vs-vsac.js index faced58d..f67ea601 100644 --- a/tx/vs/vs-vsac.js +++ b/tx/vs/vs-vsac.js @@ -17,6 +17,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { * @param {string} config.cacheFolder - Local folder for cached database * @param {number} [config.refreshIntervalHours=24] - Hours between refresh scans * @param {string} [config.baseUrl='http://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server + * @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds */ constructor(config, stats) { super(); @@ -43,7 +44,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { const authString = Buffer.from(`apikey:${this.apiKey}`).toString('base64'); this.httpClient = axios.create({ baseURL: this.baseUrl, - timeout: 30000, + timeout: config.timeoutMs || 120000, headers: { 'Accept': 'application/fhir+json', 'User-Agent': 'FHIR-ValueSet-Provider/1.0', From a7c06a910016ee52e1e05262e734f05f0ef4b92d Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 10 Apr 2026 13:15:32 +1000 Subject: [PATCH 4/5] tidy up dashboard --- root-bare-template.html | 9833 +-------------------------------------- 1 file changed, 58 insertions(+), 9775 deletions(-) diff --git a/root-bare-template.html b/root-bare-template.html index fe625e8b..7c558c2b 100644 --- a/root-bare-template.html +++ b/root-bare-template.html @@ -10,9789 +10,72 @@ - - - - -
-
-
-
-
+ -

[%title%]

+

[%title%]

[%content%] -
- - -
-
-
-
- From 10ce9ff133f6223710523ff4a05fcdfe05930fa2 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 10 Apr 2026 13:35:50 +1000 Subject: [PATCH 5/5] fix security warning --- .npmrc | 2 +- package-lock.json | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.npmrc b/.npmrc index 7417ed69..78c50d20 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -min-release-age=7 \ No newline at end of file +min-release-age=3 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f63016e8..91ff55ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "fhirsmith", - "version": "0.8.6", + "version": "0.9.0", "license": "BSD-3", "dependencies": { "axios": "^1.13.4", @@ -30,7 +30,7 @@ "fs-extra": "^11.3.3", "ini": "^6.0.0", "inquirer": "^8.2.5", - "liquidjs": "^10.24.0", + "liquidjs": "^10.25.5", "lusca": "^1.7.0", "natural": "^6.12.0", "node-cron": "^3.0.3", @@ -2271,14 +2271,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-jest": { @@ -7897,10 +7897,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8",