fhirsmith 0.7.0 → 0.7.2

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.
@@ -0,0 +1,63 @@
1
+ # FHIRsmith Extension Tracker Module
2
+
3
+ Tracks extensions, profiles, and extension usage across FHIR Implementation Guides. IGs submit data via POST, and the module provides browsable views of the collected data.
4
+
5
+ ## Configuration
6
+
7
+ ```json
8
+ "ext-tracker": {
9
+ "enabled": true,
10
+ "database": "/var/fhir/data/extension-tracker.db",
11
+ "url": "/ext-tracker"
12
+ }
13
+ ```
14
+
15
+ - **database**: path to the SQLite database file (auto-created if missing)
16
+ - **url** (optional): URL base to mount at (defaults to `/ext-tracker`)
17
+
18
+ ## POST — Submit IG Data
19
+
20
+ POST JSON to the module root. Required fields: `package`, `version`, `fhirVersion`. Optional: `jurisdiction`, `extensions`, `profiles`, `usage`.
21
+
22
+ ```
23
+ POST /ext-tracker
24
+ Content-Type: application/json
25
+
26
+ {
27
+ "package": "hl7.fhir.uv.tx-ecosystem",
28
+ "version": "1.9.1",
29
+ "fhirVersion": "5.0.0",
30
+ "jurisdiction": "001",
31
+ "extensions": [
32
+ { "url": "http://example.org/ext", "title": "My Extension", "types": ["string"] }
33
+ ],
34
+ "profiles": {
35
+ "Patient": [
36
+ { "url": "http://example.org/profile", "title": "My Profile" }
37
+ ]
38
+ },
39
+ "usage": {
40
+ "http://example.org/ext": ["Patient", "Patient.name"]
41
+ }
42
+ }
43
+ ```
44
+
45
+ Submitting a new version for an existing package replaces the old data entirely — only the latest submission per package is kept.
46
+
47
+ Duplicate types in extensions are automatically deduplicated.
48
+
49
+ ## GET — Browse Data
50
+
51
+ - `/ext-tracker` — Summary dashboard with package list
52
+ - `/ext-tracker/extensions` — All extensions, filterable by `?package=`
53
+ - `/ext-tracker/profiles` — All profiles, filterable by `?resource=` and `?package=`
54
+ - `/ext-tracker/usage` — Extension usage, filterable by `?url=` and `?location=`
55
+ - `/ext-tracker/package/:name` — Detail page for a specific package
56
+
57
+ ## Dependencies
58
+
59
+ Requires `better-sqlite3` (`npm install better-sqlite3`).
60
+
61
+ ## Database
62
+
63
+ The SQLite database is auto-created on first run with five tables: `packages`, `extensions`, `extension_types`, `profiles`, and `usages`. Foreign keys cascade deletes, so removing a package cleans up all related data.
@@ -0,0 +1,305 @@
1
+ const express = require('express');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const USERS_FILE = '.users.json';
5
+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
6
+ const SAFE_NAME = /^[a-zA-Z0-9._-]+$/;
7
+ const AUTH_FAIL_DELAY_MS = 5000;
8
+
9
+ function escapeHtml(str) {
10
+ return String(str)
11
+ .replace(/&/g, '&')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ class FolderModule {
19
+ constructor(stats) {
20
+ this.folders = [];
21
+ this.stats = stats;
22
+ }
23
+
24
+ initialize(config, app) {
25
+ this.folders = [];
26
+
27
+ const folderConfigs = config.folders || [];
28
+ for (const fc of folderConfigs) {
29
+ if (fc.enabled === false) {
30
+ continue;
31
+ }
32
+ if (!fc.folder || !fc.url || !fc.name) {
33
+ console.log(`Folder config entry missing required fields (name, folder, url) - skipping`);
34
+ continue;
35
+ }
36
+ const rootDir = path.resolve(fc.folder);
37
+ if (!fs.existsSync(rootDir)) {
38
+ console.log(`Folder path ${rootDir} does not exist - skipping "${fc.name}"`);
39
+ continue;
40
+ }
41
+
42
+ const urlBase = fc.url.startsWith('/') ? fc.url : '/' + fc.url;
43
+ const router = express.Router();
44
+
45
+ // GET - serve files and directory listings
46
+ router.get(urlBase + '/{*subpath}', (req, res) => {
47
+ this.handleGet(req, res, rootDir, urlBase);
48
+ });
49
+ router.get(urlBase, (req, res) => {
50
+ this.handleGet(req, res, rootDir, urlBase);
51
+ });
52
+
53
+ // PUT - upload with basic auth (express.raw captures body before any global JSON parser)
54
+ router.put(urlBase + '/{*subpath}', express.raw({ type: '*/*', limit: MAX_UPLOAD_BYTES }), (req, res) => {
55
+ this.handlePut(req, res, rootDir, urlBase);
56
+ });
57
+
58
+ app.use('/', router);
59
+ this.folders.push({ name: fc.name, folder: rootDir, url: urlBase });
60
+ console.log(`Folder module: serving "${fc.name}" from ${rootDir} at ${urlBase}`);
61
+ }
62
+
63
+ if (this.folders.length === 0) {
64
+ console.log('Folder module: no folders configured');
65
+ }
66
+ }
67
+
68
+ handleGet(req, res, rootDir, urlBase) {
69
+ this.stats.countRequest('search', 0);
70
+ const subPath = req.path.substring(urlBase.length) || '/';
71
+ const safePath = path.normalize(subPath);
72
+ if (safePath.includes('..')) {
73
+ return res.status(403).send('Forbidden');
74
+ }
75
+
76
+ const fullPath = path.join(rootDir, safePath);
77
+
78
+ // never serve .users.json
79
+ if (path.basename(fullPath) === '.users.json') {
80
+ return res.status(403).send('Forbidden');
81
+ }
82
+
83
+ if (!fs.existsSync(fullPath)) {
84
+ return res.status(404).send('Not found');
85
+ }
86
+
87
+ const stat = fs.statSync(fullPath);
88
+ if (stat.isFile()) {
89
+ return res.sendFile(fullPath);
90
+ }
91
+
92
+ if (stat.isDirectory()) {
93
+ return this.sendDirectoryListing(res, fullPath, req.path);
94
+ }
95
+
96
+ return res.status(404).send('Not found');
97
+ }
98
+
99
+ sendDirectoryListing(res, dirPath, requestPath) {
100
+ const start = Date.now();
101
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
102
+ const urlPath = requestPath.endsWith('/') ? requestPath : requestPath + '/';
103
+
104
+ // separate dirs and files, exclude .users.json
105
+ const dirs = entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
106
+ const files = entries.filter(e => e.isFile() && e.name !== '.users.json').sort((a, b) => a.name.localeCompare(b.name));
107
+
108
+ const safeRequestPath = escapeHtml(requestPath);
109
+ let html = `<html><head><title>Index of ${safeRequestPath}</title></head><body>`;
110
+ html += `<h1>Index of ${safeRequestPath}</h1><pre>`;
111
+
112
+ // parent directory link (if not at mount root)
113
+ if (requestPath.split('/').filter(Boolean).length > 1) {
114
+ html += `<a href="${urlPath}..">../</a>\n`;
115
+ }
116
+
117
+ for (const dir of dirs) {
118
+ const safeDirName = escapeHtml(dir.name);
119
+ html += `<a href="${urlPath}${encodeURIComponent(dir.name)}/">${safeDirName}/</a>\n`;
120
+ }
121
+
122
+ for (const file of files) {
123
+ const stat = fs.statSync(path.join(dirPath, file.name));
124
+ const size = this.formatSize(stat.size);
125
+ const safeFileName = escapeHtml(file.name);
126
+ html += `<a href="${urlPath}${encodeURIComponent(file.name)}">${safeFileName}</a>${' '.repeat(Math.max(1, 60 - file.name.length))}${size}\n`;
127
+ }
128
+
129
+ html += '</pre></body></html>';
130
+ this.stats.countRequest('folder', Date.now() - start);
131
+ res.type('html').send(html);
132
+ }
133
+
134
+ formatSize(bytes) {
135
+ if (bytes < 1024) return bytes + ' B';
136
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
137
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
138
+ return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
139
+ }
140
+
141
+ handlePut(req, res, rootDir, urlBase) {
142
+ this.stats.countRequest('submit', 0);
143
+ const subPath = req.path.substring(urlBase.length);
144
+ const safePath = path.normalize(subPath);
145
+ if (safePath.includes('..')) {
146
+ return res.status(403).send('Forbidden');
147
+ }
148
+
149
+ const fullPath = path.join(rootDir, safePath);
150
+
151
+ // must be a file path, not a directory
152
+ if (safePath === '/' || safePath === '') {
153
+ return res.status(400).send('Cannot PUT to directory root');
154
+ }
155
+
156
+ // validate every path segment: alphanumeric, dots, dashes, underscores only
157
+ // split on both / and \ since path.normalize uses \ on Windows
158
+ const segments = safePath.split(/[/\\]/).filter(Boolean);
159
+ for (const seg of segments) {
160
+ if (!SAFE_NAME.test(seg)) {
161
+ return res.status(400).send('Invalid path: names may only contain letters, numbers, dots, dashes and underscores');
162
+ }
163
+ }
164
+
165
+ // only allow .zip files
166
+ const filename = path.basename(fullPath);
167
+ if (!filename.toLowerCase().endsWith('.zip')) {
168
+ return res.status(400).send('Only .zip files may be uploaded');
169
+ }
170
+
171
+ // check content-length before reading body
172
+ const contentLength = parseInt(req.headers['content-length'], 10);
173
+ if (contentLength > MAX_UPLOAD_BYTES) {
174
+ return res.status(413).send(`File too large (limit ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
175
+ }
176
+
177
+ // authenticate
178
+ const credentials = this.parseBasicAuth(req);
179
+ if (!credentials) {
180
+ res.set('WWW-Authenticate', 'Basic realm="Folder Upload"');
181
+ return res.status(401).send('Authentication required');
182
+ }
183
+
184
+ if (!this.checkUser(rootDir, path.dirname(fullPath), credentials.username, credentials.password)) {
185
+ // deliberate delay to slow down brute-force attempts
186
+ return setTimeout(() => {
187
+ res.set('WWW-Authenticate', 'Basic realm="Folder Upload"');
188
+ res.status(403).send('Access denied');
189
+ }, AUTH_FAIL_DELAY_MS);
190
+ }
191
+
192
+ this.getBody(req).then(body => {
193
+ // double-check actual body size
194
+ if (body.length > MAX_UPLOAD_BYTES) {
195
+ return res.status(413).send(`File too large (limit ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
196
+ }
197
+
198
+ // ensure parent directory exists
199
+ const parentDir = path.dirname(fullPath);
200
+ if (!fs.existsSync(parentDir)) {
201
+ fs.mkdirSync(parentDir, { recursive: true });
202
+ }
203
+
204
+ // write the file
205
+ fs.writeFileSync(fullPath, body);
206
+
207
+ // if it's main.zip or master.zip, also save as default.zip
208
+ const basename = path.basename(fullPath).toLowerCase();
209
+ if (basename === 'main.zip' || basename === 'master.zip') {
210
+ const defaultPath = path.join(parentDir, 'default.zip');
211
+ fs.copyFileSync(fullPath, defaultPath);
212
+ }
213
+
214
+ return res.status(200).send('OK');
215
+ }).catch(err => {
216
+ console.log(`Folder module: PUT error for ${fullPath}: ${err.message}`);
217
+ return res.status(500).send('Upload failed');
218
+ });
219
+ }
220
+
221
+ // get request body as a Buffer, whether or not global middleware already parsed it
222
+ getBody(req) {
223
+ // if global middleware already parsed it, use that
224
+ if (Buffer.isBuffer(req.body)) {
225
+ return Promise.resolve(req.body);
226
+ }
227
+ if (req.body && typeof req.body === 'object') {
228
+ return Promise.resolve(Buffer.from(JSON.stringify(req.body)));
229
+ }
230
+ if (req.body) {
231
+ return Promise.resolve(Buffer.from(req.body));
232
+ }
233
+ // no middleware parsed it — read from stream
234
+ return new Promise((resolve, reject) => {
235
+ const chunks = [];
236
+ req.on('data', chunk => chunks.push(chunk));
237
+ req.on('end', () => resolve(Buffer.concat(chunks)));
238
+ req.on('error', reject);
239
+ });
240
+ }
241
+
242
+ parseBasicAuth(req) {
243
+ const header = req.headers.authorization;
244
+ if (!header || !header.startsWith('Basic ')) {
245
+ return null;
246
+ }
247
+ const decoded = Buffer.from(header.substring(6), 'base64').toString('utf-8');
248
+ const colon = decoded.indexOf(':');
249
+ if (colon < 0) {
250
+ return null;
251
+ }
252
+ return {
253
+ username: decoded.substring(0, colon),
254
+ password: decoded.substring(colon + 1)
255
+ };
256
+ }
257
+
258
+ // walk up from the target directory to rootDir looking for .users.json
259
+ checkUser(rootDir, dir, username, password) {
260
+ let current = path.resolve(dir);
261
+ const root = path.resolve(rootDir);
262
+
263
+ while (true) {
264
+ const usersPath = path.join(current, USERS_FILE);
265
+ if (fs.existsSync(usersPath)) {
266
+ try {
267
+ const users = JSON.parse(fs.readFileSync(usersPath, 'utf-8'));
268
+ if (users[username] && users[username] === password) {
269
+ return true;
270
+ }
271
+ } catch (e) {
272
+ // malformed json, keep walking up
273
+ }
274
+ }
275
+
276
+ if (current === root) {
277
+ break;
278
+ }
279
+
280
+ const parent = path.dirname(current);
281
+ if (parent === current) {
282
+ break; // filesystem root - stop
283
+ }
284
+ current = parent;
285
+ }
286
+
287
+ return false;
288
+ }
289
+
290
+ shutdown() {
291
+ // nothing to clean up
292
+ }
293
+
294
+ getStatus() {
295
+ return {
296
+ folders: this.folders.map(f => ({
297
+ name: f.name,
298
+ folder: f.folder,
299
+ url: f.url
300
+ }))
301
+ };
302
+ }
303
+ }
304
+
305
+ module.exports = FolderModule;
@@ -0,0 +1,57 @@
1
+ # FHIRsmith Folder Module
2
+
3
+ Serves static files from one or more directories, with optional authenticated upload via PUT.
4
+
5
+ ## Configuration
6
+
7
+ Add a `folder` entry under `modules` in your config, following the standard FHIRsmith module pattern:
8
+
9
+ ```json
10
+ "folder": {
11
+ "enabled": true,
12
+ "folders": [
13
+ { "name": "packages", "folder": "/var/fhir/packages", "url": "/packages" },
14
+ { "name": "igs", "folder": "/var/fhir/igs", "url": "/igs", "enabled": false }
15
+ ]
16
+ }
17
+ ```
18
+
19
+ The top-level `enabled` controls whether the module loads at all. Each folder entry also supports its own `enabled: false` to disable individual folders without removing them from the config.
20
+
21
+ - **name**: display name (used in status and logging)
22
+ - **folder**: absolute path to the directory to serve
23
+ - **url**: URL path to mount at
24
+ - **enabled** (optional): set to `false` to skip this entry
25
+
26
+ Directories that don't exist at startup are skipped with a warning. Zero folders is valid.
27
+
28
+ ## GET — File Serving
29
+
30
+ Any GET request under the configured URL serves files directly. If the path is a directory, it returns an HTML listing showing subdirectories first, then files with sizes.
31
+
32
+ ## PUT — Authenticated Upload
33
+
34
+ PUT writes a file to the path specified in the URL. The parent directory is created automatically if it doesn't exist.
35
+
36
+ All PUT requests require HTTP Basic authentication. Credentials are checked against `.users.json` files (see below).
37
+
38
+ Special case: uploading `main.zip` or `master.zip` automatically creates a copy named `default.zip` in the same directory.
39
+
40
+ ## .users.json
41
+
42
+ Authentication for PUT is controlled by `.users.json` files placed anywhere in the served directory tree. Format is a simple JSON object mapping usernames to passwords:
43
+
44
+ ```json
45
+ {
46
+ "grahame": "secretpassword",
47
+ "ci-bot": "buildtoken123"
48
+ }
49
+ ```
50
+
51
+ When a PUT comes in, the module walks up from the target directory to the folder root looking for a `.users.json` file. The first one found that contains a matching username and password grants access. If no match is found at any level, the request is rejected with 403.
52
+
53
+ This means you can put a `.users.json` at the root to cover everything, or use per-subdirectory files to grant more specific access. `.users.json` files are never served by GET.
54
+
55
+ ## Dependencies
56
+
57
+ No additional npm dependencies required.
@@ -100,20 +100,26 @@ class HtmlServer {
100
100
  }
101
101
 
102
102
  sendErrorResponse(res, templateName, error, statusCode = 500) {
103
+ if (res.headersSent) {
104
+ this.log.error('[HtmlServer] Cannot send error response - headers already sent:', error.message || error);
105
+ return;
106
+ }
103
107
  const errorContent = `
104
108
  <div class="alert alert-danger">
105
109
  <h4>Error</h4>
106
110
  <p>${escape(error.message || error)}</p>
107
111
  </div>
108
112
  `;
109
-
113
+
110
114
  try {
111
115
  const html = this.renderPage(templateName, 'Error', errorContent);
112
116
  res.status(statusCode).setHeader('Content-Type', 'text/html');
113
117
  res.send(html);
114
118
  } catch (renderError) {
115
119
  this.log.error('[HtmlServer] Error rendering error page:', renderError);
116
- res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${escape(renderError.message)}</p>`);
120
+ if (!res.headersSent) {
121
+ res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${escape(renderError.message)}</p>`);
122
+ }
117
123
  }
