fhirsmith 0.16.0 → 0.17.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 (43) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +40 -19
  3. package/config-template.json +25 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/library/html-server.js +16 -16
  7. package/library/html.js +9 -0
  8. package/library/narrative-check.js +203 -0
  9. package/library/npm-audit.js +42 -3
  10. package/package.json +2 -1
  11. package/packages/openapi.yaml +6 -3
  12. package/packages/package-crawler.js +189 -82
  13. package/packages/packages.js +33 -69
  14. package/packages/readme.md +1 -1
  15. package/packages/utilities/dedupe-versions.js +22 -8
  16. package/registry/crawler.js +8 -1
  17. package/registry/registry.js +18 -11
  18. package/release.sh +80 -0
  19. package/shl/shl.js +5 -3
  20. package/testing/readme.md +1 -1
  21. package/tx/cm/cm-database.js +62 -0
  22. package/tx/cm/cm-package.js +20 -3
  23. package/tx/cs/cs-cs.js +86 -18
  24. package/tx/cs/cs-loinc.js +32 -14
  25. package/tx/library/resource-ids.js +16 -1
  26. package/tx/library.js +9 -2
  27. package/tx/ocl/README.md +62 -6
  28. package/tx/ocl/cm-ocl.cjs +166 -44
  29. package/tx/ocl/cs-ocl.cjs +163 -13
  30. package/tx/ocl/resolve/public-access.js +143 -0
  31. package/tx/ocl/resolve/reference-resolver.js +458 -0
  32. package/tx/ocl/vs-ocl.cjs +167 -4
  33. package/tx/problems.js +7 -5
  34. package/tx/provider.js +5 -1
  35. package/tx/tx-html.js +46 -7
  36. package/tx/tx.js +22 -6
  37. package/tx/vs/vs-database.js +26 -0
  38. package/tx/vs/vs-package.js +42 -10
  39. package/tx/workers/expand.js +1 -1
  40. package/tx/workers/metadata.js +6 -1
  41. package/tx/workers/read.js +87 -5
  42. package/utilities/submit-test-report.js +1 -1
  43. package/xig/xig.js +88 -12
@@ -19,6 +19,12 @@ const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-rel
19
19
  const DISCOVERY_PARAMS = ['registry', 'server', 'fhirVersion', 'url', 'authoritativeOnly', 'language'];
20
20
  const RESOLVE_PARAMS = ['fhirVersion', 'url', 'version', 'valueSet', 'authoritativeOnly', 'language', 'usage'];
21
21
 
