fhirsmith 0.16.1 → 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.
- package/CHANGELOG.md +47 -0
- package/README.md +26 -18
- package/config-template.json +25 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/library/html-server.js +16 -16
- package/library/html.js +9 -0
- package/library/narrative-check.js +203 -0
- package/package.json +2 -1
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +4 -62
- package/packages/utilities/dedupe-versions.js +22 -8
- package/registry/crawler.js +8 -1
- package/registry/registry.js +18 -11
- package/release.sh +80 -0
- package/testing/readme.md +1 -1
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-cs.js +86 -18
- package/tx/cs/cs-loinc.js +32 -14
- package/tx/library.js +9 -2
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/problems.js +7 -5
- package/tx/provider.js +5 -1
- package/tx/tx-html.js +22 -7
- package/tx/tx.js +4 -3
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/workers/expand.js +1 -1
- package/tx/workers/metadata.js +6 -1
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +88 -12
- package/gr.sh +0 -3
package/registry/registry.js
CHANGED
|
@@ -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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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/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://
|
|
34
|
+
to a /testing server (by default https://npm.fhir.org/testing).
|
|
35
35
|
|
|
36
36
|
## Reading and searching
|
|
37
37
|
|
package/tx/cm/cm-database.js
CHANGED
|
@@ -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
|
package/tx/cm/cm-package.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
1246
|
+
if (texts.code === searchTerm) {
|
|
1186
1247
|
rating = 100;
|
|
1187
|
-
} else if (
|
|
1248
|
+
} else if (display && display.text === searchTerm) {
|
|
1188
1249
|
rating = 100;
|
|
1189
1250
|
}
|
|
1190
1251
|
// Code starts with search term
|
|
1191
|
-
else if (
|
|
1252
|
+
else if (texts.code.startsWith(searchTerm)) {
|
|
1192
1253
|
rating = 90;
|
|
1193
1254
|
}
|
|
1194
1255
|
// Display starts with search term
|
|
1195
|
-
else if (
|
|
1196
|
-
const lengthRatio = searchTerm.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 (
|
|
1261
|
+
else if (texts.code.includes(searchTerm)) {
|
|
1201
1262
|
rating = 60;
|
|
1202
1263
|
}
|
|
1203
1264
|
// Display contains search term
|
|
1204
|
-
else if (
|
|
1265
|
+
else if (display && display.text.includes(searchTerm)) {
|
|
1205
1266
|
rating = 50;
|
|
1206
1267
|
}
|
|
1207
1268
|
// Definition contains search term
|
|
1208
|
-
else if (
|
|
1269
|
+
else if (texts.definition && texts.definition.includes(searchTerm)) {
|
|
1209
1270
|
rating = 30;
|
|
1210
1271
|
}
|
|
1211
1272
|
// Check designations
|
|
1212
|
-
else if (
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) => {
|
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
|
-
|
|
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
|
-
|
|
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}`);
|