fhirsmith 0.7.4 → 0.7.6

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.
@@ -1,6 +1,8 @@
1
1
  # TX-Import: Medical Terminology Import Tool
2
2
 
3
- A comprehensive CLI tool for importing various medical terminology standards into SQLite databases and other formats. The tool supports LOINC, SNOMED CT, UNII, NDC, and provides extensible architecture for additional terminologies.
3
+ A comprehensive CLI tool for importing various medical terminology standards into SQLite databases and other formats
4
+ for use by FHIRsmith. The tool supports LOINC, SNOMED CT, UNII, NDC, and provides extensible architecture for
5
+ additional terminologies.
4
6
 
5
7
  ## Table of Contents
6
8
 
package/tx/library.js CHANGED
@@ -65,7 +65,8 @@ class Library {
65
65
  */
66
66
  conceptMapProviders;
67
67
 
68
- contentSources = [];
68
+ packageSources = [];
69
+ externalSources = [];
69
70
 
70
71
  baseUrl = null;
71
72
  cacheFolder = null;
@@ -101,6 +102,7 @@ class Library {
101
102
  this.conceptMapProviders = [];
102
103
  this.oclProviderSets = new Map();
103
104
  this.oclConfig = {};
105
+ this.ignored = new Set();
104
106
 
105
107
  // Create package manager for FHIR packages
106
108
  const packageServers = ['https://packages2.fhir.org/packages'];
@@ -161,6 +163,7 @@ class Library {
161
163
  const config = yaml.parse(yamlContent);
162
164
  this.baseUrl = config.base.url;
163
165
  this.oclConfig = config.ocl && typeof config.ocl === 'object' ? config.ocl : {};
166
+ this.ignored = new Set(Array.isArray(config.ignored) ? config.ignored : []);
164
167
 
165
168
  this.log.info('Fetching Data from '+this.baseUrl);
166
169
 
@@ -288,7 +291,7 @@ class Library {
288
291
  case 'ocl':
289
292
  await this.loadOcl(details, isDefault, mode);
290
293
  break;
291
-
294
+
292
295
  default:
293
296
  throw new Error(`Unknown source type: ${type}`);
294
297
  }
@@ -358,6 +361,9 @@ class Library {
358
361
  const codeSystemProvider = new OCLCodeSystemProvider(config);
359
362
  const valueSetProvider = new OCLValueSetProvider(config);
360
363
  const conceptMapProvider = new OCLConceptMapProvider(config);
364
+ this.externalSources.push(codeSystemProvider);
365
+ this.externalSources.push(valueSetProvider);
366
+ this.externalSources.push(conceptMapProvider);
361
367
  providerSet = {
362
368
  config,
363
369
  codeSystemProvider,
@@ -468,6 +474,7 @@ class Library {
468
474
  let vsac = new VSACValueSetProvider(this.vsacCfg, this.stats);
469
475
  vsac.initialize();
470
476
  this.valueSetProviders.push(vsac);
477
+ this.externalSources.push(vsac);
471
478
  //const mem = process.memoryUsage();
472
479
  let time = Math.floor(Date.now() - this.lastTime).toString().padStart(5)+" ";
473
480
  let system = "vsac".padEnd(50);
@@ -567,6 +574,17 @@ class Library {
567
574
  this.registerProvider(omopFN, omop, isDefault);
568
575
  }
569
576
 
577
+ /**
578
+ * Returns true if the given url/version should be excluded from npm/url package loading.
579
+ * Matches against the ignored list using either plain url or url#version.
580
+ */
581
+ #isIgnored(url, version) {
582
+ if (this.ignored.size === 0) return false;
583
+ if (this.ignored.has(url)) return true;
584
+ if (version && this.ignored.has(`${url}#${version}`)) return true;
585
+ return false;
586
+ }
587
+
570
588
  async loadNpm(packageManager, details, isDefault, mode, csOnly) {
571
589
  // Parse packageId and version from details (e.g., "hl7.terminology.r4#6.0.2")
572
590
  let packageId = details;
@@ -584,13 +602,17 @@ class Library {
584
602
  const contentLoader = new PackageContentLoader(fullPackagePath);
585
603
  await contentLoader.initialize();
586
604
 
587
- this.contentSources.push(contentLoader.id()+"#"+contentLoader.version());
605
+ this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
588
606
 
589
607
  let cp = new ListCodeSystemProvider();
590
608
  const resources = await contentLoader.getResourcesByType("CodeSystem");
591
609
  let csc = 0;
592
610
  for (const resource of resources) {
593
611
  const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
612
+ if (this.#isIgnored(cs.url, cs.version)) {
613
+ this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`);
614
+ continue;
615
+ }
594
616
  cs.sourcePackage = contentLoader.pid();
595
617
  cp.codeSystems.push(cs);
596
618
  csc++;
@@ -618,13 +640,17 @@ class Library {
618
640
  const contentLoader = new PackageContentLoader(fullPackagePath);
619
641
  await contentLoader.initialize();
620
642
 
621
- this.contentSources.push(contentLoader.id()+"#"+contentLoader.version());
643
+ this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
622
644
 
623
645
  let cp = new ListCodeSystemProvider();
624
646
  const resources = await contentLoader.getResourcesByType("CodeSystem");
625
647
  let csc = 0;
626
648
  for (const resource of resources) {
627
649
  const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
650
+ if (this.#isIgnored(cs.url, cs.version)) {
651
+ this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`);
652
+ continue;
653
+ }
628
654
  cs.sourcePackage = contentLoader.pid();
629
655
  cp.codeSystems.set(cs.url, cs);
630
656
  cp.codeSystems.set(cs.vurl, cs);
@@ -826,7 +852,8 @@ class Library {
826
852
  provider.lastTime = this.lastTime;
827
853
  provider.lastMemory = this.lastMemory;
828
854
  provider.totalDownloaded = this.totalDownloaded;
829
- provider.contentSources = this.contentSources;
855
+ provider.packageSources = this.packageSources;
856
+ provider.externalSources = this.externalSources;
830
857
 
831
858
 
832
859
  // Now add the existing value set providers after the FHIR core packages
@@ -905,4 +932,4 @@ class Library {
905
932
 
906
933
  }
907
934
 
908
- module.exports = { Library };
935
+ module.exports = { Library };
package/tx/provider.js CHANGED
@@ -49,7 +49,8 @@ class Provider {
49
49
  */
50
50
  conceptMapProviders;
51
51
 
52
- contentSources;
52
+ packageSources;
53
+ externalSources;
53
54
 
54
55
  baseUrl = null;
55
56
  path;
package/tx/tx-html.js CHANGED
@@ -234,24 +234,44 @@ class TxHtmlRenderer {
234
234
  html += await this.buildSearchForm(req);
235
235
 
236
236
  // ===== Packages and Factories Section =====
237
- html += '<hr/><h3>Content Sources &amp; Code System Factories</h3>';
237
+ html += '<hr/><h3>Source Content</h3>';
238
238
 
239
- // List content sources
240
- html += '<h6>Content Sources</h6>';
241
- if (provider.contentSources && provider.contentSources.length > 0) {
242
- const sorted = [...provider.contentSources].sort();
239
+ // List Packages
240
+ html += '<h6>FHIR Packages</h6>';
241
+ if (provider.packageSources && provider.packageSources.length > 0) {
242
+ const sorted = [...provider.packageSources].sort();
243
243
  html += '<ul>';
244
244
  for (const source of sorted) {
245
245
  html += `<li>${escape(source)}</li>`;
246
246
  }
247
247
  html += '</ul>';
248
248
  } else {
249
- html += '<p><em>No content sources available</em></p>';
249
+ html += '<p><em>No FHIR Packages Loaded</em></p>';
250
250
  }
251
251
 
252
- // Code System Factories table
253
- // Code System Factories table
254
- html += '<h6 class="mt-4">External CodeSystems</h6>';
252
+ // List Packages
253
+ html += '<h6>External Sources</h6>';
254
+ if (provider.externalSources && provider.externalSources.length > 0) {
255
+ const sorted = [...provider.externalSources].sort();
256
+ html += '<ul>';
257
+ for (const source of sorted) {
258
+ let n = source.name();
259
+ if (!n) {
260
+ n = source.sourcePackage();
261
+ }
262
+ let ii = source.infoName();
263
+ if (ii) {
264
+ html += `<li>${escape(n)} (<a href="info/${source.id()}">${ii}</a>)</li>`;
265
+ } else {
266
+ html += `<li>${escape(n)}</li>`;
267
+ }
268
+ }
269
+ html += '</ul>';
270
+ } else {
271
+ html += '<p><em>No External Sources Configured</em></p>';
272
+ }
273
+
274
+ html += '<h6 class="mt-4">Special CodeSystems</h6>';
255
275
  html += '<table class="grid">';
256
276
  html += '<thead><tr><th>Name</th><th>URI</th><th>Version</th><th>Use Count</th></tr></thead>';
257
277
  html += '<tbody>';
@@ -1290,6 +1310,13 @@ class TxHtmlRenderer {
1290
1310
  });
1291
1311
  }
1292
1312
 
1313
+ async buildInfoPage(source, req) {
1314
+ let html = '';
1315
+ const infoContent = await source.info(req);
1316
+ html += infoContent;
1317
+ return html;
1318
+ }
1319
+
1293
1320
  buildSourceOptions(provider) {
1294
1321
  let result = '<option value=""></option>';
1295
1322
  result += `<option value="internal">internal</option>`;
@@ -40,3 +40,6 @@ sources:
40
40
  - npm:us.cdc.phinvads
41
41
  - npm:hl7.fhir.uv.sdc
42
42
  - internal:vsac
43
+
44
+ ignored:
45
+ - urn:iso:std:iso:3166#20210120
package/tx/tx.js CHANGED
@@ -9,7 +9,7 @@ const express = require('express');
9
9
  const path = require('path');
10
10
  const Logger = require('../library/logger');
11
11
  const { Library } = require('./library');
12
- const { OperationContext, ResourceCache, ExpansionCache } = require('./operation-context');
12
+ const { OperationContext, ResourceCache, ExpansionCache, debugLog} = require('./operation-context');
13
13
  const { LanguageDefinitions } = require('../library/languages');
14
14
  const { I18nSupport } = require('../library/i18nsupport');
15
15
  const { CodeSystemXML } = require('./xml/codesystem-xml');
@@ -965,6 +965,29 @@ class TXModule {
965
965
  this.countRequest('home', Date.now() - start);
966
966
  }
967
967
  });
968
+
969
+ // External source info pages
970
+ router.get('/info/:id', async (req, res) => {
971
+ const start = Date.now();
972
+ try {
973
+ const source = req.txEndpoint.provider.externalSources.find(s => s.id() === req.params.id);
974
+ if (!source) {
975
+ res.status(404).send('Not found');
976
+ return;
977
+ }
978
+ let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txEndpoint.provider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
979
+ const content = await txhtml.buildInfoPage(source, req);
980
+ const html = await txhtml.renderPage(source.name(), content, req.txEndpoint, start);
981
+ res.setHeader('Content-Type', 'text/html');
982
+ res.send(html);
983
+ } catch (error) {
984
+ debugLog(error);
985
+ this.log.error(`Error rendering info page for ${req.params.id}: ${error.message}`);
986
+ res.status(500).send('Internal server error');
987
+ } finally {
988
+ this.countRequest('info', Date.now() - start);
989
+ }
990
+ });
968
991
  }
969
992
 
970
993
  /**
@@ -1153,16 +1176,16 @@ class TXModule {
1153
1176
  ec = 0;
1154
1177
 
1155
1178
  checkProperJson() { // jsonStr) {
1156
- // const errors = [];
1157
- // if (jsonStr.includes("[]")) errors.push("Found [] in json");
1158
- // if (jsonStr.includes('""')) errors.push('Found "" in json');
1159
- //
1160
- // if (errors.length > 0) {
1161
- // this.ec++;
1162
- // const filename = `/Users/grahamegrieve/temp/tx-err-log/err${this.ec}.json`;
1163
- // writeFileSync(filename, jsonStr);
1164
- // throw new Error(errors.join('; '));
1165
- // }
1179
+ // const errors = [];
1180
+ // if (jsonStr.includes("[]")) errors.push("Found [] in json");
1181
+ // if (jsonStr.includes('""')) errors.push('Found "" in json');
1182
+ //
1183
+ // if (errors.length > 0) {
1184
+ // this.ec++;
1185
+ // const filename = `/Users/grahamegrieve/temp/tx-err-log/err${this.ec}.json`;
1186
+ // writeFileSync(filename, jsonStr);
1187
+ // throw new Error(errors.join('; '));
1188
+ // }
1166
1189
  }
1167
1190
 
1168
1191
  transformResourceForVersion(data, fhirVersion) {
@@ -22,6 +22,27 @@ class ValueSetDatabase {
22
22
  this._writeDb = null; // Write connection (opened only when needed)
23
23
  }
24
24
 
25
+ /**
26
+ * Apply any pending schema migrations
27
+ * @param {sqlite3.Database} db
28
+ * @returns {Promise<void>}
29
+ * @private
30
+ */
31
+ _migrateIfNeeded(db) {
32
+ return new Promise((resolve, reject) => {
33
+ db.all("PRAGMA table_info(valuesets)", [], (err, cols) => {
34
+ if (err) { reject(err); return; }
35
+ const hasCol = cols.some(c => c.name === 'date_first_seen');
36
+ if (hasCol) { resolve(); return; }
37
+ db.run(
38
+ "ALTER TABLE valuesets ADD COLUMN date_first_seen INTEGER DEFAULT 0",
39
+ [],
40
+ (err) => err ? reject(err) : resolve()
41
+ );
42
+ });
43
+ });
44
+ }
45
+
25
46
  /**
26
47
  * Get a read-only database connection (opens lazily if needed)
27
48
  * @returns {Promise<sqlite3.Database>}
@@ -62,7 +83,7 @@ class ValueSetDatabase {
62
83
  this._writeDb = null;
63
84
  reject(new Error(`Failed to open database for writing: ${err.message}`));
64
85
  } else {
65
- resolve(this._writeDb);
86
+ this._migrateIfNeeded(this._writeDb).then(() => resolve(this._writeDb)).catch(reject);
66
87
  }
67
88
  });
68
89
  });
@@ -144,7 +165,8 @@ class ValueSetDatabase {
144
165
  status TEXT,
145
166
  title TEXT,
146
167
  content TEXT NOT NULL,
147
- last_seen INTEGER DEFAULT (strftime('%s', 'now'))
168
+ last_seen INTEGER DEFAULT (strftime('%s', 'now')),
169
+ date_first_seen INTEGER DEFAULT (strftime('%s', 'now'))
148
170
  )
149
171
  `);
150
172
 
@@ -190,6 +212,7 @@ class ValueSetDatabase {
190
212
  db.run('CREATE INDEX idx_valuesets_title ON valuesets(title)');
191
213
  db.run('CREATE INDEX idx_valuesets_publisher ON valuesets(publisher)');
192
214
  db.run('CREATE INDEX idx_valuesets_last_seen ON valuesets(last_seen)');
215
+ db.run('CREATE INDEX idx_valuesets_date_first_seen ON valuesets(date_first_seen)');
193
216
  db.run('CREATE INDEX idx_identifiers_system ON valueset_identifiers(system)');
194
217
  db.run('CREATE INDEX idx_identifiers_value ON valueset_identifiers(value)');
195
218
  db.run('CREATE INDEX idx_jurisdictions_system ON valueset_jurisdictions(system)');
@@ -246,10 +269,24 @@ class ValueSetDatabase {
246
269
  const expansionId = valueSet.expansion?.identifier || null;
247
270
 
248
271
  db.run(`
249
- INSERT OR REPLACE INTO valuesets (
272
+ INSERT INTO valuesets (
250
273
  id, url, version, date, description, effectivePeriod_start, effectivePeriod_end,
251
- expansion_identifier, name, publisher, status, title, content, last_seen
252
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
274
+ expansion_identifier, name, publisher, status, title, content, last_seen, date_first_seen
275
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
276
+ ON CONFLICT(id) DO UPDATE SET
277
+ url=excluded.url,
278
+ version=excluded.version,
279
+ date=excluded.date,
280
+ description=excluded.description,
281
+ effectivePeriod_start=excluded.effectivePeriod_start,
282
+ effectivePeriod_end=excluded.effectivePeriod_end,
283
+ expansion_identifier=excluded.expansion_identifier,
284
+ name=excluded.name,
285
+ publisher=excluded.publisher,
286
+ status=excluded.status,
287
+ title=excluded.title,
288
+ content=excluded.content,
289
+ last_seen=strftime('%s', 'now')
253
290
  `, [
254
291
  valueSet.id,
255
292
  valueSet.url,
package/tx/vs/vs-vsac.js CHANGED
@@ -70,6 +70,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
70
70
  if (!(await this.database.exists())) {
71
71
  await this.database.create();
72
72
  } else {
73
+ // Ensure schema is up to date (e.g. date_first_seen column added after initial deploy)
74
+ await this.database._migrateIfNeeded(await this.database._getWriteConnection());
73
75
  // Load existing data
74
76
  await this._reloadMap();
75
77
  }
@@ -511,6 +513,52 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
511
513
  this.stats.task('VSAC Sync', logMsg);
512
514
 
513
515
  }
516
+
517
+ name() {
518
+ return "VSAC";
519
+ }
520
+
521
+ infoName() {
522
+ return "history";
523
+ }
524
+
525
+ async info() {
526
+ const db = await this.database._getReadConnection();
527
+ const rows = await new Promise((resolve, reject) => {
528
+ db.all(
529
+ `SELECT url, version, date_first_seen
530
+ FROM valuesets
531
+ WHERE date_first_seen > 0
532
+ ORDER BY date_first_seen DESC
533
+ LIMIT 100`,
534
+ [],
535
+ (err, rows) => err ? reject(err) : resolve(rows)
536
+ );
537
+ });
538
+
539
+ const escape = require('escape-html');
540
+ let html = '<h3>Recently Value Sets Added to VSAC</h3>';
541
+ html += '<p>The last ' + rows.length + ' value sets found from VSAC, most recent first.</p>';
542
+ html += '<table class="grid">';
543
+ html += '<thead><tr><th>URL</th><th>Version</th><th>Date Observed</th></tr></thead>';
544
+ html += '<tbody>';
545
+ for (const row of rows) {
546
+ const date = row.date_first_seen
547
+ ? new Date(row.date_first_seen * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC'
548
+ : 'unknown';
549
+ html += '<tr>';
550
+ html += `<td>${escape(row.url || '')}</td>`;
551
+ html += `<td>${escape(row.version || '')}</td>`;
552
+ html += `<td>${escape(date)}</td>`;
553
+ html += '</tr>';
554
+ }
555
+ html += '</tbody></table>';
556
+ return html;
557
+ }
558
+
559
+ id() {
560
+ return "vsac";
561
+ }
514
562
  }
515
563
 
516
564
  // Usage examples:
@@ -1389,17 +1389,22 @@ class ValueSetChecker {
1389
1389
  let hd = list.hasDisplay(this.params.workingLanguages(), null, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE)
1390
1390
  if (!hd.found) {
1391
1391
  let baseMsg;
1392
- if (hd.difference === DisplayDifference.Normalized) {
1392
+ let severity = this.dispWarning();
1393
+ if (list.userDefined) {
1394
+ baseMsg = 'Display_Name_Not_Fixed_use_Supplement';
1395
+ severity = 'information';
1396
+ } else if (hd.difference === DisplayDifference.Normalized) {
1393
1397
  baseMsg = 'Display_Name_WS_for__should_be_one_of__instead_of';
1394
1398
  } else {
1395
1399
  baseMsg = 'Display_Name_for__should_be_one_of__instead_of';
1396
1400
  }
1397
- let mid = baseMsg;
1398
1401
  let dc = list.displayCount(this.params.workingLanguages(), null, true);
1399
- let severity = this.dispWarning();
1400
- if (dc === 0) {
1401
- severity = 'warning';
1402
- dc = list.displayCount(this.params.workingLanguages(), null, false);
1402
+ let mid = baseMsg;
1403
+ if (severity !== 'information') {
1404
+ if (dc === 0) {
1405
+ severity = 'warning';
1406
+ dc = list.displayCount(this.params.workingLanguages(), null, false);
1407
+ }
1403
1408
  }
1404
1409
 
1405
1410
  let m, ds;
@@ -276,12 +276,9 @@ class TerminologyWorker {
276
276
  if (this.hasSupplement(cs, supplements)) {
277
277
  continue;
278
278
  }
279
- // Handle exact URL match (no version specified in supplements)
279
+ // Handle exact URL match (no version specified in supplements field)
280
280
  if (supplementsUrl === url) {
281
- // If we're looking for a specific version, only include if no version in supplements URL
282
- if (!version) {
283
- supplements.push(cs);
284
- }
281
+ supplements.push(cs);
285
282
  continue;
286
283
  }
287
284