22
+ // A link to a URL from a registry list or a crawled server: those are other people's
23
+ // content, so only an http(s) URL is linked, and it is escaped
24
+ function safeHref(url) {
25
+ return typeof url === 'string' && /^https?:\/\//i.test(url.trim()) ? escape(url) : '#';
26
+ }
27
+
22
28
  class RegistryModule {
23
29
  constructor(stats) {
24
30
  this.router = express.Router();
@@ -50,7 +56,8 @@ class RegistryModule {
50
56
  masterUrl: config.masterUrl || 'https://fhir.github.io/ig-registry/tx-servers.json',
51
57
  timeout: config.timeout || 30000,
52
58
  userAgent: config.userAgent || 'FHIRRegistryServer/1.0',
53
- apiKeys: config.apiKeys || {}
59
+ apiKeys: config.apiKeys || {},
60
+ maxResponseMB: config.maxResponseMB
54
61
  };
55
62
 
56
63
  this.crawler = new RegistryCrawler(crawlerConfig, this.stats);
@@ -536,7 +543,7 @@ class RegistryModule {
536
543
  for (const row of rows) {
537
544
  html += '<tr>';
538
545
  html += `<td>${escape(row.name)}</td>`;
539
- html += `<td><a href="${escape(row.url)}" target="_blank" rel="noopener">${escape(row.url)}</a>`;
546
+ html += `<td><a href="${safeHref(row.url)}" target="_blank" rel="noopener">${escape(row.url)}</a>`;
540
547
  if (row.error) {
541
548
  html += ` <span class="text-danger" title="${escape(row.error)}">(unreachable)</span>`;
542
549
  }
@@ -682,7 +689,7 @@ class RegistryModule {
682
689
 
683
690
  for (const server of serverVersions) {
684
691
  html += '<tr>';
685
- html += `<td><a href="${server.serverUrl}" target="_blank">${escape(server.serverUrl)}</a></td>`;
692
+ html += `<td><a href="${safeHref(server.serverUrl)}" target="_blank">${escape(server.serverUrl)}</a></td>`;
686
693
  html += `<td>${escape(server.software.replace("Reference Server", "HealthIntersections"))}</td>`;
687
694
  html += `<td>${escape(server.authority.replace("Published by", ""))}</td>`;
688
695
  html += `<td>${escape(server.version)}</td>`;
@@ -1032,7 +1039,7 @@ class RegistryModule {
1032
1039
  const rowspan = cs.servers.length;
1033
1040
  html += '<tr>';
1034
1041
  html += `<td rowspan="${rowspan}">${this._highlightWildcard(escape(formattedMask))}</td>`;
1035
- html += `<td><a href="${escape(cs.servers[0].url)}" target="_blank">${escape(cs.servers[0].url)}</a>${this._renderLanguageScope(cs.servers[0])}</td>`;
1042
+ html += `<td><a href="${safeHref(cs.servers[0].url)}" target="_blank">${escape(cs.servers[0].url)}</a>${this._renderLanguageScope(cs.servers[0])}</td>`;
1036
1043
 
1037
1044
  // Format versions as R3/R4/R5
1038
1045
  const formattedVersions = cs.servers[0].versions.map(v => this._formatFhirVersion(v));
@@ -1042,7 +1049,7 @@ class RegistryModule {
1042
1049
  // Additional rows for this code system (if any)
1043
1050
  for (let i = 1; i < cs.servers.length; i++) {
1044
1051
  html += '<tr>';
1045
- html += `<td><a href="${escape(cs.servers[i].url)}" target="_blank">${escape(cs.servers[i].url)}</a>${this._renderLanguageScope(cs.servers[i])}</td>`;
1052
+ html += `<td><a href="${safeHref(cs.servers[i].url)}" target="_blank">${escape(cs.servers[i].url)}</a>${this._renderLanguageScope(cs.servers[i])}</td>`;
1046
1053
 
1047
1054
  // Format versions as R3/R4/R5
1048
1055
  const formattedVersions = cs.servers[i].versions.map(v => this._formatFhirVersion(v));
@@ -1088,7 +1095,7 @@ class RegistryModule {
1088
1095
  const rowspan = vs.servers.length;
1089
1096
  html += '<tr>';
1090
1097
  html += `<td rowspan="${rowspan}">${this._highlightWildcard(escape(vs.mask))}</td>`;
1091
- html += `<td><a href="${escape(vs.servers[0].url)}" target="_blank">${escape(vs.servers[0].url)}</a>${this._renderLanguageScope(vs.servers[0])}</td>`;
1098
+ html += `<td><a href="${safeHref(vs.servers[0].url)}" target="_blank">${escape(vs.servers[0].url)}</a>${this._renderLanguageScope(vs.servers[0])}</td>`;
1092
1099
 
1093
1100
  // Format versions as R3/R4/R5
1094
1101
  const formattedVersions = vs.servers[0].versions.map(v => this._formatFhirVersion(v));
@@ -1098,7 +1105,7 @@ class RegistryModule {
1098
1105
  // Additional rows for this value set (if any)
1099
1106
  for (let i = 1; i < vs.servers.length; i++) {
1100
1107
  html += '<tr>';
1101
- html += `<td><a href="${escape(vs.servers[i].url)}" target="_blank">${escape(vs.servers[i].url)}</a>${this._renderLanguageScope(vs.servers[i])}</td>`;
1108
+ html += `<td><a href="${safeHref(vs.servers[i].url)}" target="_blank">${escape(vs.servers[i].url)}</a>${this._renderLanguageScope(vs.servers[i])}</td>`;
1102
1109
 
1103
1110
  // Format versions as R3/R4/R5
1104
1111
  const formattedVersions = vs.servers[i].versions.map(v => this._formatFhirVersion(v));
@@ -1322,7 +1329,7 @@ class RegistryModule {
1322
1329
  html += '<div class="card-body">';
1323
1330
  html += `<p><strong>FHIR Version:</strong> ${escape(fhirVersion)}</p>`;
1324
1331
  html += `<p><strong>Resource URL:</strong> ${escape(resourceUrl)}</p>`;
1325
- html += `<p><strong>Registry URL:</strong> <a href="${result['registry-url']}" target="_blank">${escape(result['registry-url'])}</a></p>`;
1332
+ html += `<p><strong>Registry URL:</strong> <a href="${safeHref(result['registry-url'])}" target="_blank">${escape(result['registry-url'])}</a></p>`;
1326
1333
  if (usage) {
1327
1334
  html += `<p><strong>Usage:</strong> ${escape(usage)}</p>`;
1328
1335
  }
@@ -1355,7 +1362,7 @@ class RegistryModule {
1355
1362
  result.authoritative.forEach(server => {
1356
1363
  html += '<tr>';
1357
1364
  html += `<td>${escape(server['server-name'])}</td>`;
1358
- html += `<td><a href="${server.url}" target="_blank">${escape(server.url)}</a></td>`;
1365
+ html += `<td><a href="${safeHref(server.url)}" target="_blank">${escape(server.url)}</a></td>`;
1359
1366
  html += `<td>${this.renderSecurityTags(server)}</td>`;
1360
1367
  html += `<td>${server.languages ? escape(server.languages.join(', ')) : ''}</td>`;
1361
1368
  html += `<td>${server.access_info ? escape(server.access_info) : ''}</td>`;
@@ -1395,7 +1402,7 @@ class RegistryModule {
1395
1402
  result.candidates.forEach(server => {
1396
1403
  html += '<tr>';
1397
1404
  html += `<td>${escape(server['server-name'])}</td>`;
1398
- html += `<td><a href="${server.url}" target="_blank">${escape(server.url)}</a></td>`;
1405
+ html += `<td><a href="${safeHref(server.url)}" target="_blank">${escape(server.url)}</a></td>`;
1399
1406
  html += `<td>${this.renderSecurityTags(server)}</td>`;
1400
1407
  if (server['language-support'] === 'unknown') {
1401
1408
  html += '<td><span class="text-muted" title="This server hosts the code system, but the ecosystem does not know whether it can supply the requested language">unknown</span></td>';
@@ -1570,7 +1577,7 @@ class RegistryModule {
1570
1577
  res.send(html);
1571
1578
  } catch (error) {
1572
1579
  this.logger.error('Error rendering log page:', error);
1573
- res.status(500).send(`<pre>Error rendering log page: ${error.message}</pre>`);
1580
+ res.status(500).send(`<pre>Error rendering log page: ${escape(error.message)}</pre>`);
1574
1581
  }
1575
1582
  } else {
1576
1583
  // Return JSON logs
package/release.sh ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Release FHIRsmith: ./release.sh 0.17.0
4
+ #
5
+ # Checks that the release is ready, submits the tx test report to the /testing server, then
6
+ # tags the release and pushes the tag (which starts .github/workflows/release.yml).
7
+ # Stops at the first problem, before anything is tagged.
8
+ #
9
+ # The test report: run the tx tests first (npx jest tests/tx/test-cases.test.js). If the
10
+ # /testing server needs a token, set FHIRSMITH_TESTING_TOKEN.
11
+
12
+ set -euo pipefail
13
+
14
+ fail() {
15
+ echo "gr.sh: $*" >&2
16
+ exit 1
17
+ }
18
+
19
+ [ $# -eq 1 ] || fail "usage: ./gr.sh <version>, e.g. ./gr.sh 0.17.0"
20
+ VERSION="${1#v}"
21
+ TAG="v$VERSION"
22
+ [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "'$1' is not a release version (X.Y.Z)"
23
+
24
+ cd "$(dirname "$0")"
25
+
26
+ # --- git: on main, nothing uncommitted, and exactly what is on GitHub -------------------
27
+ [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || fail "not on main"
28
+ [ -z "$(git status --porcelain --untracked-files=no)" ] || fail "there are uncommitted changes"
29
+ git fetch --quiet origin main --tags
30
+ [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] \
31
+ || fail "main is not the same as origin/main - push (or pull) first, so the tag is on a commit GitHub has"
32
+ ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG already exists"
33
+
34
+ # --- versions: what release.yml's verify-version job checks ----------------------------
35
+ check_version() {
36
+ local what="$1" found="$2"
37
+ [ "$found" = "$VERSION" ] || fail "$what is '$found', not $VERSION (npm version --no-git-tag-version $VERSION && npm install --package-lock-only)"
38
+ }
39
+ check_version "package.json version" "$(node -p "require('./package.json').version")"
40
+ check_version "package-lock.json version" "$(node -p "require('./package-lock.json').version")"
41
+ check_version 'package-lock.json packages[""]' "$(node -p "require('./package-lock.json').packages[''].version")"
42
+
43
+ # --- CHANGELOG: a dated section for this version, with the conformance statement in -----
44
+ HEADING=$(grep -E "^## \[v?${VERSION//./\\.}\]" CHANGELOG.md || true)
45
+ [ -n "$HEADING" ] || fail "CHANGELOG.md has no section for $VERSION"
46
+ [[ "$HEADING" =~ [0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "the CHANGELOG.md section for $VERSION has no date: $HEADING"
47
+ SECTION=$(sed -n "/^## \[v\{0,1\}${VERSION//./\\.}\]/,/^## \[/p" CHANGELOG.md | sed '$d')
48
+ ! grep -qx '(paste)' <<<"$SECTION" || fail "the Tx Conformance Statement for $VERSION hasn't been pasted into CHANGELOG.md"
49
+
50
+ # --- the test report: this version, passed, and run on what is being released ----------
51
+ REPORT=test-cases-report.json
52
+ [ -f "$REPORT" ] || fail "no $REPORT - run the tx tests first (npx jest tests/tx/test-cases.test.js)"
53
+ # the last commit that changed what the tests run - not the notes, nor the version bump
54
+ CODE_COMMITTED=$(git log -1 --format=%cI -- . ':(exclude)*.md' ':(exclude)release.sh' ':(exclude)package.json' ':(exclude)package-lock.json')
55
+ node - "$REPORT" "$VERSION" "$CODE_COMMITTED" <<'NODE' || exit 1
56
+ const [file, version, committed] = process.argv.slice(2);
57
+ const report = JSON.parse(require('fs').readFileSync(file, 'utf8'));
58
+ const fhirsmith = (report.participant || []).find(p => p.uri === 'https://github.com/HealthIntersections/FHIRsmith');
59
+ const problems = [];
60
+ if (!fhirsmith || fhirsmith.version !== version) {
61
+ problems.push(`it is from FHIRsmith ${fhirsmith ? fhirsmith.version : '(unknown)'}, not ${version}`);
62
+ }
63
+ if (report.result !== 'pass') {
64
+ problems.push(`its result is '${report.result}', not 'pass'`);
65
+ }
66
+ if (!report.issued || new Date(report.issued) < new Date(committed)) {
67
+ problems.push(`it was run (${report.issued}) before the last change to the code (${committed})`);
68
+ }
69
+ if (problems.length > 0) {
70
+ console.error(`gr.sh: ${file} can't be submitted for ${version}: ${problems.join('; ')} - run the tx tests again`);
71
+ process.exit(1);
72
+ }
73
+ NODE
74
+
75
+ # --- submit the report, then tag ---------------------------------------------------------
76
+ node utilities/submit-test-report.js -file "$REPORT" || fail "the test report was not accepted, so $TAG has not been tagged"
77
+
78
+ git tag -a "$TAG" -m "FHIRsmith $VERSION"
79
+ git push origin "$TAG"
80
+ echo "Tagged and pushed $TAG - the release workflow is now running"
package/shl/shl.js CHANGED
@@ -418,10 +418,12 @@ class SHLModule {
418
418
  }
419
419
  }
420
420
 
421
- stopCleanupJob() {
421
+ // node-cron 4: stop() may return a promise
422
+ async stopCleanupJob() {
422
423
  if (this.cleanupJob) {
423
- this.cleanupJob.stop();
424
+ const job = this.cleanupJob;
424
425
  this.cleanupJob = null;
426
+ await job.stop();
425
427
  }
426
428
  }
427
429
 
@@ -1060,7 +1062,7 @@ class SHLModule {
1060
1062
  async shutdown() {
1061
1063
  shlLog.info('Shutting down SHL module...');
1062
1064
 
1063
- this.stopCleanupJob();
1065
+ await this.stopCleanupJob();
1064
1066
 
1065
1067
  // Stop FHIR validator
1066
1068
  if (this.fhirValidator) {
package/testing/readme.md CHANGED
@@ -31,7 +31,7 @@ shown on any page.
31
31
 
32
32
  FHIRsmith's own tx test run writes its results as `test-cases-report.json` in the
33
33
  FHIRsmith root; `node utilities/submit-test-report.js [-server url] [-token token]` sends it
34
- to a /testing server (by default https://testing.fhir.org/testing).
34
+ to a /testing server (by default https://npm.fhir.org/testing).
35
35
 
36
36
  ## Reading and searching
37
37
 
@@ -2,6 +2,9 @@ const fs = require('fs').promises;
2
2
  const sqlite3 = require('sqlite3').verbose();
3
3
  const { VersionUtilities } = require('../../library/version-utilities');
4
4
  const { ConceptMap } = require('../library/conceptmap');
5
+ // Key of the row written once the cache has been fully populated (see isComplete)
6
+ const CACHE_COMPLETE_KEY = 'cache-complete';
7
+
5
8
  // Columns that can be returned directly without parsing JSON
6
9
  const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', 'publisher', 'status', 'title'];
7
10
 
@@ -113,6 +116,14 @@ class ConceptMapDatabase {
113
116
  db.run('CREATE INDEX idx_jurisdictions_code ON conceptmap_jurisdictions(code)');
114
117
  db.run('CREATE INDEX idx_systems_system ON conceptmap_systems(system)');
115
118
 
119
+ // Cache status (key-value) - holds the marker that population finished
120
+ db.run(`
121
+ CREATE TABLE cache_status (
122
+ key TEXT PRIMARY KEY,
123
+ value TEXT
124
+ )
125
+ `);
126
+
116
127
  db.close((err) => {
117
128
  if (err) {
118
129
  reject(new Error(`Failed to close database after creation: ${err.message}`));
@@ -125,6 +136,57 @@ class ConceptMapDatabase {
125
136
  });
126
137
  }
127
138
 
139
+ /**
140
+ * True if this cache was fully populated: markComplete() was reached. A cache
141
+ * whose population was cut short (the process killed part way through) has no
142
+ * marker - nor does one built before the marker existed, or a file too damaged
143
+ * to read - so it reports false.
144
+ * @returns {Promise<boolean>}
145
+ */
146
+ async isComplete() {
147
+ return new Promise((resolve) => {
148
+ const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
149
+ if (err) {
150
+ resolve(false);
151
+ return;
152
+ }
153
+ db.get('SELECT value FROM cache_status WHERE key = ?', [CACHE_COMPLETE_KEY], (err, row) => {
154
+ db.close(() => resolve(!err && row != null));
155
+ });
156
+ });
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Record that this cache has been fully populated. Called once, after the last
162
+ * concept map has been written
163
+ * @returns {Promise<void>}
164
+ */
165
+ async markComplete() {
166
+ return new Promise((resolve, reject) => {
167
+ const db = new sqlite3.Database(this.dbPath, (err) => {
168
+ if (err) {
169
+ reject(new Error(`Failed to open database: ${err.message}`));
170
+ return;
171
+ }
172
+ db.serialize(() => {
173
+ db.run('CREATE TABLE IF NOT EXISTS cache_status (key TEXT PRIMARY KEY, value TEXT)');
174
+ db.run(`INSERT INTO cache_status (key, value) VALUES (?, ?)
175
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
176
+ [CACHE_COMPLETE_KEY, new Date().toISOString()], (err) => {
177
+ db.close((closeErr) => {
178
+ if (err || closeErr) {
179
+ reject(new Error(`Failed to mark cache complete: ${(err || closeErr).message}`));
180
+ } else {
181
+ resolve();
182
+ }
183
+ });
184
+ });
185
+ });
186
+ });
187
+ });
188
+ }
189
+
128
190
  /**
129
191
  * Insert or update a single ConceptMap in the database
130
192
  * @param {Object} conceptMap - The ConceptMap resource
@@ -1,4 +1,6 @@
1
1
  const path = require('path');
2
+ const fs = require('fs').promises;
3
+ const { assertNoActiveContent } = require('../../library/narrative-check');
2
4
  const { AbstractConceptMapProvider } = require('./cm-api');
3
5
  const { PackageContentLoader } = require('../../library/package-manager');
4
6
  const { ConceptMapDatabase } = require('./cm-database');
@@ -20,7 +22,9 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
20
22
  super();
21
23
  validateParameter(packageLoader, "packageLoader", PackageContentLoader);
22
24
  this.packageLoader = packageLoader;
23
- this.dbPath = path.join(packageLoader.packageFolder, '.conceptmaps.db');
25
+ // -2: caches from before concept maps were checked for active content in their
26
+ // narrative are rebuilt (see initialize)
27
+ this.dbPath = path.join(packageLoader.packageFolder, '.conceptmaps-2.db');
24
28
  this.database = new ConceptMapDatabase(this.dbPath);
25
29
  this.conceptMapMap = new Map();
26
30
  this.initialized = false;
@@ -36,12 +40,23 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
36
40
  return;
37
41
  }
38
42
  await this.packageLoader.initialize();
39
-
40
- const dbExists = await this.database.exists();
43
+ await fs.rm(path.join(this.packageLoader.packageFolder, '.conceptmaps.db'), { force: true }); // the old cache
44
+
45
+ // A cache is only used once its population finished. One that was cut short
46
+ // (the process killed part way through) would otherwise be trusted forever,
47
+ // silently missing whatever hadn't been written yet
48
+ let dbExists = await this.database.exists();
49
+ if (dbExists && !(await this.database.isComplete())) {
50
+ require('../../library/logger').getInstance().child({ module: 'tx' })
51
+ .warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: concept map cache ${this.dbPath} is incomplete - rebuilding it`);
52
+ await fs.rm(this.dbPath, { force: true });
53
+ dbExists = false;
54
+ }
41
55
 
42
56
  if (!dbExists) {
43
57
  await this.database.create();
44
58
  await this._populateDatabase();
59
+ await this.database.markComplete();
45
60
  }
46
61
 
47
62
  this.conceptMapMap = await this.database.loadAllConceptMaps();
@@ -68,6 +83,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
68
83
  const conceptMap = await this.packageLoader.loadFile(entry);
69
84
  if (conceptMap.url) {
70
85
  try {
86
+ // its narrative is shown as-is in a page, so it must not carry script
87
+ assertNoActiveContent(conceptMap);
71
88
  conceptMaps.push(new ConceptMap(conceptMap, this.packageLoader.fhirVersion())); // get in converted to R5 format
72
89
  } catch (e) {
73
90
  // one unacceptable concept map (e.g. one with contained resources, which the
package/tx/cs/cs-cs.js CHANGED
@@ -19,6 +19,63 @@ const regexUtilities = require("../../library/regex-utilities");
19
19
  // filter results do not depend on forIteration, so one form is stored per version.
20
20
  const FILTER_ANALYSIS = Symbol('filterAnalysis');
21
21
 
22
+ // The texts of each concept as a text filter compares them, by concept. Folding is
23
+ // the costly part of a search over a large code system, so it is done once per
24
+ // concept, and dropped with the code system.
25
+ const SEARCH_TEXTS = new WeakMap();
26
+
27
+ /**
28
+ * Lower case without accents, so "colera" finds "Cólera". Only marks on Latin, Greek and
29
+ * Cyrillic letters are accents: in other scripts, such as kana or Devanagari, marks are
30
+ * part of how a word is spelt, and dropping them would make "がん" find "かん".
31
+ */
32
+ function foldSearchText(text) {
33
+ return text.normalize('NFD')
34
+ .replace(/([\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '$1')
35
+ .toLowerCase();
36
+ }
37
+
38
+ /**
39
+ * A text as a text filter compares it: `text` folded, and `words` the same with each
40
+ * run of other characters made one space and a space in front, so that " " + word
41
+ * finds where a word starts.
42
+ */
43
+ function searchText(text) {
44
+ const folded = foldSearchText(text);
45
+ return { text: folded, words: ' ' + folded.replace(/[^\p{L}\p{N}]+/gu, ' ') };
46
+ }
47
+
48
+ function conceptSearchTexts(concept) {
49
+ let texts = SEARCH_TEXTS.get(concept);
50
+ if (texts === undefined) {
51
+ texts = {
52
+ code: foldSearchText(concept.code),
53
+ display: concept.display ? searchText(concept.display) : null,
54
+ definition: concept.definition ? foldSearchText(concept.definition) : null,
55
+ designations: Array.isArray(concept.designation)
56
+ ? concept.designation.filter(designation => designation.value).map(designation => searchText(designation.value))
57
+ : []
58
+ };
59
+ SEARCH_TEXTS.set(concept, texts);
60
+ }
61
+ return texts;
62
+ }
63
+
64
+ /**
65
+ * Whether every word starts a word of the text.
66
+ */
67
+ function startsWords(text, words) {
68
+ return words.every(word => text.words.includes(' ' + word));
69
+ }
70
+
71
+ /**
72
+ * How much of the text the words cover, from 0 to 1, counting one space between words
73
+ * whatever separated them in the search.
74
+ */
75
+ function wordsRatio(words, text) {
76
+ return Math.min(1, words.join(' ').length / text.text.length);
77
+ }
78
+
22
79
  /**
23
80
  * Context class for FHIR CodeSystem provider concepts
24
81
  */
@@ -1145,14 +1202,15 @@ class FhirCodeSystemProvider extends BaseCSServices {
1145
1202
 
1146
1203
 
1147
1204
  const results = new FhirCodeSystemProviderFilterContext();
1148
- const searchTerm = filter.filter.toLowerCase();
1205
+ const searchTerm = foldSearchText(filter.filter);
1206
+ const searchWords = searchTerm.split(/[^\p{L}\p{N}]+/u).filter(word => word !== '');
1149
1207
 
1150
1208
  // Search through all concepts
1151
1209
  const allConcepts = this.codeSystem.getAllConcepts();
1152
1210
 
1153
1211
  for (const concept of allConcepts) {
1154
1212
  this.opContext.deadCheck('cs:searchFilter');
1155
- const rating = this._calculateSearchRating(concept, searchTerm);
1213
+ const rating = this._calculateSearchRating(concept, searchTerm, searchWords);
1156
1214
  if (rating > 0) {
1157
1215
  results.add(concept, rating);
1158
1216
  }
@@ -1174,47 +1232,57 @@ class FhirCodeSystemProvider extends BaseCSServices {
1174
1232
  /**
1175
1233
  * Calculate search relevance rating for a concept
1176
1234
  * @param {Object} concept - The concept to rate
1177
- * @param {string} searchTerm - The search term (lowercase)
1235
+ * @param {string} searchTerm - The search term (folded: lower case, no accents)
1236
+ * @param {string[]} searchWords - The words of the search term
1178
1237
  * @returns {number} Rating (0 = no match, higher = better match)
1179
1238
  * @private
1180
1239
  */
1181
- _calculateSearchRating(concept, searchTerm) {
1240
+ _calculateSearchRating(concept, searchTerm, searchWords) {
1241
+ const texts = conceptSearchTexts(concept);
1242
+ const display = texts.display;
1182
1243
  let rating = 0;
1183
1244
 
1184
1245
  // Exact matches get highest rating
1185
- if (concept.code.toLowerCase() === searchTerm) {
1246
+ if (texts.code === searchTerm) {
1186
1247
  rating = 100;
1187
- } else if (concept.display && concept.display.toLowerCase() === searchTerm) {
1248
+ } else if (display && display.text === searchTerm) {
1188
1249
  rating = 100;
1189
1250
  }
1190
1251
  // Code starts with search term
1191
- else if (concept.code.toLowerCase().startsWith(searchTerm)) {
1252
+ else if (texts.code.startsWith(searchTerm)) {
1192
1253
  rating = 90;
1193
1254
  }
1194
1255
  // Display starts with search term
1195
- else if (concept.display && concept.display.toLowerCase().startsWith(searchTerm)) {
1196
- const lengthRatio = searchTerm.length / concept.display.length;
1256
+ else if (display && display.text.startsWith(searchTerm)) {
1257
+ const lengthRatio = searchTerm.length / display.text.length;
1197
1258
  rating = 80 + (10 * lengthRatio);
1198
1259
  }
1199
1260
  // Code contains search term
1200
- else if (concept.code.toLowerCase().includes(searchTerm)) {
1261
+ else if (texts.code.includes(searchTerm)) {
1201
1262
  rating = 60;
1202
1263
  }
1203
1264
  // Display contains search term
1204
- else if (concept.display && concept.display.toLowerCase().includes(searchTerm)) {
1265
+ else if (display && display.text.includes(searchTerm)) {
1205
1266
  rating = 50;
1206
1267
  }
1207
1268
  // Definition contains search term
1208
- else if (concept.definition && concept.definition.toLowerCase().includes(searchTerm)) {
1269
+ else if (texts.definition && texts.definition.includes(searchTerm)) {
1209
1270
  rating = 30;
1210
1271
  }
1211
1272
  // Check designations
1212
- else if (concept.designation && Array.isArray(concept.designation)) {
1213
- for (const designation of concept.designation) {
1214
- if (designation.value && designation.value.toLowerCase().includes(searchTerm)) {
1215
- rating = 40;
1216
- break;
1217
- }
1273
+ else if (texts.designations.some(designation => designation.text.includes(searchTerm))) {
1274
+ rating = 40;
1275
+ }
1276
+ // Each word of a search of several starts a word of the display, in any order;
1277
+ // the shorter the display, the closer the match
1278
+ else if (searchWords.length > 1 && display && startsWords(display, searchWords)) {
1279
+ rating = 20 + (10 * wordsRatio(searchWords, display));
1280
+ }
1281
+ // Or of one designation
1282
+ else if (searchWords.length > 1) {
1283
+ const designation = texts.designations.find(designation => startsWords(designation, searchWords));
1284
+ if (designation) {
1285
+ rating = 10 + (10 * wordsRatio(searchWords, designation));
1218
1286
  }
1219
1287
  }
1220
1288
 
package/tx/cs/cs-loinc.js CHANGED
@@ -664,9 +664,10 @@ class LoincServices extends BaseCSServices {
664
664
  filterContext.filters.push(filter);
665
665
  }
666
666
 
667
- async searchFilter(filterContext, filterText, sort) {
667
+ async searchFilter(filterContext, filterText, sort) { // eslint-disable-line no-unused-vars
668
668
  const filter = new LoincFilterHolder();
669
- await this.#executeFilterQuery('$text', (sort ? '>' : '<'), filterText.filter, filter);
669
+ // LOINC does not rate its matches, so sorted or not they come in description order
670
+ await this.#executeFilterQuery('$text', '<', filterText.filter, filter);
670
671
  filterContext.filters.push(filter);
671
672
  }
672
673
 
@@ -859,12 +860,7 @@ class LoincServices extends BaseCSServices {
859
860
  }
860
861
  // Concept hierarchy filters (is-a, descendent-of)
861
862
  else if (prop === 'concept' && ['is-a', 'descendent-of'].includes(op)) {
862
- sql = `SELECT DescendentKey as Key FROM Closure
863
- WHERE AncestorKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
864
- ORDER BY DescendentKey ASC`;
865
- lsql = `SELECT COUNT(DescendentKey) FROM Closure
866
- WHERE AncestorKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
867
- AND DescendentKey = `;
863
+ ({ sql, lsql } = this.#hierarchyFilterSql(op, value));
868
864
  }
869
865
  // Concept equal filter (workaround for VSAC misuse)
870
866
  else if (prop === 'concept' && op === '=') {
@@ -887,12 +883,7 @@ class LoincServices extends BaseCSServices {
887
883
  }
888
884
  // Code property filters (workaround for VSAC misuse)
889
885
  else if (prop === 'code' && ['is-a', 'descendent-of'].includes(op)) {
890
- sql = `SELECT DescendentKey as Key FROM Closure
891
- WHERE AncestorKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
892
- ORDER BY DescendentKey ASC`;
893
- lsql = `SELECT COUNT(DescendentKey) FROM Closure
894
- WHERE AncestorKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
895
- AND DescendentKey = `;
886
+ ({ sql, lsql } = this.#hierarchyFilterSql(op, value));
896
887
  }
897
888
  else if (prop === 'code' && op === '=') {
898
889
  sql = `SELECT CodeKey as Key FROM Codes
@@ -936,6 +927,33 @@ class LoincServices extends BaseCSServices {
936
927
  }
937
928
  }
938
929
 
930
+ /**
931
+ * The SQL for an is-a or descendent-of filter. The Closure table holds the strict descendants
932
+ * of each part (and never the part itself), which is descendent-of. is-a also includes the
933
+ * concept itself, as FHIR defines it.
934
+ *
935
+ * The closure follows LOINC's ComponentHierarchyBySystem path by path: a code is a descendant
936
+ * of the parts on its own PATH_TO_ROOT. A part that sits under two parents has its children by
937
+ * system listed separately under each, and they are not inherited across the paths - reading
938
+ * the file as a graph would, for example, put Glucose in Serum or Plasma under Urinalysis,
939
+ * because Protein appears under both.
940
+ */
941
+ #hierarchyFilterSql(op, value) {
942
+ const code = this.#sqlWrapString(value);
943
+ const descendants = `SELECT DescendentKey as Key FROM Closure
944
+ WHERE AncestorKey IN (SELECT CodeKey FROM Codes WHERE Code = '${code}')`;
945
+ const set = op === 'is-a'
946
+ ? `${descendants}
947
+ UNION
948
+ SELECT CodeKey as Key FROM Codes WHERE Code = '${code}'`
949
+ : descendants;
950
+ return {
951
+ sql: `${set}
952
+ ORDER BY Key ASC`,
953
+ lsql: `SELECT COUNT(*) FROM (${set}) WHERE Key = `
954
+ };
955
+ }
956
+
939
957
  // Helper method for regex matching
940
958
  async #findRegexMatches(sql, pattern, valueColumn, keyColumn = 'Key') {
941
959
  return new Promise((resolve, reject) => {
@@ -58,6 +58,21 @@ function stripIdPrefix(id, spaceId) {
58
58
  return id.startsWith(prefix) && id.length > prefix.length ? id.substring(prefix.length) : null;
59
59
  }
60
60
 
61
+ /**
62
+ * The id a resource had in its source, for an id in any id space: everything after the
63
+ * prefix, which never contains a '-' (see checkIdPrefix). Null if the id has no prefix.
64
+ *
65
+ * @param {string} id
66
+ * @returns {string|null}
67
+ */
68
+ function originalId(id) {
69
+ if (typeof id !== 'string') {
70
+ return null;
71
+ }
72
+ const i = id.indexOf('-');
73
+ return i > 0 && i < id.length - 1 ? id.substring(i + 1) : null;
74
+ }
75
+
61
76
  /**
62
77
  * Whether a prefix given in the library YAML is acceptable. It's the start of an id, so it
63
78
  * may only use id characters, and no '-', so that the prefix of any id is unambiguous.
@@ -104,4 +119,4 @@ function assignIdSpace(provider, spaceId) {
104
119
 
105
120
  const CORE_PREFIX = 'core';
106
121
 
107
- module.exports = { applyIdPrefix, stripIdPrefix, checkIdPrefix, assignIdSpace, CORE_PREFIX };
122
+ module.exports = { applyIdPrefix, stripIdPrefix, originalId, checkIdPrefix, assignIdSpace, CORE_PREFIX };
package/tx/library.js CHANGED
@@ -5,6 +5,7 @@ const yaml = require('yaml'); // npm install yaml
5
5
  const { PackageManager, PackageContentLoader } = require('../library/package-manager');
6
6
  const { FolderContentLoader, FileContentLoader } = require('../library/folder-content-loader');
7
7
  const { CodeSystem } = require("./library/codesystem");
8
+ const { assertNoActiveContent } = require('../library/narrative-check');
8
9
  const {CountryCodeFactoryProvider} = require("./cs/cs-country");
9
10
  const {Iso4217FactoryProvider} = require("./cs/cs-currency");
10
11
  const {AreaCodeFactoryProvider} = require("./cs/cs-areacode");
@@ -738,7 +739,10 @@ class Library {
738
739
  for (const resource of resources) {
739
740
  let cs;
740
741
  try {
741
- cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
742
+ const json = await contentLoader.loadFile(resource, contentLoader.fhirVersion());
743
+ // narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
744
+ assertNoActiveContent(json);
745
+ cs = new CodeSystem(json);
742
746
  } catch (e) {
743
747
  // one unacceptable resource (e.g. contained resources the server doesn't support)
744
748
  // must not stop the rest of the package loading
@@ -857,7 +861,10 @@ class Library {
857
861
  for (const entry of csEntries) {
858
862
  let cs;
859
863
  try {
860
- cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion()));
864
+ const json = await contentLoader.loadFile(entry, contentLoader.fhirVersion());
865
+ // narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
866
+ assertNoActiveContent(json);
867
+ cs = new CodeSystem(json);
861
868
  } catch (e) {
862
869
  // one unacceptable resource must not stop the rest of the package loading
863
870
  this.log.warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${entry.id || entry.filename || ''}: ${e.message}`);