fhirsmith 0.3.0 → 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.
Files changed (103) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -2
  3. package/library/cron-utilities.js +136 -0
  4. package/library/folder-setup.js +6 -0
  5. package/library/html-server.js +13 -29
  6. package/library/html.js +3 -8
  7. package/library/languages.js +160 -37
  8. package/library/package-manager.js +48 -1
  9. package/library/utilities.js +100 -19
  10. package/package.json +2 -2
  11. package/packages/package-crawler.js +6 -1
  12. package/packages/packages.js +38 -54
  13. package/publisher/publisher.js +19 -27
  14. package/registry/api.js +11 -10
  15. package/registry/crawler.js +31 -29
  16. package/registry/model.js +5 -26
  17. package/registry/readme.md +1 -11
  18. package/registry/registry.js +32 -41
  19. package/server.js +53 -5
  20. package/shl/shl.js +0 -18
  21. package/static/assets/js/statuspage.js +1 -9
  22. package/stats.js +39 -1
  23. package/token/token.js +14 -9
  24. package/translations/Messages.properties +2 -1
  25. package/tx/README.md +17 -6
  26. package/tx/cs/cs-api.js +19 -1
  27. package/tx/cs/cs-base.js +77 -0
  28. package/tx/cs/cs-country.js +46 -0
  29. package/tx/cs/cs-cpt.js +9 -5
  30. package/tx/cs/cs-cs.js +27 -13
  31. package/tx/cs/cs-db.js +0 -13
  32. package/tx/cs/cs-lang.js +60 -22
  33. package/tx/cs/cs-loinc.js +69 -98
  34. package/tx/cs/cs-mimetypes.js +4 -0
  35. package/tx/cs/cs-ndc.js +6 -0
  36. package/tx/cs/cs-omop.js +16 -15
  37. package/tx/cs/cs-rxnorm.js +23 -1
  38. package/tx/cs/cs-snomed.js +283 -40
  39. package/tx/cs/cs-ucum.js +90 -70
  40. package/tx/importers/import-sct.module.js +371 -35
  41. package/tx/importers/readme.md +117 -7
  42. package/tx/library/bundle.js +5 -0
  43. package/tx/library/capabilitystatement.js +3 -142
  44. package/tx/library/codesystem.js +19 -173
  45. package/tx/library/conceptmap.js +4 -218
  46. package/tx/library/designations.js +14 -1
  47. package/tx/library/extensions.js +7 -0
  48. package/tx/library/namingsystem.js +3 -89
  49. package/tx/library/operation-outcome.js +8 -3
  50. package/tx/library/parameters.js +3 -2
  51. package/tx/library/renderer.js +10 -6
  52. package/tx/library/terminologycapabilities.js +3 -243
  53. package/tx/library/valueset.js +3 -235
  54. package/tx/library.js +100 -13
  55. package/tx/operation-context.js +23 -4
  56. package/tx/params.js +35 -38
  57. package/tx/provider.js +6 -5
  58. package/tx/sct/expressions.js +12 -3
  59. package/tx/tx-html.js +80 -89
  60. package/tx/tx.fhir.org.yml +6 -5
  61. package/tx/tx.js +163 -13
  62. package/tx/vs/vs-database.js +56 -39
  63. package/tx/vs/vs-package.js +21 -2
  64. package/tx/vs/vs-vsac.js +175 -39
  65. package/tx/workers/batch-validate.js +2 -0
  66. package/tx/workers/batch.js +2 -0
  67. package/tx/workers/expand.js +132 -112
  68. package/tx/workers/lookup.js +33 -14
  69. package/tx/workers/metadata.js +2 -2
  70. package/tx/workers/read.js +3 -2
  71. package/tx/workers/related.js +574 -0
  72. package/tx/workers/search.js +46 -9
  73. package/tx/workers/subsumes.js +13 -3
  74. package/tx/workers/translate.js +7 -3
  75. package/tx/workers/validate.js +258 -285
  76. package/tx/workers/worker.js +43 -39
  77. package/tx/xml/bundle-xml.js +237 -0
  78. package/tx/xml/xml-base.js +215 -64
  79. package/tx/xversion/xv-bundle.js +71 -0
  80. package/tx/xversion/xv-capabiliityStatement.js +137 -0
  81. package/tx/xversion/xv-codesystem.js +169 -0
  82. package/tx/xversion/xv-conceptmap.js +224 -0
  83. package/tx/xversion/xv-namingsystem.js +88 -0
  84. package/tx/xversion/xv-operationoutcome.js +27 -0
  85. package/tx/xversion/xv-parameters.js +87 -0
  86. package/tx/xversion/xv-resource.js +45 -0
  87. package/tx/xversion/xv-terminologyCapabilities.js +214 -0
  88. package/tx/xversion/xv-valueset.js +234 -0
  89. package/utilities/dev-proxy-server.js +126 -0
  90. package/utilities/explode-results.js +58 -0
  91. package/utilities/split-by-system.js +198 -0
  92. package/utilities/vsac-cs-fetcher.js +0 -0
  93. package/{windows-install.js → utilities/windows-install.js} +2 -0
  94. package/vcl/vcl.js +0 -18
  95. package/xig/xig.js +108 -99
  96. package/passwords.ini +0 -2
  97. package/registry/registry-data.json +0 -121015
  98. package/shl/private-key.pem +0 -5
  99. package/shl/public-key.pem +0 -18
  100. package/test-cache/vsac/vsac-valuesets.db +0 -0
  101. package/tx/dev.fhir.org.yml +0 -14
  102. package/tx/fixtures/test-cases-setup.json +0 -18
  103. package/tx/fixtures/test-cases.yml +0 -16
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ 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
+
20
+ ## [v0.4.2] - 2026-02-05
21
+ ### Changed
22
+ - Even More testing the release process; some tidy up to testing data
23
+
24
+ ## [v0.4.1] - 2026-02-05
25
+ ### Changed
26
+ - More testing the release process; some tidy up to testing data
27
+
28
+ ## [v0.4.0] - 2026-02-05
29
+ ### Changed
30
+ - Just testing the release process; some tidy up to testing data
31
+
8
32
  ## [v0.3.0] - 2026-02-05
