fhirsmith 0.4.2 → 0.5.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 +12 -0
- package/README.md +1 -1
- package/library/cron-utilities.js +136 -0
- package/library/html-server.js +13 -29
- package/library/html.js +3 -8
- package/library/languages.js +160 -37
- package/library/package-manager.js +48 -1
- package/library/utilities.js +100 -19
- package/package.json +2 -2
- package/packages/package-crawler.js +6 -1
- package/packages/packages.js +38 -54
- package/publisher/publisher.js +19 -27
- package/registry/api.js +11 -10
- package/registry/crawler.js +31 -29
- package/registry/model.js +5 -26
- package/registry/registry.js +32 -41
- package/server.js +53 -5
- package/shl/shl.js +0 -18
- package/static/assets/js/statuspage.js +1 -9
- package/stats.js +39 -1
- package/token/token.js +14 -9
- package/translations/Messages.properties +2 -1
- package/tx/README.md +17 -6
- package/tx/cs/cs-api.js +19 -1
- package/tx/cs/cs-base.js +77 -0
- package/tx/cs/cs-country.js +46 -0
- package/tx/cs/cs-cpt.js +9 -5
- package/tx/cs/cs-cs.js +27 -13
- package/tx/cs/cs-lang.js +60 -22
- package/tx/cs/cs-loinc.js +69 -98
- package/tx/cs/cs-mimetypes.js +4 -0
- package/tx/cs/cs-ndc.js +6 -0
- package/tx/cs/cs-omop.js +16 -15
- package/tx/cs/cs-rxnorm.js +23 -1
- package/tx/cs/cs-snomed.js +283 -40
- package/tx/cs/cs-ucum.js +90 -70
- package/tx/importers/import-sct.module.js +371 -35
- package/tx/importers/readme.md +117 -7
- package/tx/library/bundle.js +5 -0
- package/tx/library/capabilitystatement.js +3 -142
- package/tx/library/codesystem.js +19 -173
- package/tx/library/conceptmap.js +4 -218
- package/tx/library/designations.js +14 -1
- package/tx/library/extensions.js +7 -0
- package/tx/library/namingsystem.js +3 -89
- package/tx/library/operation-outcome.js +8 -3
- package/tx/library/parameters.js +3 -2
- package/tx/library/renderer.js +10 -6
- package/tx/library/terminologycapabilities.js +3 -243
- package/tx/library/valueset.js +3 -235
- package/tx/library.js +100 -13
- package/tx/operation-context.js +23 -4
- package/tx/params.js +35 -38
- package/tx/provider.js +6 -5
- package/tx/sct/expressions.js +12 -3
- package/tx/tx-html.js +80 -89
- package/tx/tx.fhir.org.yml +6 -5
- package/tx/tx.js +163 -13
- package/tx/vs/vs-database.js +56 -39
- package/tx/vs/vs-package.js +21 -2
- package/tx/vs/vs-vsac.js +175 -39
- package/tx/workers/batch-validate.js +2 -0
- package/tx/workers/batch.js +2 -0
- package/tx/workers/expand.js +132 -112
- package/tx/workers/lookup.js +33 -14
- package/tx/workers/metadata.js +2 -2
- package/tx/workers/read.js +3 -2
- package/tx/workers/related.js +574 -0
- package/tx/workers/search.js +46 -9
- package/tx/workers/subsumes.js +13 -3
- package/tx/workers/translate.js +7 -3
- package/tx/workers/validate.js +258 -285
- package/tx/workers/worker.js +43 -39
- package/tx/xml/bundle-xml.js +237 -0
- package/tx/xml/xml-base.js +215 -64
- package/tx/xversion/xv-bundle.js +71 -0
- package/tx/xversion/xv-capabiliityStatement.js +137 -0
- package/tx/xversion/xv-codesystem.js +169 -0
- package/tx/xversion/xv-conceptmap.js +224 -0
- package/tx/xversion/xv-namingsystem.js +88 -0
- package/tx/xversion/xv-operationoutcome.js +27 -0
- package/tx/xversion/xv-parameters.js +87 -0
- package/tx/xversion/xv-resource.js +45 -0
- package/tx/xversion/xv-terminologyCapabilities.js +214 -0
- package/tx/xversion/xv-valueset.js +234 -0
- package/utilities/dev-proxy-server.js +126 -0
- package/utilities/explode-results.js +58 -0
- package/utilities/split-by-system.js +198 -0
- package/utilities/vsac-cs-fetcher.js +0 -0
- package/{windows-install.js → utilities/windows-install.js} +2 -0
- package/vcl/vcl.js +0 -18
- package/xig/xig.js +108 -99
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.5.0] - 2026-02-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Prototype Implementation of $related operation
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- A great deal of QA work preparing the server to run tx.fhir.org, which led to 100s of fixes
|
|
15
|
+
|
|
16
|
+
### Tx Conformance Statement
|
|
17
|
+
|
|
18
|
+
FHIRsmith passed all 1288 HL7 terminology service tests (modes tx.fhir.org,omop,general,snomed, tests v1.9.1-SNAPSHOT, runner v6.8.0)
|
|
19
|
+
|
|
8
20
|
## [v0.4.2] - 2026-02-05
|
|
9
21
|
### Changed
|
|
10
22
|
- Even More testing the release process; some tidy up to testing data
|
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ Available tags:
|
|
|
190
190
|
|
|
191
191
|
### Windows Installation
|
|
192
192
|
|
|
193
|
-
You can install as a windows service using [windows-install.js](windows-install.js). You might need to
|
|
193
|
+
You can install as a windows service using [windows-install.js](utilities/windows-install.js). You might need to
|
|
194
194
|
hack that.
|
|
195
195
|
|
|
196
196
|
## Releases
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a cron expression to a human-readable summary
|
|
3
|
+
* Supports standard 5-field cron: minute hour day-of-month month day-of-week
|
|
4
|
+
* @param {string} cron
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
function describeCron(cron) {
|
|
8
|
+
const parts = cron.trim().split(/\s+/);
|
|
9
|
+
if (parts.length !== 5) {
|
|
10
|
+
return `Invalid cron expression (expected 5 fields, got ${parts.length})`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
14
|
+
|
|
15
|
+
const allStar = (f) => f === '*';
|
|
16
|
+
const isStep = (f) => f.includes('/');
|
|
17
|
+
const stepVal = (f) => parseInt(f.split('/')[1]);
|
|
18
|
+
|
|
19
|
+
// Every minute: * * * * *
|
|
20
|
+
if (allStar(minute) && allStar(hour) && allStar(dom) && allStar(month) && allStar(dow)) {
|
|
21
|
+
return 'Every minute';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// */N minutes: */N * * * *
|
|
25
|
+
if (isStep(minute) && allStar(hour) && allStar(dom) && allStar(month) && allStar(dow)) {
|
|
26
|
+
const n = stepVal(minute);
|
|
27
|
+
return n === 1 ? 'Every minute' : `Every ${n} minutes`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Every hour at minute M: M * * * *
|
|
31
|
+
if (!allStar(minute) && !isStep(minute) && allStar(hour) && allStar(dom) && allStar(month) && allStar(dow)) {
|
|
32
|
+
const m = parseInt(minute);
|
|
33
|
+
return m === 0 ? 'Every hour, on the hour' : `Every hour at minute ${m}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Every N hours: 0 */N * * * (or M */N * * *)
|
|
37
|
+
if (!isStep(minute) && isStep(hour) && allStar(dom) && allStar(month) && allStar(dow)) {
|
|
38
|
+
const n = stepVal(hour);
|
|
39
|
+
const m = parseInt(minute);
|
|
40
|
+
const hourPart = n === 1 ? 'Every hour' : `Every ${n} hours`;
|
|
41
|
+
return m === 0 ? hourPart : `${hourPart} at minute ${m}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Specific hour and minute: M H * * *
|
|
45
|
+
if (!allStar(minute) && !isStep(minute) && !allStar(hour) && !isStep(hour) && allStar(dom) && allStar(month) && allStar(dow)) {
|
|
46
|
+
const timeStr = formatTime(hour, minute);
|
|
47
|
+
return `Every day at ${timeStr}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Specific day of week: M H * * D
|
|
51
|
+
if (!allStar(minute) && !allStar(hour) && allStar(dom) && allStar(month) && !allStar(dow)) {
|
|
52
|
+
const timeStr = formatTime(hour, minute);
|
|
53
|
+
const days = parseDow(dow);
|
|
54
|
+
return `${days} at ${timeStr}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Specific day of month: M H D * *
|
|
58
|
+
if (!allStar(minute) && !allStar(hour) && !allStar(dom) && !isStep(dom) && allStar(month) && allStar(dow)) {
|
|
59
|
+
const timeStr = formatTime(hour, minute);
|
|
60
|
+
const d = ordinal(parseInt(dom));
|
|
61
|
+
return `On the ${d} of every month at ${timeStr}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Specific month and day: M H D Mo *
|
|
65
|
+
if (!allStar(minute) && !allStar(hour) && !allStar(dom) && !allStar(month) && allStar(dow)) {
|
|
66
|
+
const timeStr = formatTime(hour, minute);
|
|
67
|
+
const d = ordinal(parseInt(dom));
|
|
68
|
+
const mo = parseMonth(month);
|
|
69
|
+
return `Every year on ${mo} ${d} at ${timeStr}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fallback: describe each field
|
|
73
|
+
return describeFallback(minute, hour, dom, month, dow);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatTime(hour, minute) {
|
|
77
|
+
const h = parseInt(hour);
|
|
78
|
+
const m = parseInt(minute);
|
|
79
|
+
const period = h >= 12 ? 'PM' : 'AM';
|
|
80
|
+
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
81
|
+
return m === 0 ? `${h12}${period}` : `${h12}:${String(m).padStart(2, '0')}${period}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ordinal(n) {
|
|
85
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
86
|
+
const v = n % 100;
|
|
87
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
91
|
+
const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June',
|
|
92
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
93
|
+
|
|
94
|
+
function parseDow(field) {
|
|
95
|
+
const indices = expandField(field, 0, 7).map(d => d % 7);
|
|
96
|
+
if (indices.length === 5 && !indices.includes(0) && !indices.includes(6)) return 'Weekdays';
|
|
97
|
+
if (indices.length === 2 && indices.includes(0) && indices.includes(6)) return 'Weekends';
|
|
98
|
+
return 'Every ' + indices.map(i => DOW_NAMES[i]).join(', ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseMonth(field) {
|
|
102
|
+
const indices = expandField(field, 1, 12);
|
|
103
|
+
return indices.map(i => MONTH_NAMES[i]).join(', ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function expandField(field, min, max) {
|
|
107
|
+
const results = new Set();
|
|
108
|
+
for (const part of field.split(',')) {
|
|
109
|
+
if (part.includes('/')) {
|
|
110
|
+
const [range, step] = part.split('/');
|
|
111
|
+
const s = parseInt(step);
|
|
112
|
+
const start = range === '*' ? min : parseInt(range);
|
|
113
|
+
for (let i = start; i <= max; i += s) results.add(i);
|
|
114
|
+
} else if (part.includes('-')) {
|
|
115
|
+
const [a, b] = part.split('-').map(Number);
|
|
116
|
+
for (let i = a; i <= b; i++) results.add(i);
|
|
117
|
+
} else if (part === '*') {
|
|
118
|
+
for (let i = min; i <= max; i++) results.add(i);
|
|
119
|
+
} else {
|
|
120
|
+
results.add(parseInt(part));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...results].sort((a, b) => a - b);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function describeFallback(minute, hour, dom, month, dow) {
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (minute !== '*') parts.push(`minute ${minute}`);
|
|
129
|
+
if (hour !== '*') parts.push(`hour ${hour}`);
|
|
130
|
+
if (dom !== '*') parts.push(`day ${dom}`);
|
|
131
|
+
if (month !== '*') parts.push(`month ${month}`);
|
|
132
|
+
if (dow !== '*') parts.push(`weekday ${dow}`);
|
|
133
|
+
return `Runs at: ${parts.join(', ')}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { describeCron };
|
package/library/html-server.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
|
+
const escape = require('escape-html');
|
|
10
11
|
|
|
11
12
|
class HtmlServer {
|
|
12
13
|
log;
|
|
@@ -44,23 +45,6 @@ class HtmlServer {
|
|
|
44
45
|
return this.templates.has(templateName);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// HTML Utilities
|
|
48
|
-
escapeHtml(text) {
|
|
49
|
-
if (typeof text !== 'string') {
|
|
50
|
-
return String(text);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const map = {
|
|
54
|
-
'&': '&',
|
|
55
|
-
'<': '<',
|
|
56
|
-
'>': '>',
|
|
57
|
-
'"': '"',
|
|
58
|
-
"'": '''
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
48
|
// Page Rendering - simple template substitution
|
|
65
49
|
renderPage(templateName, title, content, options = {}) {
|
|
66
50
|
const template = this.getTemplate(templateName);
|
|
@@ -80,21 +64,21 @@ class HtmlServer {
|
|
|
80
64
|
|
|
81
65
|
// Perform template replacements
|
|
82
66
|
let html = template
|
|
83
|
-
.replace(/\[%title%\]/g,
|
|
67
|
+
.replace(/\[%title%\]/g, escape(title))
|
|
84
68
|
.replace(/\[%content%\]/g, content) // Content is assumed to be already-safe HTML
|
|
85
|
-
.replace(/\[%ver%\]/g,
|
|
86
|
-
.replace(/\[%download-date%\]/g,
|
|
87
|
-
.replace(/\[%total-resources%\]/g,
|
|
88
|
-
.replace(/\[%total-packages%\]/g,
|
|
89
|
-
.replace(/\[%endpoint-path%\]/g,
|
|
90
|
-
.replace(/\[%fhir-version%\]/g,
|
|
91
|
-
.replace(/\[%ms%\]/g,
|
|
69
|
+
.replace(/\[%ver%\]/g, escape(renderOptions.version))
|
|
70
|
+
.replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate))
|
|
71
|
+
.replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString()))
|
|
72
|
+
.replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString()))
|
|
73
|
+
.replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath))
|
|
74
|
+
.replace(/\[%fhir-version%\]/g, escape(renderOptions.fhirversion))
|
|
75
|
+
.replace(/\[%ms%\]/g, escape(renderOptions.processingTime.toString()));
|
|
92
76
|
|
|
93
77
|
// Handle any custom template variables
|
|
94
78
|
if (options.templateVars) {
|
|
95
79
|
for (const [key, value] of Object.entries(options.templateVars)) {
|
|
96
80
|
const placeholder = `[%${key}%]`;
|
|
97
|
-
const escapedValue = typeof value === 'string' ?
|
|
81
|
+
const escapedValue = typeof value === 'string' ? escape(value) : String(value);
|
|
98
82
|
html = html.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), escapedValue);
|
|
99
83
|
}
|
|
100
84
|
}
|
|
@@ -110,7 +94,7 @@ class HtmlServer {
|
|
|
110
94
|
res.send(html);
|
|
111
95
|
} catch (error) {
|
|
112
96
|
this.log.error('[HtmlServer] Error rendering page:', error);
|
|
113
|
-
res.status(500).send(`<h1>Error</h1><p>Failed to render page: ${
|
|
97
|
+
res.status(500).send(`<h1>Error</h1><p>Failed to render page: ${escape(error.message)}</p>`);
|
|
114
98
|
}
|
|
115
99
|
}
|
|
116
100
|
|
|
@@ -118,7 +102,7 @@ class HtmlServer {
|
|
|
118
102
|
const errorContent = `
|
|
119
103
|
<div class="alert alert-danger">
|
|
120
104
|
<h4>Error</h4>
|
|
121
|
-
<p>${
|
|
105
|
+
<p>${escape(error.message || error)}</p>
|
|
122
106
|
</div>
|
|
123
107
|
`;
|
|
124
108
|
|
|
@@ -128,7 +112,7 @@ class HtmlServer {
|
|
|
128
112
|
res.send(html);
|
|
129
113
|
} catch (renderError) {
|
|
130
114
|
this.log.error('[HtmlServer] Error rendering error page:', renderError);
|
|
131
|
-
res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${
|
|
115
|
+
res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${escape(renderError.message)}</p>`);
|
|
132
116
|
}
|
|
133
117
|
}
|
|
134
118
|
|
package/library/html.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const {validateParameter, validateOptionalParameter} = require("./utilities");
|
|
2
|
+
const escape = require('escape-html');
|
|
3
|
+
|
|
2
4
|
const NodeType = {
|
|
3
5
|
Document: 'Document',
|
|
4
6
|
Element: 'Element',
|
|
@@ -723,7 +725,7 @@ class XhtmlNode {
|
|
|
723
725
|
const newline = effectivePretty ? '\n' : '';
|
|
724
726
|
|
|
725
727
|
if (this.nodeType === NodeType.Text) {
|
|
726
|
-
return
|
|
728
|
+
return escape(this.content || '');
|
|
727
729
|
}
|
|
728
730
|
|
|
729
731
|
if (this.nodeType === NodeType.Comment) {
|
|
@@ -771,13 +773,6 @@ class XhtmlNode {
|
|
|
771
773
|
return '';
|
|
772
774
|
}
|
|
773
775
|
|
|
774
|
-
#escapeHtml(text) {
|
|
775
|
-
return text
|
|
776
|
-
.replace(/&/g, '&')
|
|
777
|
-
.replace(/</g, '<')
|
|
778
|
-
.replace(/>/g, '>');
|
|
779
|
-
}
|
|
780
|
-
|
|
781
776
|
#escapeAttr(text) {
|
|
782
777
|
return String(text)
|
|
783
778
|
.replace(/&/g, '&')
|
package/library/languages.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const os = require('os');
|
|
3
|
-
const {
|
|
3
|
+
const {validateOptionalParameter, Utilities} = require("./utilities");
|
|
4
|
+
const {join} = require("node:path");
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Language part types for matching depth
|
|
@@ -22,6 +23,7 @@ class LanguageEntry {
|
|
|
22
23
|
constructor() {
|
|
23
24
|
this.code = '';
|
|
24
25
|
this.displays = [];
|
|
26
|
+
this.translations = new Map(); // language code -> translated display name
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -120,7 +122,7 @@ class Language {
|
|
|
120
122
|
if (index < parts.length) {
|
|
121
123
|
this.language = parts[index];
|
|
122
124
|
if (this.language != '*' && languageDefinitions && !languageDefinitions.languages.has(this.language)) {
|
|
123
|
-
throw new Error("The language '"+this.language+"' in the code '"+this.
|
|
125
|
+
throw new Error("The language '"+this.language+"' in the code '"+this.code+"' is not valid");
|
|
124
126
|
}
|
|
125
127
|
index++;
|
|
126
128
|
}
|
|
@@ -130,7 +132,7 @@ class Language {
|
|
|
130
132
|
const part = parts[index];
|
|
131
133
|
if (part.length === 3 && /^[a-zA-Z]{3}$/.test(part)) {
|
|
132
134
|
if (languageDefinitions && !languageDefinitions.extLanguages.has(part)) {
|
|
133
|
-
throw new Error("The extLanguage '"+part+"' in the code '"+code+"' is not valid");
|
|
135
|
+
throw new Error("The extLanguage '"+part+"' in the code '"+this.code+"' is not valid");
|
|
134
136
|
}
|
|
135
137
|
this.extLang.push(part.toLowerCase());
|
|
136
138
|
index++;
|
|
@@ -144,7 +146,7 @@ class Language {
|
|
|
144
146
|
const part = parts[index];
|
|
145
147
|
if (part.length === 4 && /^[a-zA-Z]{4}$/.test(part)) {
|
|
146
148
|
if (languageDefinitions && !languageDefinitions.scripts.has(part)) {
|
|
147
|
-
throw new Error("The script '"+part+"' in the code '"+code+"' is not valid");
|
|
149
|
+
throw new Error("The script '"+part+"' in the code '"+this.code+"' is not valid");
|
|
148
150
|
}
|
|
149
151
|
this.script = part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
|
150
152
|
index++;
|
|
@@ -157,7 +159,7 @@ class Language {
|
|
|
157
159
|
if ((part.length === 2 && /^[a-zA-Z]{2}$/.test(part)) ||
|
|
158
160
|
(part.length === 3 && /^[0-9]{3}$/.test(part))) {
|
|
159
161
|
if (languageDefinitions && !languageDefinitions.regions.has(part)) {
|
|
160
|
-
throw new Error("The region '"+part+"' in the code '"+code+"' is not valid");
|
|
162
|
+
throw new Error("The region '"+part+"' in the code '"+this.code+"' is not valid");
|
|
161
163
|
}
|
|
162
164
|
this.region = part.toUpperCase();
|
|
163
165
|
index++;
|
|
@@ -187,9 +189,10 @@ class Language {
|
|
|
187
189
|
} else if (part.length === 1 && part !== 'x') {
|
|
188
190
|
// Extension
|
|
189
191
|
this.extension = part + '-' + parts.slice(index + 1).join('-');
|
|
192
|
+
index++;
|
|
190
193
|
break;
|
|
191
194
|
} else {
|
|
192
|
-
index
|
|
195
|
+
throw new Error("Unable to recognised '"+parts[index]+"' as a valid part in the language code "+this.code);
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
198
|
}
|
|
@@ -496,10 +499,12 @@ class LanguageDefinitions {
|
|
|
496
499
|
/**
|
|
497
500
|
* Load definitions from IETF language subtag registry file
|
|
498
501
|
*/
|
|
499
|
-
static async
|
|
502
|
+
static async fromFiles(filePath) {
|
|
500
503
|
const definitions = new LanguageDefinitions();
|
|
501
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
504
|
+
const content = fs.readFileSync(join(filePath, "lang.dat"), 'utf8');
|
|
502
505
|
definitions._load(content);
|
|
506
|
+
definitions._loadTranslations(join(filePath, "languages.csv"), definitions.languages);
|
|
507
|
+
definitions._loadTranslations(join(filePath, "regions.csv"), definitions.regions);
|
|
503
508
|
return definitions;
|
|
504
509
|
}
|
|
505
510
|
|
|
@@ -589,6 +594,88 @@ class LanguageDefinitions {
|
|
|
589
594
|
return [vars, i];
|
|
590
595
|
}
|
|
591
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Load translations from a CSV file into an existing map of entries.
|
|
599
|
+
* CSV format: code,english,french,german,spanish,arabic,chinese,russian,japanese,swahili
|
|
600
|
+
* The language codes for the translation columns:
|
|
601
|
+
*/
|
|
602
|
+
_loadTranslations(csvPath, targetMap) {
|
|
603
|
+
if (!fs.existsSync(csvPath)) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const content = fs.readFileSync(csvPath, 'utf8');
|
|
607
|
+
const lines = content.split('\n');
|
|
608
|
+
if (lines.length < 2) return;
|
|
609
|
+
|
|
610
|
+
const header = this._parseCsvLine(lines[0]);
|
|
611
|
+
// header[0] = 'code', header[1..] = language names mapped to codes
|
|
612
|
+
const langCodeMap = {
|
|
613
|
+
'english': 'en',
|
|
614
|
+
'french': 'fr',
|
|
615
|
+
'german': 'de',
|
|
616
|
+
'spanish': 'es',
|
|
617
|
+
'arabic': 'ar',
|
|
618
|
+
'chinese': 'zh',
|
|
619
|
+
'russian': 'ru',
|
|
620
|
+
'japanese': 'ja',
|
|
621
|
+
'swahili': 'sw'
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const columnLangs = header.slice(1).map(h => langCodeMap[h.toLowerCase()] || h.toLowerCase());
|
|
625
|
+
|
|
626
|
+
for (let i = 1; i < lines.length; i++) {
|
|
627
|
+
const line = lines[i].trim();
|
|
628
|
+
if (!line) continue;
|
|
629
|
+
const fields = this._parseCsvLine(line);
|
|
630
|
+
if (fields.length < 2) continue;
|
|
631
|
+
|
|
632
|
+
const code = fields[0];
|
|
633
|
+
const entry = targetMap.get(code);
|
|
634
|
+
if (!entry) continue;
|
|
635
|
+
|
|
636
|
+
for (let j = 1; j < fields.length && j < header.length; j++) {
|
|
637
|
+
const langCode = columnLangs[j - 1];
|
|
638
|
+
const value = fields[j];
|
|
639
|
+
if (value) {
|
|
640
|
+
entry.translations.set(langCode, value);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Parse a single CSV line, handling quoted fields with commas
|
|
648
|
+
*/
|
|
649
|
+
_parseCsvLine(line) {
|
|
650
|
+
const fields = [];
|
|
651
|
+
let current = '';
|
|
652
|
+
let inQuotes = false;
|
|
653
|
+
for (let i = 0; i < line.length; i++) {
|
|
654
|
+
const ch = line[i];
|
|
655
|
+
if (inQuotes) {
|
|
656
|
+
if (ch === '"' && i + 1 < line.length && line[i + 1] === '"') {
|
|
657
|
+
current += '"';
|
|
658
|
+
i++;
|
|
659
|
+
} else if (ch === '"') {
|
|
660
|
+
inQuotes = false;
|
|
661
|
+
} else {
|
|
662
|
+
current += ch;
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
if (ch === '"') {
|
|
666
|
+
inQuotes = true;
|
|
667
|
+
} else if (ch === ',') {
|
|
668
|
+
fields.push(current);
|
|
669
|
+
current = '';
|
|
670
|
+
} else {
|
|
671
|
+
current += ch;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
fields.push(current);
|
|
676
|
+
return fields;
|
|
677
|
+
}
|
|
678
|
+
|
|
592
679
|
/**
|
|
593
680
|
* Load language entry
|
|
594
681
|
*/
|
|
@@ -671,39 +758,56 @@ class LanguageDefinitions {
|
|
|
671
758
|
*
|
|
672
759
|
* @return {Language} parsed language (or null)
|
|
673
760
|
*/
|
|
674
|
-
parse(code) {
|
|
675
|
-
if (!code)
|
|
761
|
+
parse(code, msg) {
|
|
762
|
+
if (!code) {
|
|
763
|
+
if (msg) {
|
|
764
|
+
msg.message = 'No code provided';
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
676
768
|
|
|
677
769
|
// Check cache first
|
|
678
770
|
if (this.parsed.has(code)) {
|
|
679
771
|
return this.parsed.get(code);
|
|
680
772
|
}
|
|
681
773
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
774
|
+
try {
|
|
775
|
+
const lang = new Language(code, this);
|
|
776
|
+
// Cache the result
|
|
777
|
+
this.parsed.set(code, lang);
|
|
778
|
+
return lang;
|
|
779
|
+
} catch (e) {
|
|
780
|
+
if (msg) {
|
|
781
|
+
msg.message = e.message;
|
|
782
|
+
}
|
|
783
|
+
return null;
|
|
690
784
|
}
|
|
691
|
-
|
|
692
|
-
const lang = new Language(code);
|
|
693
|
-
|
|
694
|
-
// Cache the result
|
|
695
|
-
this.parsed.set(code, lang);
|
|
696
|
-
return lang;
|
|
697
785
|
}
|
|
698
786
|
|
|
699
787
|
/**
|
|
700
|
-
* Get display name for language
|
|
788
|
+
* Get display name for language, optionally translated
|
|
701
789
|
*/
|
|
702
790
|
getDisplayForLang(code, displayIndex = 0) {
|
|
703
791
|
const lang = this.languages.get(code);
|
|
704
792
|
return lang && lang.displays[displayIndex] ? lang.displays[displayIndex] : code;
|
|
705
793
|
}
|
|
706
794
|
|
|
795
|
+
/**
|
|
796
|
+
* Get translated display name for a language code
|
|
797
|
+
* @param {string} code - the language subtag
|
|
798
|
+
* @param {string} displayLang - the language to translate into (e.g. 'fr', 'de')
|
|
799
|
+
* @returns {string} translated name, or English name, or the code itself
|
|
800
|
+
*/
|
|
801
|
+
getTranslatedDisplayForLang(code, displayLang) {
|
|
802
|
+
const lang = this.languages.get(code);
|
|
803
|
+
if (!lang) return code;
|
|
804
|
+
if (displayLang) {
|
|
805
|
+
const translated = lang.translations.get(displayLang);
|
|
806
|
+
if (translated) return translated;
|
|
807
|
+
}
|
|
808
|
+
return lang.displays[0] || code;
|
|
809
|
+
}
|
|
810
|
+
|
|
707
811
|
/**
|
|
708
812
|
* Get display name for region
|
|
709
813
|
*/
|
|
@@ -712,6 +816,22 @@ class LanguageDefinitions {
|
|
|
712
816
|
return region && region.displays[displayIndex] ? region.displays[displayIndex] : code;
|
|
713
817
|
}
|
|
714
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Get translated display name for a region code
|
|
821
|
+
* @param {string} code - the region subtag
|
|
822
|
+
* @param {string} displayLang - the language to translate into (e.g. 'fr', 'de')
|
|
823
|
+
* @returns {string} translated name, or English name, or the code itself
|
|
824
|
+
*/
|
|
825
|
+
getTranslatedDisplayForRegion(code, displayLang) {
|
|
826
|
+
const region = this.regions.get(code);
|
|
827
|
+
if (!region) return code;
|
|
828
|
+
if (displayLang) {
|
|
829
|
+
const translated = region.translations.get(displayLang);
|
|
830
|
+
if (translated) return translated;
|
|
831
|
+
}
|
|
832
|
+
return region.displays[0] || code;
|
|
833
|
+
}
|
|
834
|
+
|
|
715
835
|
/**
|
|
716
836
|
* Get display name for script
|
|
717
837
|
*/
|
|
@@ -734,18 +854,21 @@ class LanguageDefinitions {
|
|
|
734
854
|
}
|
|
735
855
|
|
|
736
856
|
let result = this.getDisplayForLang(lang.language, displayIndex);
|
|
737
|
-
|
|
738
857
|
const parts = [];
|
|
739
|
-
if (lang.script) {
|
|
740
|
-
parts.push(
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
858
|
+
if (lang.region && !lang.script && !lang.variant) {
|
|
859
|
+
parts.push(`${this.getDisplayForRegion(lang.region, 0)}`);
|
|
860
|
+
} else {
|
|
861
|
+
if (lang.script) {
|
|
862
|
+
parts.push(`Script=${this.getDisplayForScript(lang.script, 0)}`);
|
|
863
|
+
}
|
|
864
|
+
if (lang.region) {
|
|
865
|
+
parts.push(`Region=${this.getDisplayForRegion(lang.region, 0)}`);
|
|
866
|
+
}
|
|
867
|
+
if (lang.variant) {
|
|
868
|
+
const variant = this.variants.get(lang.variant);
|
|
869
|
+
const variantDisplay = variant && variant.displays[0] ? variant.displays[0] : lang.variant;
|
|
870
|
+
parts.push(`Variant=${variantDisplay}`);
|
|
871
|
+
}
|
|
749
872
|
}
|
|
750
873
|
|
|
751
874
|
if (parts.length > 0) {
|
|
@@ -776,4 +899,4 @@ module.exports = {
|
|
|
776
899
|
LanguageScript,
|
|
777
900
|
LanguageRegion,
|
|
778
901
|
LanguageVariant
|
|
779
|
-
};
|
|
902
|
+
};
|
|
@@ -413,6 +413,7 @@ class PackageManager {
|
|
|
413
413
|
}
|
|
414
414
|
} catch (error) {
|
|
415
415
|
// Try next server
|
|
416
|
+
console.info("Error looking for "+packageId+" on "+server+": "+error);
|
|
416
417
|
continue;
|
|
417
418
|
}
|
|
418
419
|
}
|
|
@@ -586,6 +587,50 @@ class PackageManager {
|
|
|
586
587
|
}
|
|
587
588
|
}
|
|
588
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Fetch a package directly from a URL (e.g., a CI build .tgz)
|
|
592
|
+
* @param {string} url - URL to a package.tgz file
|
|
593
|
+
* @returns {Promise<string>} Path to extracted package folder
|
|
594
|
+
*/
|
|
595
|
+
async fetchUrl(url) {
|
|
596
|
+
console.log("Fetch Package from URL: " + url);
|
|
597
|
+
const client = new CIBuildClient();
|
|
598
|
+
const packageData = await client.fetchFromUrlSpecific(url);
|
|
599
|
+
|
|
600
|
+
// Extract to a temp location to read package.json for name and version
|
|
601
|
+
const tempKey = `_url_temp_${Date.now()}`;
|
|
602
|
+
const tempPath = await this.extractToCache(tempKey, 'url', packageData);
|
|
603
|
+
const tempFullPath = path.join(this.cacheFolder, tempPath);
|
|
604
|
+
|
|
605
|
+
// Read package name and version from the extracted package
|
|
606
|
+
const pkgJsonPath = path.join(tempFullPath, 'package', 'package.json');
|
|
607
|
+
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
|
|
608
|
+
const packageId = pkgJson.name;
|
|
609
|
+
const version = pkgJson.version;
|
|
610
|
+
|
|
611
|
+
if (!packageId || !version) {
|
|
612
|
+
throw new Error(`Package at ${url} has no name or version in package.json`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Use the same cache key format as npm packages
|
|
616
|
+
const finalName = `${packageId}#${version}`;
|
|
617
|
+
const finalPath = path.join(this.cacheFolder, finalName);
|
|
618
|
+
|
|
619
|
+
// If it already exists, the same package is already loaded - that's a config error
|
|
620
|
+
try {
|
|
621
|
+
await fs.access(finalPath);
|
|
622
|
+
await fs.rm(tempFullPath, { recursive: true, force: true });
|
|
623
|
+
throw new Error(`Package ${finalName} already exists in cache. Check library config for duplicates (url: ${url})`);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
if (e.message.includes('already exists')) throw e;
|
|
626
|
+
// Doesn't exist yet, rename temp to final
|
|
627
|
+
await fs.rename(tempFullPath, finalPath);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
this.totalDownloaded = this.totalDownloaded + packageData.length;
|
|
631
|
+
return finalName;
|
|
632
|
+
}
|
|
633
|
+
|
|
589
634
|
/**
|
|
590
635
|
* Extract package to cache folder
|
|
591
636
|
* @param {string} packageId - Package identifier
|
|
@@ -856,7 +901,9 @@ class PackageContentLoader {
|
|
|
856
901
|
}
|
|
857
902
|
|
|
858
903
|
fhirVersion() {
|
|
859
|
-
|
|
904
|
+
// Handle both modern 'fhirVersions' and older 'fhir-version-list' formats
|
|
905
|
+
const versions = this.package.fhirVersions || this.package['fhir-version-list'];
|
|
906
|
+
return versions ? versions[0] : undefined;
|
|
860
907
|
}
|
|
861
908
|
|
|
862
909
|
id() {
|