118
124
  }
119
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
5
5
  "main": "server.js",
6
6
  "engines": {
@@ -32,6 +32,7 @@
32
32
  "axios": "^1.13.4",
33
33
  "base45": "^3.0.0",
34
34
  "bcrypt": "^6.0.0",
35
+ "better-sqlite3": "^12.8.0",
35
36
  "cbor": "^9.0.1",
36
37
  "chalk": "^4.1.2",
37
38
  "chokidar": "^4.0.3",
@@ -47,6 +48,7 @@
47
48
  "fhir-validator-wrapper": "1.2.2",
48
49
  "fhirpath": "^4.8.3",
49
50
  "fs-extra": "^11.3.3",
51
+ "ini": "^6.0.0",
50
52
  "inquirer": "^8.2.5",
51
53
  "liquidjs": "^10.24.0",
52
54
  "lusca": "^1.7.0",
@@ -116,4 +118,4 @@
116
118
  "url": "https://github.com/HealthIntersections/fhirsmith/issues"
117
119
  },
118
120
  "homepage": "https://github.com/HealthIntersections/fhirsmith#readme"
119
- }
121
+ }
@@ -1721,10 +1721,10 @@ class PackagesModule {
1721
1721
  }
1722
1722
 
1723
1723
  const dependency = row.Dependency;