9
33
  ### Added
10
34
  - Add first draft of publishing engine
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
@@ -235,9 +235,11 @@ GitHub Actions will automatically:
235
235
  3. Commit your changes:
236
236
  ```bash
237
237
  git commit -m "Prepare release vX.Y.Z"
238
- git push origin main
238
+ git push origin main:XXXXXX
239
239
  ```
240
240
 
241
+ do it via a PR
242
+
241
243
  4. Tag and push the release:
242
244
  ```bash
243
245
  git tag vX.Y.Z
@@ -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 };
@@ -42,6 +42,12 @@ class FolderSetup {
42
42
  return filePath;
43
43
  }
44
44
 
45
+ ensureFolder(...relativePath) {
46
+ const dirPath = path.join(this.dataDir(), ...relativePath);
47
+ fs.mkdirSync(dirPath, { recursive: true });
48
+ return dirPath;
49
+ }
50
+
45
51
  logsDir() {
46
52
  return this.subDir('logs');
47
53
  }
@@ -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
- '&': '&amp;',
55
- '<': '&lt;',
56
- '>': '&gt;',
57
- '"': '&quot;',
58
- "'": '&#39;'
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, this.escapeHtml(title))
67
+ .replace(/\[%title%\]/g, escape(title))
84
68
  .replace(/\[%content%\]/g, content) // Content is assumed to be already-safe HTML
85
- .replace(/\[%ver%\]/g, this.escapeHtml(renderOptions.version))
86
- .replace(/\[%download-date%\]/g, this.escapeHtml(renderOptions.downloadDate))
87
- .replace(/\[%total-resources%\]/g, this.escapeHtml(renderOptions.totalResources.toLocaleString()))
88
- .replace(/\[%total-packages%\]/g, this.escapeHtml(renderOptions.totalPackages.toLocaleString()))
89
- .replace(/\[%endpoint-path%\]/g, this.escapeHtml(renderOptions.endpointpath))
90
- .replace(/\[%fhir-version%\]/g, this.escapeHtml(renderOptions.fhirversion))
91
- .replace(/\[%ms%\]/g, this.escapeHtml(renderOptions.processingTime.toString()));
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' ? this.escapeHtml(value) : String(value);
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: ${this.escapeHtml(error.message)}</p>`);
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>${this.escapeHtml(error.message || error)}</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: ${this.escapeHtml(renderError.message)}</p>`);
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 this.#escapeHtml(this.content || '');
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, '&amp;')
777
- .replace(/</g, '&lt;')
778
- .replace(/>/g, '&gt;');
779
- }
780
-
781
776
  #escapeAttr(text) {
782
777
  return String(text)
783
778
  .replace(/&/g, '&amp;')
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
- const {validateParameter, validateOptionalParameter, Utilities} = require("./utilities");
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.language+"' is not valid");
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 fromFile(filePath) {
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) return null;
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
- const parts = code.split('-');
683
- let index = 0;
684
-
685
- // Validate language
686
- if (index >= parts.length) return null;
687
- const langCode = parts[index].toLowerCase();
688
- if (!this.languages.has(langCode) && langCode !== '*') {
689
- return null; // Invalid language code
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(`Script=${this.getDisplayForScript(lang.script, 0)}`);
741
- }
742
- if (lang.region) {
743
- parts.push(`Region=${this.getDisplayForRegion(lang.region, 0)}`);
744
- }
745
- if (lang.variant) {
746
- const variant = this.variants.get(lang.variant);
747
- const variantDisplay = variant && variant.displays[0] ? variant.displays[0] : lang.variant;
748
- parts.push(`Variant=${variantDisplay}`);
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
+ };