fhirsmith 0.18.0 → 0.18.1

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.
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const sqlite3 = require('sqlite3').verbose();
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
+ const { csvRecords } = require('./csv-records');
6
7
  const chalk = require('chalk');
7
8
 
8
9
  class LoincModule extends BaseTerminologyModule {
@@ -291,7 +292,10 @@ class LoincModule extends BaseTerminologyModule {
291
292
  // Translations are a different case: a main-only import is a legitimate thing to want,
292
293
  // and their absence costs designations, not correctness
293
294
  const optionalFiles = [
294
- 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv'
295
+ 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv',
296
+ // the external code mappings: their absence costs maps to other code systems, nothing else
297
+ 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
298
+ 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv'
295
299
  ];
296
300
 
297
301
  const warnings = [];
@@ -428,6 +432,11 @@ class LoincModule extends BaseTerminologyModule {
428
432
  'CREATE INDEX IF NOT EXISTS idx_descriptions_type ON Descriptions(DescriptionTypeKey)',
429
433
  'CREATE INDEX IF NOT EXISTS idx_descriptions_code_type ON Descriptions(CodeKey, DescriptionTypeKey)',
430
434
 
435
+ // External mapping indexes
436
+ 'CREATE INDEX IF NOT EXISTS idx_externalcodes_code ON ExternalCodes(System, Code)',
437
+ 'CREATE INDEX IF NOT EXISTS idx_externalmappings_code ON ExternalMappings(CodeKey)',
438
+ 'CREATE INDEX IF NOT EXISTS idx_externalmappings_target ON ExternalMappings(ExternalCodeKey)',
439
+
431
440
  // Closure table indexes
432
441
  'CREATE INDEX IF NOT EXISTS idx_closure_ancestor ON Closure(AncestorKey)',
433
442
  'CREATE INDEX IF NOT EXISTS idx_closure_descendent ON Closure(DescendentKey)'
@@ -506,7 +515,9 @@ class LoincDataMigratorWithProgress {
506
515
  'AccessoryFiles/AnswerFile/AnswerList.csv',
507
516
  'AccessoryFiles/PartFile/LoincPartLink_Primary.csv',
508
517
  'AccessoryFiles/AnswerFile/LoincAnswerListLink.csv',
509
- 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv'
518
+ 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv',
519
+ 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
520
+ 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv'
510
521
  ];
511
522
 
512
523
  // Count main files
@@ -599,6 +610,10 @@ class LoincDataMigrator {
599
610
  this.props = new Map();
600
611
  this.propValues = new Map();
601
612
  this.partNames = new Map();
613
+ this.extCodeKey = 0;
614
+ this.extCodes = new Map(); // system|version|code -> ExternalCodeKey
615
+ this.extMapKey = 0;
616
+ this.extMaps = new Set(); // codeKey|ExternalCodeKey
602
617
 
603
618
  // Create tables and initial data
604
619
  await this.createTables(db, version, options.verbose);
@@ -631,6 +646,10 @@ class LoincDataMigrator {
631
646
  this.updateProgress(0, 'List Links');
632
647
  await this.processListLinks(db, sourceDir, 8, options);
633
648
 
649
+ this.updateProgress(0, 'External Mappings');
650
+ await this.processPartMappings(db, sourceDir, 9, options);
651
+ await this.processIeeeMappings(db, sourceDir, 9, options);
652
+
634
653
  this.updateProgress(0, 'Hierarchy');
635
654
  await this.processHierarchy(db, sourceDir, 9, options);
636
655
 
@@ -719,6 +738,21 @@ class LoincDataMigrator {
719
738
  DescriptionTypeKey INTEGER NOT NULL,
720
739
  Value TEXT NOT NULL
721
740
  )`,
741
+ // Codes in other code systems that LOINC codes map to (one row per system|version|code)
742
+ `CREATE TABLE ExternalCodes (
743
+ ExternalCodeKey INTEGER NOT NULL PRIMARY KEY,
744
+ System TEXT NOT NULL,
745
+ Version TEXT NULL,
746
+ Code TEXT NOT NULL,
747
+ Display TEXT NULL
748
+ )`,
749
+ // LOINC code (Codes.CodeKey) -> ExternalCodes, with a ConceptMap equivalence (R4 codes)
750
+ `CREATE TABLE ExternalMappings (
751
+ ExternalMappingKey INTEGER NOT NULL PRIMARY KEY,
752
+ CodeKey INTEGER NOT NULL,
753
+ ExternalCodeKey INTEGER NOT NULL,
754
+ Equivalence TEXT NOT NULL
755
+ )`,
722
756
  `CREATE TABLE Closure (
723
757
  AncestorKey INTEGER NOT NULL,
724
758
  DescendentKey INTEGER NOT NULL,
@@ -843,10 +877,7 @@ class LoincDataMigrator {
843
877
  return;
844
878
  }
845
879
 
846
- const rl = readline.createInterface({
847
- input: fs.createReadStream(filePath),
848
- crlfDelay: Infinity
849
- });
880
+ const rl = csvRecords(filePath);
850
881
 
851
882
  let lineCount = 0;
852
883
  for await (const line of rl) {
@@ -872,10 +903,7 @@ class LoincDataMigrator {
872
903
  if (options.verbose) console.log('Processing Parts...');
873
904
 
874
905
  const filePath = path.join(sourceDir, 'AccessoryFiles/PartFile/Part.csv');
875
- const rl = readline.createInterface({
876
- input: fs.createReadStream(filePath),
877
- crlfDelay: Infinity
878
- });
906
+ const rl = csvRecords(filePath);
879
907
 
880
908
  let lineCount = 0;
881
909
  let processedCount = 0;
@@ -927,10 +955,7 @@ class LoincDataMigrator {
927
955
  if (options.verbose) console.log('Processing Main Codes...');
928
956
 
929
957
  const filePath = path.join(sourceDir, 'LoincTable/Loinc.csv');
930
- const rl = readline.createInterface({
931
- input: fs.createReadStream(filePath),
932
- crlfDelay: Infinity
933
- });
958
+ const rl = csvRecords(filePath);
934
959
 
935
960
  let lineCount = 0;
936
961
  let processedCount = 0;
@@ -1007,10 +1032,7 @@ class LoincDataMigrator {
1007
1032
 
1008
1033
  if (options.verbose) console.log('Processing Consumer Names...');
1009
1034
 
1010
- const rl = readline.createInterface({
1011
- input: fs.createReadStream(filePath),
1012
- crlfDelay: Infinity
1013
- });
1035
+ const rl = csvRecords(filePath);
1014
1036
 
1015
1037
  let lineCount = 0;
1016
1038
  let processedCount = 0;
@@ -1045,10 +1067,7 @@ class LoincDataMigrator {
1045
1067
 
1046
1068
  if (options.verbose) console.log('Processing Answer Lists...');
1047
1069
 
1048
- const rl = readline.createInterface({
1049
- input: fs.createReadStream(filePath),
1050
- crlfDelay: Infinity
1051
- });
1070
+ const rl = csvRecords(filePath);
1052
1071
 
1053
1072
  let lineCount = 0;
1054
1073
  let processedCount = 0;
@@ -1058,7 +1077,7 @@ class LoincDataMigrator {
1058
1077
  lineCount++;
1059
1078
  if (lineCount === 1) continue;
1060
1079
 
1061
- const items = csvSplit(line, 11);
1080
+ const items = csvSplit(line, 15);
1062
1081
  if (items.length < 11) continue;
1063
1082
 
1064
1083
  await this.processListItem(db, items, currentList);
@@ -1111,6 +1130,111 @@ class LoincDataMigrator {
1111
1130
 
1112
1131
  this.addRelationship(db, listCodeKey, answerCodeKey, this.rels.get('Answer'));
1113
1132
  this.addRelationship(db, answerCodeKey, listCodeKey, this.rels.get('AnswerList'));
1133
+
1134
+ // ExtCodeId (11), ExtCodeDisplayName (12), ExtCodeSystem (13), ExtCodeSystemVersion (14).
1135
+ // The file carries no equivalence for these; an answer and its external code are the same thing
1136
+ if (items[11] && items[13]) {
1137
+ this.addExternalMapping(db, answerCodeKey, items[13], items[14], items[11], items[12], 'equivalent');
1138
+ }
1139
+ }
1140
+
1141
+ async processPartMappings(db, sourceDir, step, options) {
1142
+ const filePath = path.join(sourceDir, 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv');
1143
+ if (!fs.existsSync(filePath)) {
1144
+ if (options.verbose) console.warn(`Part mapping file not found: ${filePath}`);
1145
+ return;
1146
+ }
1147
+
1148
+ if (options.verbose) console.log('Processing Part Mappings...');
1149
+
1150
+ const rl = csvRecords(filePath);
1151
+
1152
+ // PartNumber, PartName, PartTypeName, ExtCodeId, ExtCodeDisplayName, ExtCodeSystem,
1153
+ // Equivalence, ContentOrigin, ExtCodeSystemVersion, ExtCodeSystemCopyrightNotice
1154
+ let lineCount = 0;
1155
+ let processedCount = 0;
1156
+ for await (const line of rl) {
1157
+ lineCount++;
1158
+ if (lineCount === 1) continue;
1159
+
1160
+ const items = csvSplit(line, 10);
1161
+ const partCode = items[0];
1162
+ const system = items[5];
1163
+ // RadLex mappings are not simple code-to-code maps; not handled yet
1164
+ if (!items[3] || !system || system === 'http://www.radlex.org' || !this.codes.has(partCode)) continue;
1165
+
1166
+ this.addExternalMapping(db, this.codes.get(partCode).key, system, items[8], items[3], items[4], items[6] || 'equivalent');
1167
+ processedCount++;
1168
+
1169
+ if (processedCount % 100 === 0) {
1170
+ this.updateProgress(100);
1171
+ }
1172
+ }
1173
+
1174
+ const remaining = processedCount % 100;
1175
+ if (remaining > 0) {
1176
+ this.updateProgress(remaining);
1177
+ }
1178
+ }
1179
+
1180
+ async processIeeeMappings(db, sourceDir, step, options) {
1181
+ const filePath = path.join(sourceDir, 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv');
1182
+ if (!fs.existsSync(filePath)) {
1183
+ if (options.verbose) console.warn(`IEEE mapping file not found: ${filePath}`);
1184
+ return;
1185
+ }
1186
+
1187
+ if (options.verbose) console.log('Processing IEEE Mappings...');
1188
+
1189
+ const rl = csvRecords(filePath);
1190
+
1191
+ // LOINC_NUM, LOINC_LONG_COMMON_NAME, IEEE_CF_CODE10, IEEE_REFID, EQUIVALENCE
1192
+ // The code is the CF_CODE10 (as used in FHIR, e.g. 150456); the REFID is its display.
1193
+ let lineCount = 0;
1194
+ let processedCount = 0;
1195
+ for await (const line of rl) {
1196
+ lineCount++;
1197
+ if (lineCount === 1) continue;
1198
+
1199
+ const items = csvSplit(line, 5);
1200
+ const loincCode = items[0];
1201
+ if (!items[2] || !this.codes.has(loincCode)) continue;
1202
+
1203
+ this.addExternalMapping(db, this.codes.get(loincCode).key, 'urn:iso:std:iso:11073:10101', null,
1204
+ items[2], items[3], ieeeEquivalence(items[4]));
1205
+ processedCount++;
1206
+
1207
+ if (processedCount % 100 === 0) {
1208
+ this.updateProgress(100);
1209
+ }
1210
+ }
1211
+
1212
+ const remaining = processedCount % 100;
1213
+ if (remaining > 0) {
1214
+ this.updateProgress(remaining);
1215
+ }
1216
+ }
1217
+
1218
+ addExternalMapping(db, codeKey, system, version, code, display, equivalence) {
1219
+ system = normaliseExternalSystem(system);
1220
+ version = version || null;
1221
+ const targetId = system + '|' + (version || '') + '|' + code;
1222
+ let extKey = this.extCodes.get(targetId);
1223
+ if (!extKey) {
1224
+ this.extCodeKey++;
1225
+ extKey = this.extCodeKey;
1226
+ this.extCodes.set(targetId, extKey);
1227
+ db.run('INSERT INTO ExternalCodes (ExternalCodeKey, System, Version, Code, Display) VALUES (?, ?, ?, ?, ?)',
1228
+ [extKey, system, version, code, display || null]);
1229
+ }
1230
+
1231
+ // the same answer appears once per answer list it's in
1232
+ const mapId = codeKey + '|' + extKey;
1233
+ if (this.extMaps.has(mapId)) return;
1234
+ this.extMaps.add(mapId);
1235
+ this.extMapKey++;
1236
+ db.run('INSERT INTO ExternalMappings (ExternalMappingKey, CodeKey, ExternalCodeKey, Equivalence) VALUES (?, ?, ?, ?)',
1237
+ [this.extMapKey, codeKey, extKey, equivalence]);
1114
1238
  }
1115
1239
 
1116
1240
  async processPartLinks(db, sourceDir, step, options) {
@@ -1122,10 +1246,7 @@ class LoincDataMigrator {
1122
1246
 
1123
1247
  if (options.verbose) console.log('Processing Part Links...');
1124
1248
 
1125
- const rl = readline.createInterface({
1126
- input: fs.createReadStream(filePath),
1127
- crlfDelay: Infinity
1128
- });
1249
+ const rl = csvRecords(filePath);
1129
1250
 
1130
1251
  let lineCount = 0;
1131
1252
  let processedCount = 0;
@@ -1171,10 +1292,7 @@ class LoincDataMigrator {
1171
1292
 
1172
1293
  if (options.verbose) console.log('Processing List Links...');
1173
1294
 
1174
- const rl = readline.createInterface({
1175
- input: fs.createReadStream(filePath),
1176
- crlfDelay: Infinity
1177
- });
1295
+ const rl = csvRecords(filePath);
1178
1296
 
1179
1297
  let lineCount = 0;
1180
1298
  let processedCount = 0;
@@ -1227,10 +1345,7 @@ class LoincDataMigrator {
1227
1345
 
1228
1346
  if (options.verbose) console.log('Processing Hierarchy...');
1229
1347
 
1230
- const rl = readline.createInterface({
1231
- input: fs.createReadStream(filePath),
1232
- crlfDelay: Infinity
1233
- });
1348
+ const rl = csvRecords(filePath);
1234
1349
 
1235
1350
  let lineCount = 0;
1236
1351
  let processedCount = 0;
@@ -1258,7 +1373,7 @@ class LoincDataMigrator {
1258
1373
  async processHierarchyItem(db, items) {
1259
1374
  const pathCode = items[3];
1260
1375
  const parentPath = items[2];
1261
- const description = items[4];
1376
+ const description = items[4].trim();
1262
1377
 
1263
1378
  if (!this.codes.has(pathCode)) {
1264
1379
  this.codeKey++;
@@ -1339,10 +1454,7 @@ class LoincDataMigrator {
1339
1454
  return;
1340
1455
  }
1341
1456
 
1342
- const rl = readline.createInterface({
1343
- input: fs.createReadStream(filePath),
1344
- crlfDelay: Infinity
1345
- });
1457
+ const rl = csvRecords(filePath);
1346
1458
 
1347
1459
  let lineCount = 0;
1348
1460
  let processedCount = 0;
@@ -1488,6 +1600,29 @@ function adjustPropName(s) {
1488
1600
  throw new Error(`Unknown Property Name: ${s}`);
1489
1601
  }
1490
1602
 
1603
+ // LOINC names some code systems with something other than their FHIR URI. The others not yet
1604
+ // known ('CMS LCDS', 'Unicode') are left as LOINC has them
1605
+ const EXTERNAL_SYSTEM_URIS = {
1606
+ 'CMS/HCPCS': 'http://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets',
1607
+ 'https://www.cdc.gov/phin': 'urn:oid:2.16.840.1.113883.6.238' // CDC Race & Ethnicity
1608
+ };
1609
+
1610
+ function normaliseExternalSystem(system) {
1611
+ return EXTERNAL_SYSTEM_URIS[system] || system;
1612
+ }
1613
+
1614
+ // The IEEE table states equivalence from the LOINC side ('broader' = the LOINC code is broader,
1615
+ // e.g. Glucose in Serum or Plasma -> MDC_CONC_GLU_VENOUS_PLASMA). ConceptMap equivalence is stated
1616
+ // from the target side, so broader/narrower swap to narrower/wider.
1617
+ function ieeeEquivalence(s) {
1618
+ switch (s) {
1619
+ case 'broader': return 'narrower';
1620
+ case 'narrower': return 'wider';
1621
+ case '': return 'equivalent';
1622
+ default: return s;
1623
+ }
1624
+ }
1625
+
1491
1626
  function descClassType(s) {
1492
1627
  const types = {
1493
1628
  '1': 'Laboratory class',
@@ -1539,5 +1674,6 @@ function removeQuotes(str) {
1539
1674
 
1540
1675
  module.exports = {
1541
1676
  LoincModule,
1542
- LoincDataMigrator
1677
+ LoincDataMigrator,
1678
+ normaliseExternalSystem
1543
1679
  };
@@ -1136,6 +1136,9 @@ class SnomedImporter {
1136
1136
  // Step 10: Read relationships
1137
1137
  await this.readRelationships();
1138
1138
 
1139
+ // Step 10a: Read alternate identifiers (e.g. the LOINC codes of the LOINC Extension)
1140
+ await this.readIdentifiers();
1141
+
1139
1142
  // Step 11: Link concepts
1140
1143
  this.linkConcepts();
1141
1144
 
@@ -1165,6 +1168,7 @@ class SnomedImporter {
1165
1168
  concepts: [],
1166
1169
  descriptions: [],
1167
1170
  relationships: [],
1171
+ identifiers: [],
1168
1172
  refsetDirectories: []
1169
1173
  };
1170
1174
 
@@ -1218,6 +1222,8 @@ class SnomedImporter {
1218
1222
  } else if (firstLine.startsWith('id\teffectiveTime\tactive\tmoduleId\tsourceId\tdestinationId\trelationshipGroup\ttypeId\tcharacteristicTypeId\tmodifierId') &&
1219
1223
  !filePath.includes('StatedRelationship')) {
1220
1224
  files.relationships.push(filePath);
1225
+ } else if (files.identifiers && firstLine.startsWith('alternateIdentifier\teffectiveTime\tactive\tmoduleId\tidentifierSchemeId\treferencedComponentId')) {
1226
+ files.identifiers.push(filePath);
1221
1227
  }
1222
1228
  } catch (error) {
1223
1229
  console.log(`DEBUG: Error reading file ${filePath}: ${error.message}`);
@@ -1726,6 +1732,56 @@ class SnomedImporter {
1726
1732
  return /^\d+$/.test(str);
1727
1733
  }
1728
1734
 
1735
+ /**
1736
+ * Read the alternate identifier files (sct2_Identifier). Each active row gives a
1737
+ * concept a code in another scheme - the LOINC Extension, for instance, gives each
1738
+ * of its concepts its LOINC code (scheme 30051010000102 |LOINC code identifier|).
1739
+ * Kept as [scheme, module, code, conceptId] for the concepts in this edition; where
1740
+ * a row appears more than once (Full files, or base + extension) the latest wins.
1741
+ */
1742
+ async readIdentifiers() {
1743
+ const rows = new Map(); // scheme|code|concept -> {effectiveTime, active, row}
1744
+ for (const file of this.files.identifiers || []) {
1745
+ const rl = readline.createInterface({
1746
+ input: fs.createReadStream(file),
1747
+ crlfDelay: Infinity
1748
+ });
1749
+ let lineCount = 0;
1750
+ for await (const line of rl) {
1751
+ lineCount++;
1752
+ if (lineCount === 1 || !line) continue;
1753
+ const parts = line.split('\t');
1754
+ if (parts.length < 6) continue;
1755
+ const [code, effectiveTime, active, moduleId, schemeId, conceptId] = parts.map(p => p.trim());
1756
+ const key = schemeId + '|' + code + '|' + conceptId;
1757
+ const prior = rows.get(key);
1758
+ if (!prior || prior.effectiveTime <= effectiveTime) {
1759
+ rows.set(key, { effectiveTime, active: active === '1', row: [schemeId, moduleId, code, conceptId] });
1760
+ }
1761
+ }
1762
+ }
1763
+ this.identifiers = [];
1764
+ let missing = 0;
1765
+ for (const r of rows.values()) {
1766
+ if (!r.active) continue;
1767
+ let known = false;
1768
+ try {
1769
+ known = this.conceptMap.has(BigInt(r.row[3]));
1770
+ } catch {
1771
+ known = false;
1772
+ }
1773
+ if (known) {
1774
+ this.identifiers.push(r.row);
1775
+ } else {
1776
+ missing++;
1777
+ }
1778
+ }
1779
+ this.identifiers.sort((a, b) => (a.join('|') < b.join('|') ? -1 : 1));
1780
+ if (this.identifiers.length > 0 || missing > 0) {
1781
+ console.log(`Alternate identifiers: ${this.identifiers.length}${missing ? ` (${missing} for concepts not in this edition, ignored)` : ''}`);
1782
+ }
1783
+ }
1784
+
1729
1785
  async readRelationships() {
1730
1786
  // Estimate total lines
1731
1787
  let totalLines = 0;
@@ -2766,8 +2822,12 @@ class SnomedImporter {
2766
2822
 
2767
2823
  progressBar?.update(50);
2768
2824
 
2825
+ const identifiers = this.identifiers || [];
2769
2826
  await writer.writeCache({
2770
- version: '17', // Current version
2827
+ // 18 = 17 plus the alternate identifiers. Only used when there are some, so that
2828
+ // caches without them stay readable by servers that only know 17
2829
+ version: identifiers.length > 0 ? '18' : '17',
2830
+ identifiers: identifiers,
2771
2831
  versionUri: this.isTesting ? this.config.uri.replace("/sct/", "/xsct/") : this.config.uri,
2772
2832
  versionDate: this.extractDateFromUri(this.config.uri),
2773
2833
 
@@ -2854,6 +2914,11 @@ class SnomedCacheWriter {
2854
2914
  }
2855
2915
  buffers.push(this.writeInteger(data.defaultLanguage));
2856
2916
 
2917
+ if (data.version === '18') {
2918
+ // alternate identifiers, as JSON: [[scheme, module, code, conceptId], ...]
2919
+ buffers.push(this.writeBytes(Buffer.from(JSON.stringify(data.identifiers || []), 'utf8')));
2920
+ }
2921
+
2857
2922
  // Write to file
2858
2923
  const finalBuffer = Buffer.concat(buffers);
2859
2924
  await fs.promises.writeFile(this.filePath, finalBuffer);
@@ -2,6 +2,7 @@ const { BaseTerminologyModule } = require('./tx-import-base');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const readline = require('readline');
5
+ const { csvRecords } = require('./csv-records');
5
6
  const chalk = require('chalk');
6
7
 
7
8
  class LoincSubsetModule extends BaseTerminologyModule {
@@ -716,6 +717,16 @@ class LoincSubsetProcessor {
716
717
  dest: 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv',
717
718
  handler: 'processHierarchy'
718
719
  },
720
+ {
721
+ source: 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
722
+ dest: 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
723
+ handler: 'processFirstColumnCodes'
724
+ },
725
+ {
726
+ source: 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv',
727
+ dest: 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv',
728
+ handler: 'processFirstColumnCodes'
729
+ },
719
730
  {
720
731
  source: 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv',
721
732
  dest: 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv',
@@ -787,7 +798,8 @@ class LoincSubsetProcessor {
787
798
  path.join(destDir, 'AccessoryFiles/ConsumerName'),
788
799
  path.join(destDir, 'AccessoryFiles/AnswerFile'),
789
800
  path.join(destDir, 'AccessoryFiles/ComponentHierarchyBySystem'),
790
- path.join(destDir, 'AccessoryFiles/LinguisticVariants')
801
+ path.join(destDir, 'AccessoryFiles/LinguisticVariants'),
802
+ path.join(destDir, 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable')
791
803
  ];
792
804
 
793
805
  for (const dir of dirs) {
@@ -879,6 +891,16 @@ class LoincSubsetProcessor {
879
891
  });
880
892
  }
881
893
 
894
+ // keep the rows whose first column (the LOINC code or part) is in the subset
895
+ async processFirstColumnCodes(sourcePath, destPath) {
896
+ await this.processFileWithFilter(sourcePath, destPath, (line, lineNum) => {
897
+ if (lineNum === 1) return true; // Keep header
898
+
899
+ const items = this.csvSplit(line, 1);
900
+ return this.targetCodes.has(this.removeQuotes(items[0]));
901
+ });
902
+ }
903
+
882
904
  async processAnswerLists(sourcePath, destPath) {
883
905
  await this.processFileWithFilter(sourcePath, destPath, (line, lineNum) => {
884
906
  if (lineNum === 1) return true; // Keep header
@@ -1002,13 +1024,10 @@ class LoincSubsetProcessor {
1002
1024
  return;
1003
1025
  }
1004
1026
 
1005
- const readStream = fs.createReadStream(sourcePath);
1006
1027
  const writeStream = fs.createWriteStream(destPath);
1007
1028
 
1008
- const rl = readline.createInterface({
1009
- input: readStream,
1010
- crlfDelay: Infinity
1011
- });
1029
+ // records, not lines: a quoted field can hold a line break
1030
+ const rl = csvRecords(sourcePath);
1012
1031
 
1013
1032
  let lineNum = 0;
1014
1033
  let includedLines = 0;
@@ -649,7 +649,10 @@ class CodeSystem extends CanonicalResource {
649
649
  if (this.jsonObj.url === 'https://terminology.dhp.uz/fhir/CodeSystem/loinc-supplement-uz') {
650
650
  return true;
651
651
  }
652
- return (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type' && getValuePrimitive(x) == 'lang-pack');
652
+ // a global language pack is used whether or not it is referenced. The code was 'lang-pack'
653
+ // until Oct 2026; it is still accepted, so that supplements marked the old way keep working
654
+ return (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type'
655
+ && ['global-lang-pack', 'lang-pack'].includes(getValuePrimitive(x)));
653
656
  }
654
657
 
655
658
  isPropUri(code, uri) {
@@ -564,6 +564,19 @@ class Designations {
564
564
  return this._langsMatch(langList, cd.language, LangMatchType.LANG, defLang);
565
565
  }
566
566
 
567
+ /**
568
+ * Note, in the set of supplements to report as used, the supplement a designation came from (if
569
+ * it came from one). Designations from a supplement carry it as .supplement (see
570
+ * CodeSystemProvider._listSupplementDesignations); this used to look for .source, which is never
571
+ * set on a designation, so a supplement that only supplied the chosen display - a global language
572
+ * pack, typically - was never reported as used.
573
+ */
574
+ _reportSupplement(supplements, cd) {
575
+ if (supplements && cd && cd.supplement && cd.supplement.vurl) {
576
+ supplements.add(cd.supplement.vurl);
577
+ }
578
+ }
579
+
567
580
  /**
568
581
  * Find the preferred designation for given language preferences
569
582
  */
@@ -576,39 +589,29 @@ class Designations {
576
589
  // No language list, prefer base designations
577
590
  for (const cd of this.designations) {
578
591
  if (this._isPreferred(cd) && cd.isActive()) {
579
- if (supplements && cd.source) {
580
- supplements.add(cd.source);
581
- }
592
+ this._reportSupplement(supplements, cd);
582
593
  return cd;
583
594
  }
584
595
  }
585
596
  for (const cd of this.designations) {
586
597
  if (this.isDisplay(cd) && cd.isActive()) {
587
- if (supplements && cd.source) {
588
- supplements.add(cd.source);
589
- }
598
+ this._reportSupplement(supplements, cd);
590
599
  return cd;
591
600
  }
592
601
  }
593
602
  for (const cd of this.designations) {
594
603
  if (this.isDisplay(cd)) {
595
- if (supplements && cd.source) {
596
- supplements.add(cd.source);
597
- }
604
+ this._reportSupplement(supplements, cd);
598
605
  return cd;
599
606
  }
600
607
  }
601
608
  for (const cd of this.designations) {
602
609
  if (this._isPreferred(cd)) {
603
- if (supplements && cd.source) {
604
- supplements.add(cd.source);
605
- }
610
+ this._reportSupplement(supplements, cd);
606
611
  return cd;
607
612
  }
608
613
  }
609
- if (supplements && this.designations[0].source) {
610
- supplements.add(this.designations[0].source);
611
- }
614
+ this._reportSupplement(supplements, this.designations[0]);
612
615
  return this.designations[0];
613
616
  }
614
617
 
@@ -628,25 +631,19 @@ class Designations {
628
631
  // because the FSN was emitted first.
629
632
  for (const cd of this.designations) {
630
633
  if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd) && (!activeOnly || cd.isActive())) {
631
- if (supplements && cd.source) {
632
- supplements.add(cd.source);
633
- }
634
+ this._reportSupplement(supplements, cd);
634
635
  return cd;
635
636
  }
636
637
  }
637
638
  for (const cd of this.designations) {
638
639
  if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
639
- if (supplements && cd.source) {
640
- supplements.add(cd.source);
641
- }
640
+ this._reportSupplement(supplements, cd);
642
641
  return cd;
643
642
  }
644
643
  }
645
644
  for (const cd of this.designations) {
646
645
  if (this._langMatches(lang, cd.language, matchType) && (!activeOnly || cd.isActive())) {
647
- if (supplements && cd.source) {
648
- supplements.add(cd.source);
649
- }
646
+ this._reportSupplement(supplements, cd);
650
647
  return cd;
651
648
  }
652
649
  }
@@ -655,14 +652,10 @@ class Designations {
655
652
  }
656
653
  for (const cd of this.designations) {
657
654
  if (!cd.language && this.isDisplay(cd) && cd.isActive()) {
658
- if (supplements && cd.source) {
659
- supplements.add(cd.source);
660
- }
655
+ this._reportSupplement(supplements, cd);
661
656
  return cd;
662
657
  } if (!cd.language && this.isDisplay(cd)) {
663
- if (supplements && cd.source) {
664
- supplements.add(cd.source);
665
- }
658
+ this._reportSupplement(supplements, cd);
666
659
  return cd;
667
660
  }
668
661
  }
@@ -332,7 +332,8 @@ const OPERATIONS = [
332
332
  p('name', 'string', '1', 'The closure table', { min: 1 }),
333
333
  p('concept', 'Coding', '*', 'A concept to add'),
334
334
  p('version', 'string', '1', 'Return the changes since this version of the table, instead of adding concepts'),
335
- p('reset', 'boolean', '1', 'Start the table again')
335
+ p('reset', 'boolean', '1', 'Start the table again'),
336
+ p('dispose', 'boolean', '1', 'The client has finished with the table, and the server can delete it (not in the specification)')
336
337
  ],
337
338
  returns: 'ConceptMap',
338
339
  returnsDoc: 'The new relationships'