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.
Files changed (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/library/cron-utilities.js +136 -0
  4. package/library/html-server.js +13 -29
  5. package/library/html.js +3 -8
  6. package/library/languages.js +160 -37
  7. package/library/package-manager.js +48 -1
  8. package/library/utilities.js +100 -19
  9. package/package.json +2 -2
  10. package/packages/package-crawler.js +6 -1
  11. package/packages/packages.js +38 -54
  12. package/publisher/publisher.js +19 -27
  13. package/registry/api.js +11 -10
  14. package/registry/crawler.js +31 -29
  15. package/registry/model.js +5 -26
  16. package/registry/registry.js +32 -41
  17. package/server.js +53 -5
  18. package/shl/shl.js +0 -18
  19. package/static/assets/js/statuspage.js +1 -9
  20. package/stats.js +39 -1
  21. package/token/token.js +14 -9
  22. package/translations/Messages.properties +2 -1
  23. package/tx/README.md +17 -6
  24. package/tx/cs/cs-api.js +19 -1
  25. package/tx/cs/cs-base.js +77 -0
  26. package/tx/cs/cs-country.js +46 -0
  27. package/tx/cs/cs-cpt.js +9 -5
  28. package/tx/cs/cs-cs.js +27 -13
  29. package/tx/cs/cs-lang.js +60 -22
  30. package/tx/cs/cs-loinc.js +69 -98
  31. package/tx/cs/cs-mimetypes.js +4 -0
  32. package/tx/cs/cs-ndc.js +6 -0
  33. package/tx/cs/cs-omop.js +16 -15
  34. package/tx/cs/cs-rxnorm.js +23 -1
  35. package/tx/cs/cs-snomed.js +283 -40
  36. package/tx/cs/cs-ucum.js +90 -70
  37. package/tx/importers/import-sct.module.js +371 -35
  38. package/tx/importers/readme.md +117 -7
  39. package/tx/library/bundle.js +5 -0
  40. package/tx/library/capabilitystatement.js +3 -142
  41. package/tx/library/codesystem.js +19 -173
  42. package/tx/library/conceptmap.js +4 -218
  43. package/tx/library/designations.js +14 -1
  44. package/tx/library/extensions.js +7 -0
  45. package/tx/library/namingsystem.js +3 -89
  46. package/tx/library/operation-outcome.js +8 -3
  47. package/tx/library/parameters.js +3 -2
  48. package/tx/library/renderer.js +10 -6
  49. package/tx/library/terminologycapabilities.js +3 -243
  50. package/tx/library/valueset.js +3 -235
  51. package/tx/library.js +100 -13
  52. package/tx/operation-context.js +23 -4
  53. package/tx/params.js +35 -38
  54. package/tx/provider.js +6 -5
  55. package/tx/sct/expressions.js +12 -3
  56. package/tx/tx-html.js +80 -89
  57. package/tx/tx.fhir.org.yml +6 -5
  58. package/tx/tx.js +163 -13
  59. package/tx/vs/vs-database.js +56 -39
  60. package/tx/vs/vs-package.js +21 -2
  61. package/tx/vs/vs-vsac.js +175 -39
  62. package/tx/workers/batch-validate.js +2 -0
  63. package/tx/workers/batch.js +2 -0
  64. package/tx/workers/expand.js +132 -112
  65. package/tx/workers/lookup.js +33 -14
  66. package/tx/workers/metadata.js +2 -2
  67. package/tx/workers/read.js +3 -2
  68. package/tx/workers/related.js +574 -0
  69. package/tx/workers/search.js +46 -9
  70. package/tx/workers/subsumes.js +13 -3
  71. package/tx/workers/translate.js +7 -3
  72. package/tx/workers/validate.js +258 -285
  73. package/tx/workers/worker.js +43 -39
  74. package/tx/xml/bundle-xml.js +237 -0
  75. package/tx/xml/xml-base.js +215 -64
  76. package/tx/xversion/xv-bundle.js +71 -0
  77. package/tx/xversion/xv-capabiliityStatement.js +137 -0
  78. package/tx/xversion/xv-codesystem.js +169 -0
  79. package/tx/xversion/xv-conceptmap.js +224 -0
  80. package/tx/xversion/xv-namingsystem.js +88 -0
  81. package/tx/xversion/xv-operationoutcome.js +27 -0
  82. package/tx/xversion/xv-parameters.js +87 -0
  83. package/tx/xversion/xv-resource.js +45 -0
  84. package/tx/xversion/xv-terminologyCapabilities.js +214 -0
  85. package/tx/xversion/xv-valueset.js +234 -0
  86. package/utilities/dev-proxy-server.js +126 -0
  87. package/utilities/explode-results.js +58 -0
  88. package/utilities/split-by-system.js +198 -0
  89. package/utilities/vsac-cs-fetcher.js +0 -0
  90. package/{windows-install.js → utilities/windows-install.js} +2 -0
  91. package/vcl/vcl.js +0 -18
  92. 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 };
@@ -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
+ };
@@ -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
- return this.package.fhirVersions[0];
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() {