1724
- const hashIndex = dependency.indexOf('#');
1725
- if (hashIndex > 0) {
1726
- const depName = dependency.substring(0, hashIndex);
1727
- const depVersion = dependency.substring(hashIndex + 1);
1724
+ const atIndex = dependency.indexOf('@');
1725
+ if (atIndex > 0) {
1726
+ const depName = dependency.substring(0, atIndex);
1727
+ const depVersion = dependency.substring(atIndex + 1);
1728
1728
  deps[row.PackageVersionKey][depName] = depVersion;
1729
1729
  }
1730
1730
  }
@@ -2493,10 +2493,10 @@ class PackagesModule {
2493
2493
  }
2494
2494
 
2495
2495
  // Extract dependency name and version
2496
- const hashIndex = dependency.indexOf('#');
2497
- if (hashIndex > 0) {
2498
- const depName = dependency.substring(0, hashIndex);
2499
- const depVersion = dependency.substring(hashIndex + 1);
2496
+ const atIndex = dependency.indexOf('@');
2497
+ if (atIndex > 0) {
2498
+ const depName = dependency.substring(0, atIndex);
2499
+ const depVersion = dependency.substring(atIndex + 1);
2500
2500
  const depMajorMinor = this.getMajorMinorVersion(depVersion);
2501
2501
  const depRef = `${depName}#${depMajorMinor}`;
2502
2502
 
package/server.js CHANGED
@@ -59,6 +59,8 @@ const TXModule = require('./tx/tx.js');
59
59
  const htmlServer = require('./library/html-server');
60
60
  const ServerStats = require("./stats");
61
61
  const {Liquid} = require("liquidjs");
62
+ const FolderModule = require("./folder/folder");
63
+ const ExtensionTrackerModule = require("./extension-tracker/extension-tracker");
62
64
 
63
65
  htmlServer.useLog(serverLog);
64
66
 
@@ -195,6 +197,17 @@ async function initializeModules() {
195
197
  }
196
198
  }
197
199
 
200
+
201
+ if (config.modules?.['ext-tracker']?.enabled) {
202
+ try {
203
+ serverLog.info('Initializing module: ext-tracker...');
204
+ modules.extTracker = new ExtensionTrackerModule(stats);
205
+ await modules.extTracker.initialize(config.modules['ext-tracker'], app);
206
+ } catch (error) {
207
+ serverLog.error('Failed to initialize extension tracker module:', error);
208
+ throw error;
209
+ }
210
+ }
198
211
  // Initialize TX module
199
212
  // Note: TX module registers its own endpoints directly on the app
200
213
  // because it supports multiple endpoints at different paths
@@ -208,6 +221,18 @@ async function initializeModules() {
208
221
  throw error;
209
222
  }
210
223
  }
