Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 405 additions & 9 deletions tests/ocl/ocl-cm-provider.test.js

Large diffs are not rendered by default.

371 changes: 371 additions & 0 deletions tests/ocl/ocl-display-language.test.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/tx/translate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('Translate Worker', () => {
expect(response.status).toBe(400);
expect(response.body.resourceType).toBe('OperationOutcome');
expect(response.body.issue[0].code).toBe('invalid');
expect(response.body.issue[0].details.text).toContain('sourceSystem');
expect(response.body.issue[0].details.text).toContain('system parameter is required');
});

test('should return 400 when no source code/coding provided', async () => {
Expand Down
4 changes: 3 additions & 1 deletion tx/library/conceptmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ class ConceptMap extends CanonicalResource {

let all = this.canonicalMatches(targetScope, this.targetScope);
for (const g of this.jsonObj.group || []) {
if (all || (this.canonicalMatches(vurl, g.source) && this.canonicalMatches(targetSystem, g.target) )) {
const sourceOk = this.canonicalMatches(vurl, g.source);
const targetOk = !targetSystem || this.canonicalMatches(targetSystem, g.target);
if (all || (sourceOk && targetOk)) {
for (const em of g.element || []) {
if (em.code === coding.code) {
let match = {
Expand Down
18 changes: 9 additions & 9 deletions tx/library/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1800,12 +1800,12 @@ class Renderer {
if (elem.noMap) {
const nomapComment = Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment');
if (!hasComment) {
tr.td().colspan("2").style("background-color: #efefef").tx("(not mapped)");
tr.td().setAttribute('colspan',"2").style("background-color: #efefef").tx("(not mapped)");
} else if (nomapComment) {
tr.td().colspan("2").style("background-color: #efefef").tx("(not mapped)");
tr.td().setAttribute('colspan',"2").style("background-color: #efefef").tx("(not mapped)");
tr.td().style("background-color: #efefef").tx(nomapComment);
} else {
tr.td().colspan("3").style("background-color: #efefef").tx("(not mapped)");
tr.td().setAttribute('colspan',"3").style("background-color: #efefef").tx("(not mapped)");
}
} else {
let first = true;
Expand Down Expand Up @@ -1858,16 +1858,16 @@ class Renderer {
let tr = tbl.tr();
const sourceColCount = 1 + Object.keys(sources).length - 1; // code + dependsOn attributes
const targetColCount = 1 + Object.keys(targets).length - 1; // code + product attributes
tr.td().colspan(String(sourceColCount + 1)).b().tx(this.translate('CONC_MAP_SRC_DET'));
tr.td().setAttribute('colspan', String(sourceColCount + 1)).b().tx(this.translate('CONC_MAP_SRC_DET'));
if (hasRelationships) {
tr.td().b().tx(this.translate('CONC_MAP_REL'));
}
tr.td().colspan(String(targetColCount + 1)).b().tx(this.translate('CONC_MAP_TRGT_DET'));
tr.td().setAttribute('colspan', String(targetColCount + 1)).b().tx(this.translate('CONC_MAP_TRGT_DET'));
if (hasComment) {
tr.td().b().tx(this.translate('GENERAL_COMMENT'));
}
if (hasProperties) {
tr.td().colspan(String(Object.keys(props).length)).b().tx(this.translate('GENERAL_PROPS'));
tr.td().setAttribute('colspan', String(Object.keys(props).length)).b().tx(this.translate('GENERAL_PROPS'));
}

// Second header row: actual column headers
Expand Down Expand Up @@ -1944,10 +1944,10 @@ class Renderer {

const nomapComment = Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment');
if (nomapComment) {
tr.td().colspan("3").style("background-color: #efefef").tx("(not mapped)");
tr.td().setAttribute('colspan',"3").style("background-color: #efefef").tx("(not mapped)");
tr.td().style("background-color: #efefef").tx(nomapComment);
} else {
tr.td().colspan("4").style("background-color: #efefef").tx("(not mapped)");
tr.td().setAttribute('colspan',"4").style("background-color: #efefef").tx("(not mapped)");
}
} else {
let first = true;
Expand Down Expand Up @@ -2072,7 +2072,7 @@ class Renderer {
async renderCSDetailsLink(tr, url, span2) {
const td = tr.td();
if (span2) {
td.colspan("2");
td.setAttribute('colspan',"2");
}
td.b().tx(this.translate('CONC_MAP_CODES'));
td.tx(" " + this.translate('CONC_MAP_FRM') + " ");
Expand Down
250 changes: 185 additions & 65 deletions tx/ocl/cm-ocl.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,19 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
return await this.fetchConceptMapById(mappingId);
}

const mappings = await this.#searchMappings({ from_source_url: url }, this.maxSearchPages);
for (const mapping of mappings) {
const cm = this.#toConceptMap(mapping);
if (cm) {
this.#indexConceptMap(cm);
if (cm.url === url && (!version || cm.version === version)) {
return cm;
try {
const mappings = await this.#searchMappings({ from_source_url: url }, this.maxSearchPages);
for (const mapping of mappings) {
const cm = this.#toConceptMap(mapping);
if (cm) {
this.#indexConceptMap(cm);
if (cm.url === url && (!version || cm.version === version)) {
return cm;
}
}
}
} catch (_err) {
// OCL API unreachable or returned error — treat as not found
}

return null;
Expand All @@ -87,45 +91,194 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
return this._idMap.get(rawId);
}

const response = await this.httpClient.get(`/mappings/${encodeURIComponent(rawId)}/`);
const cm = this.#toConceptMap(response.data);
if (!cm) {
try {
const response = await this.httpClient.get(`/mappings/${encodeURIComponent(rawId)}/`);
const cm = this.#toConceptMap(response.data);
if (!cm) {
return null;
}
this.#indexConceptMap(cm);
return cm;
} catch (_err) {
return null;
}
this.#indexConceptMap(cm);
return cm;
}

// eslint-disable-next-line no-unused-vars
async searchConceptMaps(searchParams, _elements) {
this._validateSearchParams(searchParams);

const params = Object.fromEntries(searchParams.map(({ name, value }) => [name, String(value).toLowerCase()]));
const oclParams = {};
const params = Object.fromEntries(
searchParams.map(({ name, value }) => [name, String(value).toLowerCase()])
);
const sourceSystem = params['source-system'] || params.source || null;
const targetSystem = params['target-system'] || params.target || null;

if (params.source) {
oclParams.from_source_url = params.source;
// Without a source or target filter the search would have to fetch every
// mapping in the organisation — too expensive. Return empty so the
// Package providers can still answer generic ConceptMap listings.
if (!sourceSystem && !targetSystem) {
return [];
}

try {
const allMappings = await this.#collectMappingsForSearch(sourceSystem, targetSystem);
return this.#aggregateMappingsToConceptMaps(allMappings);
} catch (_err) {
return [];
}
if (params.target) {
oclParams.to_source_url = params.target;
}

async #collectMappingsForSearch(sourceSystem, targetSystem) {
const systemUrl = sourceSystem || targetSystem;
const candidates = await this.#candidateSourceUrls(systemUrl);
const sourcePaths = candidates.filter(s => String(s || '').startsWith('/orgs/'));

if (sourcePaths.length === 0) {
return [];
}

const mappings = await this.#searchMappings(oclParams, this.maxSearchPages);
const results = [];
const allMappings = [];
for (const sourcePath of sourcePaths) {
const normalizedPath = this.#normalizeSourcePath(sourcePath);
let concepts;
try {
concepts = await this.#fetchAllPages(
`${normalizedPath}concepts/`, { limit: PAGE_SIZE }, this.maxSearchPages
);
} catch (_err) {
continue;
}

for (const concept of concepts) {
const code = concept.id || concept.mnemonic;
if (!code) {
continue;
}
try {
const mappings = await this.#fetchAllPages(
`${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`,
{ limit: PAGE_SIZE }, 2
);
allMappings.push(...mappings);
} catch (_err) {
// concept has no mappings or endpoint inaccessible — skip
}
}
}

const sourceUrlsToResolve = new Set();
for (const m of allMappings) {
const from = m?.from_source_url || m?.fromSourceUrl;
const to = m?.to_source_url || m?.toSourceUrl;
if (from) sourceUrlsToResolve.add(from);
if (to) sourceUrlsToResolve.add(to);
}
await this.#ensureCanonicalForSourceUrls(sourceUrlsToResolve);

return allMappings;
}

#aggregateMappingsToConceptMaps(mappings) {
const groups = new Map();

for (const mapping of mappings) {
const cm = this.#toConceptMap(mapping);
if (!cm) {
const fromSource = mapping.from_source_url || mapping.fromSourceUrl || null;
const toSource = mapping.to_source_url || mapping.toSourceUrl || null;
const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
const targetCode = mapping.to_concept_code || mapping.toConceptCode;

if (!fromSource || !toSource || !sourceCode || !targetCode) {
continue;
}
this.#indexConceptMap(cm);
if (this.#matches(cm.jsonObj, params)) {
results.push(cm);

const sourceCanonical = this.#canonicalForSourceUrl(fromSource) || fromSource;
const targetCanonical = this.#canonicalForSourceUrl(toSource) || toSource;
const groupKey = `${this.#norm(sourceCanonical)}|${this.#norm(targetCanonical)}`;

if (!groups.has(groupKey)) {
groups.set(groupKey, {
sourceCanonical, targetCanonical, elements: new Map(), lastUpdated: null
});
}

const group = groups.get(groupKey);
const ts = this.#toIsoDate(
mapping.updated_on || mapping.updatedOn || mapping.updated_at || mapping.updatedAt
);
if (ts && (!group.lastUpdated || ts > group.lastUpdated)) {
group.lastUpdated = ts;
}

if (!group.elements.has(sourceCode)) {
group.elements.set(sourceCode, {
code: sourceCode,
display: mapping.from_concept_name_resolved || mapping.fromConceptNameResolved
|| mapping.from_concept_name || mapping.fromConceptName || null,
targets: []
});
}

group.elements.get(sourceCode).targets.push({
code: targetCode,
display: mapping.to_concept_name_resolved || mapping.toConceptNameResolved
|| mapping.to_concept_name || mapping.toConceptName || null,
relationship: this.#toRelationship(mapping.map_type || mapping.mapType),
comment: mapping.comment || null
});
}

const results = [];
for (const [, group] of groups) {
const sourceId = this.#lastSegment(group.sourceCanonical);
const targetId = this.#lastSegment(group.targetCanonical);
const id = `${sourceId}-to-${targetId}`;

const elements = [];
for (const [, el] of group.elements) {
elements.push({ code: el.code, display: el.display, target: el.targets });
}

const json = {
resourceType: 'ConceptMap',
id,
url: `${this.baseUrl}/ConceptMap/${id}`,
name: id,
title: `${sourceId} to ${targetId}`,
status: 'active',
sourceScopeUri: group.sourceCanonical,
targetScopeUri: group.targetCanonical,
group: [{
source: group.sourceCanonical,
target: group.targetCanonical,
element: elements
}]
};
if (group.lastUpdated) {
json.meta = { lastUpdated: group.lastUpdated };
}

const cm = new ConceptMap(json, 'R5');
this.#indexConceptMap(cm);
results.push(cm);
}
return results;
}

#lastSegment(canonical) {
const raw = String(canonical || '').trim().replace(/\/+$/, '');
const slash = raw.lastIndexOf('/');
return slash >= 0 && slash < raw.length - 1 ? raw.substring(slash + 1) : raw;
}

async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode = null) {
try {
await this.#doFindConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode);
} catch (_err) {
// OCL API errors must not break $translate for other providers
}
}

async #doFindConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode) {
const sourceCandidates = await this.#candidateSourceUrls(sourceSystem);
const targetCandidates = await this.#candidateSourceUrls(targetSystem);

Expand All @@ -135,8 +288,12 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
if (sourceCode && sourcePaths.length > 0) {
for (const sourcePath of sourcePaths) {
const conceptPath = `${this.#normalizeSourcePath(sourcePath)}concepts/${encodeURIComponent(sourceCode)}/mappings/`;
const found = await this.#fetchAllPages(conceptPath, { limit: PAGE_SIZE }, Math.min(2, this.maxSearchPages));
mappings.push(...found);
try {
const found = await this.#fetchAllPages(conceptPath, { limit: PAGE_SIZE }, Math.min(2, this.maxSearchPages));
mappings.push(...found);
} catch (_err) {
// concept not found or mappings endpoint unavailable
}
}
}

Expand Down Expand Up @@ -311,43 +468,6 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
}
}

#matches(json, params) {
for (const [name, value] of Object.entries(params)) {
if (!value) {
continue;
}

if (name === 'url') {
if ((json.url || '').toLowerCase() !== value) {
return false;
}
continue;
}

if (name === 'source') {
const src = json.group?.[0]?.source || '';
if (!src.toLowerCase().includes(value)) {
return false;
}
continue;
}

if (name === 'target') {
const tgt = json.group?.[0]?.target || '';
if (!tgt.toLowerCase().includes(value)) {
return false;
}
continue;
}

const field = json[name];
if (field == null || !String(field).toLowerCase().includes(value)) {
return false;
}
}
return true;
}

async #searchMappings(params = {}, maxPages = this.maxSearchPages) {
const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/mappings/` : '/mappings/';
return await this.#fetchAllPages(endpoint, params, maxPages);
Expand Down
Loading
Loading