224
+
225
+ if (config.modules?.folder?.enabled) {
226
+ try {
227
+ serverLog.info('Initializing module: folder...');
228
+ modules.folder = new FolderModule(stats);
229
+ await modules.folder.initialize(config.modules.folder, app);
230
+ // mount the router
231
+ } catch (error) {
232
+ serverLog.error('Failed to initialize folder module:', error);
233
+ throw error;
234
+ }
235
+ }
211
236
  }
212
237
 
213
238
  async function loadTemplates() {
@@ -312,6 +337,32 @@ async function buildRootPageContent() {
312
337
  content += '</li>';
313
338
  }
314
339
 
340
+ if (config.modules?.['ext-tracker']?.enabled) {
341
+ mc++;
342
+ content += '<li class="list-group-item">';
343
+ content += '<a href="/ext-tracker" class="text-decoration-none">Extension Tracker</a>: ';
344
+ content += 'View of Extension Usage';
345
+ content += '</li>';
346
+ }
347
+
348
+ if (config.modules.folder && config.modules.folder.enabled) {
349
+ content += '<li class="list-group-item">';
350
+ content += '<strong>Cache Folder</strong>: ';
351
+ content += 'Cache Folder for Kindling';
352
+ const folders = config.modules.folder.folders || [];
353
+ content += '<ul class="mt-2 mb-0">';
354
+ for (const fc of folders) {
355
+ if (fc.enabled === false) continue;
356
+ mc++;
357
+ content += '<li>';
358
+ content += `<a href="${fc.url}" class="text-decoration-none">${fc.name}</a>: `;
359
+ content += 'File folder with write control';
360
+ content += '</li>';
361
+ }
362
+ content += '</ul>';
363
+ }
364
+
365
+
315
366
  if (config.modules.tx && config.modules.tx.enabled) {
316
367
  content += '<li class="list-group-item">';
317
368
  content += '<strong>TX Terminology Server</strong>: ';
@@ -433,7 +484,7 @@ app.get('/', async (req, res) => {
433
484
  about
434
485
  };
435
486
 
436
- const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats);
487
+ const html = htmlServer.renderPage('root', config.hostName ? escape(config.hostName) : 'FHIRsmith Server', content, stats);
437
488
  res.setHeader('Content-Type', 'text/html');
438
489
  res.send(html);
439
490
  return;
@@ -442,8 +493,9 @@ app.get('/', async (req, res) => {
442
493
  htmlServer.sendErrorResponse(res, 'root', error);
443
494
  return;
444
495
  }
496
+ } else {
497
+ return serveFhirsmithHome(req, res);
445
498
  }
446
- return serveFhirsmithHome(req, res);
447
499
  });
448
500
 
449
501
  app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res));
@@ -622,7 +674,7 @@ async function serveFhirsmithHome(req, res) {
622
674
  processingTime: Date.now() - startTime
623
675
  };
624
676
 
625
- const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats);
677
+ const html = htmlServer.renderPage('root', config.hostName ? escape(config.hostName) : 'FHIRsmith Server', content, stats);
626
678
  res.setHeader('Content-Type', 'text/html');
627
679
  res.send(html);
628
680
  return;
@@ -410,9 +410,9 @@ class SnomedServices {
410
410
 
411
411
  if (matchFound) {
412
412
  // Calculate priority based on match quality
413
- if (term === searchText.toLowerCase()) {
413
+ if (term === searchText.filter.toLowerCase()) {
414
414
  priority = 100; // Exact match
415
- } else if (term.startsWith(searchText.toLowerCase())) {
415
+ } else if (term.startsWith(searchText.filter.toLowerCase())) {
416
416
  priority = 50; // Prefix match
417
417
  } else {
418
418
  priority = 10; // Contains match
@@ -969,7 +969,9 @@ class SnomedProvider extends BaseCSServices {
969
969
 
970
970
  // Search filter
971
971
  async searchFilter(filterContext, filter, sort) {
972
- return this.sct.searchFilter(filter, false, sort);
972
+ let f = this.sct.searchFilter(filter, false, sort);
973
+ filterContext.filters.push(f);
974
+ return f;
973
975
  }
974
976
 
975
977
  // Subsumption testing
@@ -0,0 +1,19 @@
1
+
2
+ <div class="operation-form" style="margin-bottom: 15px;">
3
+ <strong>Translate</strong>
4
+ <form method="get" action="$translate" style="margin-left: 10px; margin-top: 5px;">
5
+ <input type="hidden" name="url" value="{{ url }}"/>
6
+ <table class="grid" cellpadding="0" cellspacing="0">
7
+ <tr>
8
+ <td>System:</td><td><select name="sourceSystem">{{ sources }}</select></td>
9
+ </tr>
10
+ <tr>
11
+ <td>Code:</td><td><select name="sourceCode">{{ codes }}</select></td>
12
+ </tr>
13
+ <tr>
14
+ <td>Target:</td><td><select name="targetSystem">{{ targets }}</select></td>
15
+ </tr>
16
+ </table>
17
+ <button type="submit" class="btn btn-sm btn-primary">Translate</button>
18
+ </form>
19
+ </div>
@@ -110,14 +110,18 @@ const Extensions = {
110
110
  if (!exp.extension) {
111
111
  exp.extension = [];
112
112
  }
113
- exp.extension.push({ url : url, valueBoolean : b });
113
+ let ext = { url : url, valueBoolean : b };
114
+ exp.extension.push(ext);
115
+ return ext;
114
116
  },
115
117
 
116
118
  addString(exp, url, s) {
117
119
  if (!exp.extension) {
118
120
  exp.extension = [];
119
121
  }
120
- exp.extension.push({ url : url, valueString : s });
122
+ let ext = { url : url, valueString : s };
123
+ exp.extension.push(ext);
124
+ return ext;
121
125
  }
122
126
  }
123
127