fhirsmith 0.15.0 → 0.15.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.
- package/CHANGELOG.md +33 -1
- package/config-template.json +10 -0
- package/library/npm-audit.js +62 -9
- package/package.json +2 -2
- package/packages/npm-check-page.js +108 -0
- package/packages/npm-check.js +450 -0
- package/packages/openapi.yaml +158 -5
- package/packages/package-crawler.js +203 -20
- package/packages/packages.js +250 -8
- package/packages/readme.md +55 -2
- package/packages/utilities/dedupe-versions.js +242 -0
- package/registry/registry.js +10 -3
- package/translations/Messages.properties +3 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-icd11.js +23 -6
- package/tx/cs/icd11-expressions.js +5 -6
- package/tx/importers/icd11-schema.md +1 -0
- package/tx/importers/import-icd11.js +39 -0
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/workers/lookup.js +7 -4
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
4
|
+
//
|
|
5
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One-off repair of a package server's database and mirror, for a database built before
|
|
10
|
+
* the crawler stored each id#version only once.
|
|
11
|
+
*
|
|
12
|
+
* node packages/utilities/dedupe-versions.js -db <packages.db> -mirror <mirror folder> [-apply] [-verbose]
|
|
13
|
+
*
|
|
14
|
+
* Without -apply it reports what it would do and changes nothing. Back up the database
|
|
15
|
+
* first. It is safe to run while the server is up, but best between crawler runs. It can be
|
|
16
|
+
* run with sudo: rewritten mirror files keep the owner, group and mode of the files they replace.
|
|
17
|
+
*
|
|
18
|
+
* 1. Duplicate rows. For each id#version stored more than once, it keeps one row, using the
|
|
19
|
+
* crawler's rule: a direct copy beats a Simplifier copy (a secondary echo); otherwise the
|
|
20
|
+
* first stored wins. Two direct copies with different content are a conflict - only their
|
|
21
|
+
* Simplifier rows are removed, and they are listed for manual cleanup. Deleted rows'
|
|
22
|
+
* download counts are added to the kept row.
|
|
23
|
+
* 2. The unique index on (Id, Version), once there are no duplicates left.
|
|
24
|
+
* 3. The mirror. Every row's mirror file must be the bytes that row's Hash describes; a file
|
|
25
|
+
* that is missing or different is rewritten from the row's Content. Conflicts are skipped.
|
|
26
|
+
* The report separates the two: a different file is served with the wrong checksum; a
|
|
27
|
+
* missing one isn't served at all. -verbose lists every missing file.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
// node:sqlite (built into Node 24) rather than an npm package, so this file runs on its own:
|
|
34
|
+
// copy it to the server and run it with node, no install needed
|
|
35
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
36
|
+
|
|
37
|
+
const UNIQUE_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS SK_PackageVersions_IdVersion ON PackageVersions (Id, Version)';
|
|
38
|
+
|
|
39
|
+
// These two must match PackageCrawler.isSimplifierGuid and PackageCrawler.fixPrefix
|
|
40
|
+
// (tests/packages/package-crawler-versions.test.js checks that they do)
|
|
41
|
+
function isSimplifier(guid) {
|
|
42
|
+
return /^https?:\/\/packages\.simplifier\.net\//i.test(guid || '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fixPrefix(id) {
|
|
46
|
+
return id && id.startsWith('@') && id.includes('/') ? id.replace('@', '$$').replace('/', '$') : id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mirrorName = (id, version) => `${fixPrefix(id)}-${version}.tgz`;
|
|
50
|
+
|
|
51
|
+
// When run with sudo, a rewritten file would otherwise belong to root. Give it the owner,
|
|
52
|
+
// group and mode of the file it replaces (or of the mirror folder, for a missing file).
|
|
53
|
+
function keepOwnership(file, like) {
|
|
54
|
+
const st = fs.statSync(like);
|
|
55
|
+
if (process.getuid && process.getuid() === 0) {
|
|
56
|
+
fs.chownSync(file, st.uid, st.gid);
|
|
57
|
+
}
|
|
58
|
+
fs.chmodSync(file, st.isDirectory() ? st.mode & 0o666 : st.mode & 0o7777);
|
|
59
|
+
}
|
|
60
|
+
const sha1 = buf => crypto.createHash('sha1').update(buf).digest('hex');
|
|
61
|
+
|
|
62
|
+
// Which rows of one id#version to keep and delete. rows are in PackageVersionKey order.
|
|
63
|
+
function plan(rows) {
|
|
64
|
+
const direct = rows.filter(r => !isSimplifier(r.GUID));
|
|
65
|
+
if (direct.length === 0) {
|
|
66
|
+
return { keep: [rows[0]], conflict: false };
|
|
67
|
+
}
|
|
68
|
+
if (new Set(direct.map(r => r.Hash)).size === 1) {
|
|
69
|
+
return { keep: [direct[0]], conflict: false };
|
|
70
|
+
}
|
|
71
|
+
return { keep: direct, conflict: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, log = console.log }) {
|
|
75
|
+
if (!fs.existsSync(dbPath)) {
|
|
76
|
+
throw new Error(`No database at ${dbPath}`); // DatabaseSync would create an empty one
|
|
77
|
+
}
|
|
78
|
+
const db = new DatabaseSync(dbPath);
|
|
79
|
+
db.exec('PRAGMA busy_timeout = 10000');
|
|
80
|
+
const report = { groups: 0, deleted: [], conflicts: [], indexCreated: false, mirror: { checked: 0, ok: 0, rewritten: [], badContent: [] } };
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// 1. duplicate rows
|
|
84
|
+
const groups = db.prepare('SELECT Id, Version FROM PackageVersions GROUP BY Id, Version HAVING COUNT(*) > 1').all();
|
|
85
|
+
const rowsOf = db.prepare(`SELECT PackageVersionKey, GUID, Hash, DownloadCount FROM PackageVersions
|
|
86
|
+
WHERE Id = ? AND Version = ? ORDER BY PackageVersionKey`);
|
|
87
|
+
const changes = [];
|
|
88
|
+
for (const g of groups) {
|
|
89
|
+
const rows = rowsOf.all(g.Id, g.Version);
|
|
90
|
+
const { keep, conflict } = plan(rows);
|
|
91
|
+
const del = rows.filter(r => !keep.includes(r));
|
|
92
|
+
if (conflict) {
|
|
93
|
+
report.conflicts.push({ id: g.Id, version: g.Version, kept: keep.map(r => r.GUID) });
|
|
94
|
+
}
|
|
95
|
+
if (del.length > 0) {
|
|
96
|
+
changes.push({ id: g.Id, version: g.Version, keep: keep[0], del });
|
|
97
|
+
for (const r of del) {
|
|
98
|
+
report.deleted.push({ id: g.Id, version: g.Version, guid: r.GUID, keptGuid: keep[0].GUID });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
report.groups = groups.length;
|
|
103
|
+
const toDelete = new Set(changes.flatMap(c => c.del.map(r => r.PackageVersionKey)));
|
|
104
|
+
const conflicted = new Set(report.conflicts.map(c => `${c.id}#${c.version}`));
|
|
105
|
+
|
|
106
|
+
if (apply && changes.length > 0) {
|
|
107
|
+
const delRelated = ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs']
|
|
108
|
+
.map(t => db.prepare(`DELETE FROM ${t} WHERE PackageVersionKey = ?`));
|
|
109
|
+
const delRow = db.prepare('DELETE FROM PackageVersions WHERE PackageVersionKey = ?');
|
|
110
|
+
const addCount = db.prepare('UPDATE PackageVersions SET DownloadCount = DownloadCount + ? WHERE PackageVersionKey = ?');
|
|
111
|
+
const latest = db.prepare(`SELECT PackageVersionKey, Canonical FROM PackageVersions
|
|
112
|
+
WHERE Id = ? AND Version != 'current' ORDER BY PubDate DESC, Version DESC LIMIT 1`);
|
|
113
|
+
const setCurrent = db.prepare('UPDATE Packages SET CurrentVersion = ?, Canonical = ? WHERE Id = ?');
|
|
114
|
+
db.exec('BEGIN IMMEDIATE');
|
|
115
|
+
try {
|
|
116
|
+
const ids = new Set();
|
|
117
|
+
for (const c of changes) {
|
|
118
|
+
for (const r of c.del) {
|
|
119
|
+
delRelated.forEach(s => s.run(r.PackageVersionKey));
|
|
120
|
+
delRow.run(r.PackageVersionKey);
|
|
121
|
+
addCount.run(r.DownloadCount || 0, c.keep.PackageVersionKey);
|
|
122
|
+
}
|
|
123
|
+
ids.add(c.id);
|
|
124
|
+
}
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
const l = latest.get(id);
|
|
127
|
+
if (l) {
|
|
128
|
+
setCurrent.run(l.PackageVersionKey, l.Canonical, id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
db.exec('COMMIT');
|
|
132
|
+
} catch (e) {
|
|
133
|
+
db.exec('ROLLBACK');
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
log(`${report.groups} id#versions stored more than once; ${report.deleted.length} rows ${apply ? 'deleted' : 'to delete'}; ${report.conflicts.length} conflicts left for manual cleanup`);
|
|
138
|
+
for (const c of report.conflicts) {
|
|
139
|
+
log(` conflict: ${c.id}#${c.version} has different direct copies: ${c.kept.join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. unique index
|
|
143
|
+
const remaining = db.prepare('SELECT COUNT(*) AS n FROM (SELECT 1 FROM PackageVersions GROUP BY Id, Version HAVING COUNT(*) > 1)').get().n;
|
|
144
|
+
if (apply && remaining === 0) {
|
|
145
|
+
db.exec(UNIQUE_INDEX);
|
|
146
|
+
report.indexCreated = true;
|
|
147
|
+
log('unique index on (Id, Version) created');
|
|
148
|
+
} else if (remaining > 0) {
|
|
149
|
+
log(`unique index on (Id, Version) not created: ${apply ? remaining : report.groups} id#versions still duplicated`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. mirror
|
|
153
|
+
if (mirrorPath) {
|
|
154
|
+
const content = db.prepare('SELECT Content FROM PackageVersions WHERE PackageVersionKey = ?');
|
|
155
|
+
for (const row of db.prepare('SELECT PackageVersionKey, Id, Version, Hash FROM PackageVersions ORDER BY Id, Version').iterate()) {
|
|
156
|
+
if (toDelete.has(row.PackageVersionKey)) {
|
|
157
|
+
continue; // a dry run, and -apply would delete this row
|
|
158
|
+
}
|
|
159
|
+
if (conflicted.has(`${row.Id}#${row.Version}`)) {
|
|
160
|
+
continue; // two rows share the file; left for manual cleanup
|
|
161
|
+
}
|
|
162
|
+
report.mirror.checked++;
|
|
163
|
+
const file = path.join(mirrorPath, mirrorName(row.Id, row.Version));
|
|
164
|
+
const actual = fs.existsSync(file) ? sha1(fs.readFileSync(file)) : null;
|
|
165
|
+
if (actual === row.Hash) {
|
|
166
|
+
report.mirror.ok++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const buf = Buffer.from(content.get(row.PackageVersionKey).Content);
|
|
170
|
+
if (sha1(buf) !== row.Hash) {
|
|
171
|
+
report.mirror.badContent.push(`${row.Id}#${row.Version}`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
report.mirror.rewritten.push({ id: row.Id, version: row.Version, was: actual || 'missing', hash: row.Hash });
|
|
175
|
+
if (apply) {
|
|
176
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
177
|
+
fs.writeFileSync(tmp, buf);
|
|
178
|
+
keepOwnership(tmp, fs.existsSync(file) ? file : mirrorPath);
|
|
179
|
+
fs.renameSync(tmp, file);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const missing = report.mirror.rewritten.filter(r => r.was === 'missing');
|
|
183
|
+
const different = report.mirror.rewritten.filter(r => r.was !== 'missing');
|
|
184
|
+
const will = apply ? 'was written' : 'will be written';
|
|
185
|
+
log(`mirror ${mirrorPath}: ${report.mirror.checked} database rows checked against their mirror file`);
|
|
186
|
+
log(` ${report.mirror.ok} files match the database`);
|
|
187
|
+
log(` ${missing.length} files are missing from the mirror: the package is in the database, so the file ${will} from it`);
|
|
188
|
+
if (missing.length > report.mirror.checked / 2) {
|
|
189
|
+
log(' (most of the mirror is missing: check that -mirror is the folder the server\'s mirrorPath setting names)');
|
|
190
|
+
}
|
|
191
|
+
for (const r of verbose ? missing : missing.slice(0, 5)) {
|
|
192
|
+
log(` ${r.id}#${r.version}`);
|
|
193
|
+
}
|
|
194
|
+
if (!verbose && missing.length > 5) {
|
|
195
|
+
log(` ... and ${missing.length - 5} more (-verbose lists them all)`);
|
|
196
|
+
}
|
|
197
|
+
log(` ${different.length} files differ from the database - these are the ones served with the wrong checksum; each ${will} from the database`);
|
|
198
|
+
for (const r of different) {
|
|
199
|
+
log(` ${r.id}#${r.version}: file sha1 ${r.was}, database ${r.hash}`);
|
|
200
|
+
}
|
|
201
|
+
if (report.mirror.badContent.length > 0) {
|
|
202
|
+
log(` ${report.mirror.badContent.length} database rows whose content doesn't match their own hash - not touched:`);
|
|
203
|
+
for (const b of report.mirror.badContent) {
|
|
204
|
+
log(` ${b}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
db.close();
|
|
210
|
+
}
|
|
211
|
+
return report;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function main(argv) {
|
|
215
|
+
const arg = name => {
|
|
216
|
+
const i = argv.indexOf(name);
|
|
217
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
218
|
+
};
|
|
219
|
+
const dbPath = arg('-db');
|
|
220
|
+
const mirrorPath = arg('-mirror');
|
|
221
|
+
if (!dbPath) {
|
|
222
|
+
console.error('usage: node packages/utilities/dedupe-versions.js -db <packages.db> [-mirror <mirror folder>] [-apply] [-verbose]');
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
const apply = argv.includes('-apply');
|
|
226
|
+
if (!apply) {
|
|
227
|
+
console.log('Dry run: nothing will be changed (use -apply)');
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
dedupe({ dbPath, mirrorPath, apply, verbose: argv.includes('-verbose') });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
console.error(e.message);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (require.main === module) {
|
|
239
|
+
process.exitCode = main(process.argv.slice(2));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { dedupe, plan, isSimplifier, fixPrefix };
|
package/registry/registry.js
CHANGED
|
@@ -582,9 +582,16 @@ class RegistryModule {
|
|
|
582
582
|
// what the server says about its release (CapabilityStatement.software.releaseDate) -
|
|
583
583
|
// the only source for software other than FHIRsmith
|
|
584
584
|
_reportedReleaseDay(row) {
|
|
585
|
-
|
|
586
|
-
const m = /^(\d{4}-\d{2}-\d{2})/.exec(
|
|
587
|
-
|
|
585
|
+
const value = row.softwareReleaseDate || '';
|
|
586
|
+
const m = /^(\d{4}-\d{2}-\d{2})/.exec(value);
|
|
587
|
+
if (!m) {
|
|
588
|
+
return '';
|
|
589
|
+
}
|
|
590
|
+
// a date is the day as given. A dateTime is an instant, and different servers running
|
|
591
|
+
// the same release report it in their own time zones (2026-09-05T00:37:46+10:00 and
|
|
592
|
+
// 2026-09-04T16:37:46+02:00), so the day is the UTC day, to be the same for all of them
|
|
593
|
+
const t = value.length > 10 ? new Date(value).getTime() : NaN;
|
|
594
|
+
return isNaN(t) ? m[1] : new Date(t).toISOString().substring(0, 10);
|
|
588
595
|
}
|
|
589
596
|
|
|
590
597
|
_renderReportedReleaseCells(row, now) {
|
|
@@ -377,6 +377,8 @@ HTA_LOINC_DESC = LOINC
|
|
|
377
377
|
HTA_LOINC_MESSAGE = This material contains content from <a href="http://loinc.org">LOINC</a>. LOINC is copyright © 1995-2020, Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the <a href="http://loinc.org/license">license</a>. LOINC® is a registered United States trademark of Regenstrief Institute, Inc.
|
|
378
378
|
HTA_SCT_DESC = SNOMED Clinical Terms® (SNOMED CT®)
|
|
379
379
|
HTA_SCT_MESSAGE = This material contains content that is copyright of SNOMED International. Implementers of these specifications must have the appropriate SNOMED CT Affiliate license - for more information contact <a href="https://www.snomed.org/get-snomed">https://www.snomed.org/get-snomed</a> or <a href="mailto:info@snomed.org">info@snomed.org</a>.
|
|
380
|
+
ICD11_PC_NO_AXES = ''{0}'' cannot be postcoordinated onto ''{1}'': that concept declares no postcoordination axes
|
|
381
|
+
ICD11_PC_VALUE_NOT_ON_AXIS = ''{0}'' is not a permitted value on any postcoordination axis of ''{1}''. That stem declares: {2}
|
|
380
382
|
IG_DEPENDENCY_CAN_VERSION_ALONE = The canonical URL specifies the version ''{0}'', while the dependency does not. If a version is specified, it must be in the explicit version property
|
|
381
383
|
IG_DEPENDENCY_CAN_VERSION_ERROR = The canonical URL specifies the version ''{0}'', while the dependency has version ''{1}''. If a version is specified, it must be the same as in the explicit version property
|
|
382
384
|
IG_DEPENDENCY_CAN_VERSION_NONE = The canonical URL cannot end in ''|''
|
|
@@ -1441,6 +1443,7 @@ TEXT_LINK_SELECTOR_INVALID = The textLink selector ''{0}'' is invalid: {1}
|
|
|
1441
1443
|
SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND = The element {0} is not valid
|
|
1442
1444
|
SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND_VER = The element {0} is not valid in version {1}
|
|
1443
1445
|
FILTER_NOT_UNDERSTOOD = The filter "{0} {1} {2}" is not understood or supported
|
|
1446
|
+
FILTER_VALUE_UNKNOWN_CODE = The filter value ''{0}'' is not a code in {1}
|
|
1444
1447
|
XHTML_CONTROL_NO_SOURCE = The xhtml node at ''{0}'' does not have a class attribute to indicate its source (boilerplate, generated, or original) and this is required by the profile {1}
|
|
1445
1448
|
XHTML_XHTML_MIXED_LANG = The xhtml has some language sections ({0}), and also has content that is not in a language section
|
|
1446
1449
|
XHTML_CONTROL_NO_LANGS = The xhtml has some language sections ({0}), but language sections are prohibited in this context by the profile {1}
|
package/tx/cs/cs-api.js
CHANGED
|
@@ -74,7 +74,12 @@ class CodeSystemProvider {
|
|
|
74
74
|
* the others report "LOINC".
|
|
75
75
|
* @returns {string} display name for the code system
|
|
76
76
|
*/
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* @param {Languages} [languages] - the request's working languages, for a provider whose name
|
|
79
|
+
* is translated; the default ignores them
|
|
80
|
+
*/
|
|
81
|
+
// eslint-disable-next-line no-unused-vars
|
|
82
|
+
name(languages) { return this.system() + (this.version() ? "|"+this.version() : "") }
|
|
78
83
|
|
|
79
84
|
/**
|
|
80
85
|
* @returns {string} uri for the code system
|
package/tx/cs/cs-icd11.js
CHANGED
|
@@ -113,8 +113,11 @@ class ICD11Services extends BaseCSServices {
|
|
|
113
113
|
/**
|
|
114
114
|
* The default is system|version, but $lookup's `name` is meant to be a display name for
|
|
115
115
|
* the code system. OMOP overrides it the same way; WHO returns the linearization title.
|
|
116
|
+
* It follows the request's languages like any other display, falling back to the primary
|
|
117
|
+
* title. $lookup passes its working languages (displayLanguage, else Accept-Language),
|
|
118
|
+
* since displayLanguage never reaches opContext.langs; other callers pass nothing.
|
|
116
119
|
*/
|
|
117
|
-
name() { return this.shared.title; }
|
|
120
|
+
name(languages) { return this.shared.titles?.[this.#displayLang(languages)] || this.shared.title; }
|
|
118
121
|
description() { return this.shared.title; }
|
|
119
122
|
totalCount() { return this.shared.count; }
|
|
120
123
|
defLang() { return 'en'; }
|
|
@@ -191,12 +194,13 @@ class ICD11Services extends BaseCSServices {
|
|
|
191
194
|
return row ? new ICD11Concept(row) : null;
|
|
192
195
|
}
|
|
193
196
|
|
|
194
|
-
/**
|
|
197
|
+
/** What the expression parser needs: the database, and the request's translations. */
|
|
195
198
|
#expressionContext() {
|
|
196
199
|
return {
|
|
197
200
|
get: (sql, params) => this.#get(sql, params),
|
|
198
201
|
all: (sql, params) => this.#all(sql, params),
|
|
199
202
|
resolve: (text) => this.#resolve(text),
|
|
203
|
+
translate: (msgId, params) => this.opContext.i18n.translate(msgId, this.opContext.langs, params),
|
|
200
204
|
};
|
|
201
205
|
}
|
|
202
206
|
|
|
@@ -309,8 +313,8 @@ class ICD11Services extends BaseCSServices {
|
|
|
309
313
|
* iterable of Language, in quality order; the database holds translations keyed on the
|
|
310
314
|
* primary subtag, so that is what is matched.
|
|
311
315
|
*/
|
|
312
|
-
#displayLang() {
|
|
313
|
-
const langs = this.opContext?.langs;
|
|
316
|
+
#displayLang(languages) {
|
|
317
|
+
const langs = languages || this.opContext?.langs;
|
|
314
318
|
if (langs) {
|
|
315
319
|
for (const l of langs) {
|
|
316
320
|
const base = l.language || String(l.code || '').split('-')[0];
|
|
@@ -615,8 +619,9 @@ class ICD11Services extends BaseCSServices {
|
|
|
615
619
|
if (!target) {
|
|
616
620
|
// a value set that names a code that is not there is broken, and saying so is more
|
|
617
621
|
// use than quietly expanding to nothing
|
|
618
|
-
throw new Issue('error', 'code-invalid', null,
|
|
619
|
-
|
|
622
|
+
throw new Issue('error', 'code-invalid', null, 'FILTER_VALUE_UNKNOWN_CODE',
|
|
623
|
+
this.opContext.i18n.translate('FILTER_VALUE_UNKNOWN_CODE', this.opContext.langs, [value, this.system()]),
|
|
624
|
+
'invalid-code', 422);
|
|
620
625
|
}
|
|
621
626
|
filterContext.filters.push(new ICD11FilterSet('isa', {
|
|
622
627
|
ancestorId: target.id, minDepth: op === 'is-a' ? 0 : 1, value,
|
|
@@ -749,12 +754,24 @@ class ICD11ServicesFactory extends CodeSystemFactoryProvider {
|
|
|
749
754
|
}
|
|
750
755
|
const count = await get('SELECT COUNT(*) n FROM concept WHERE system = ?', [sys.id]);
|
|
751
756
|
const langs = await get("SELECT value FROM meta WHERE key = 'languages'", []);
|
|
757
|
+
// per-language titles; a database built before system_title existed has none
|
|
758
|
+
const all = (sql, p) => new Promise((res, rej) =>
|
|
759
|
+
db.all(sql, p, (e, r) => e ? rej(e) : res(r || [])));
|
|
760
|
+
const titles = {};
|
|
761
|
+
const hasTitles = await get(
|
|
762
|
+
"SELECT 1 x FROM sqlite_master WHERE type = 'table' AND name = 'system_title'", []);
|
|
763
|
+
if (hasTitles) {
|
|
764
|
+
for (const r of await all('SELECT lang, title FROM system_title WHERE system = ?', [sys.id])) {
|
|
765
|
+
titles[r.lang] = r.title;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
752
768
|
this._shared = {
|
|
753
769
|
systemCode: sys.code,
|
|
754
770
|
systemId: sys.id,
|
|
755
771
|
url: sys.url,
|
|
756
772
|
version: sys.version,
|
|
757
773
|
title: sys.title,
|
|
774
|
+
titles,
|
|
758
775
|
isTree: !!sys.is_tree,
|
|
759
776
|
count: count.n,
|
|
760
777
|
languages: (langs?.value || 'en').split(','),
|
|
@@ -232,12 +232,11 @@ async function parseExpression(ctx, text) {
|
|
|
232
232
|
*/
|
|
233
233
|
function notOnAnyAxis(ctx, stem, token) {
|
|
234
234
|
const axes = stem.axes.map(a => a.axis_name).join(', ');
|
|
235
|
-
const
|
|
236
|
-
?
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return new Issue('error', 'code-invalid', null, null, msg, 'invalid-code', 400).handleAsOO(400);
|
|
235
|
+
const [msgId, params] = axes
|
|
236
|
+
? ['ICD11_PC_VALUE_NOT_ON_AXIS', [token.text, stem.stem.text, axes]]
|
|
237
|
+
: ['ICD11_PC_NO_AXES', [token.text, stem.stem.text]];
|
|
238
|
+
return new Issue('error', 'code-invalid', null, msgId, ctx.translate(msgId, params),
|
|
239
|
+
'invalid-code', 400).handleAsOO(400);
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, SCHEMA_NS };
|
|
@@ -42,6 +42,7 @@ text lives in `designation`. The untouched native JSON for each concept is kept
|
|
|
42
42
|
|---|---|
|
|
43
43
|
| `meta` | key/value: release, languages, endpoints, tool version, request count |
|
|
44
44
|
| `system` | one row per code system — `mms`, `icf`, `foundation` — with its canonical url, release and `is_tree` |
|
|
45
|
+
| `system_title` | the code system's title per language (`system.title` is the primary one); only titles WHO actually translated |
|
|
45
46
|
| `concept` | one row per entity |
|
|
46
47
|
| `concept_parent` | parent/child edges, `seq` preserving the API's order |
|
|
47
48
|
| `concept_closure` | transitive closure including the reflexive row, so is-a never needs recursion |
|
|
@@ -55,6 +55,15 @@ CREATE TABLE IF NOT EXISTS system (
|
|
|
55
55
|
uri_stem TEXT NOT NULL -- versioned uri prefix used by the native API
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
-- the code system's own title, per language crawled (system.title is the primary language).
|
|
59
|
+
-- Only a title WHO actually translated is recorded -- an English fallback is not a French title.
|
|
60
|
+
CREATE TABLE IF NOT EXISTS system_title (
|
|
61
|
+
system INTEGER NOT NULL REFERENCES system(id),
|
|
62
|
+
lang TEXT NOT NULL,
|
|
63
|
+
title TEXT NOT NULL,
|
|
64
|
+
PRIMARY KEY (system, lang)
|
|
65
|
+
);
|
|
66
|
+
|
|
58
67
|
-- one row per concept
|
|
59
68
|
CREATE TABLE IF NOT EXISTS concept (
|
|
60
69
|
id INTEGER PRIMARY KEY,
|
|
@@ -374,6 +383,8 @@ class Icd11Importer {
|
|
|
374
383
|
setMeta: db.prepare(`INSERT INTO meta(key,value) VALUES (?,?)
|
|
375
384
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
|
|
376
385
|
getMeta: db.prepare(`SELECT value FROM meta WHERE key=?`),
|
|
386
|
+
insSystemTitle: db.prepare(`INSERT INTO system_title(system,lang,title) VALUES (?,?,?)
|
|
387
|
+
ON CONFLICT(system,lang) DO UPDATE SET title=excluded.title`),
|
|
377
388
|
insSystem: db.prepare(`INSERT INTO system(code,url,version,title,is_tree,api_root,uri_stem)
|
|
378
389
|
VALUES (@code,@url,@version,@title,@is_tree,@api_root,@uri_stem)
|
|
379
390
|
ON CONFLICT(code) DO UPDATE SET
|
|
@@ -566,6 +577,8 @@ class Icd11Importer {
|
|
|
566
577
|
|
|
567
578
|
for (const lang of languages) {
|
|
568
579
|
const isPrimary = lang === primaryLang;
|
|
580
|
+
// before the resume check: a language whose concepts are all done still needs its title
|
|
581
|
+
await this.storeSystemTitle(systemId, def, lang, isPrimary ? root : null);
|
|
569
582
|
const done = new Set(this.stmt.doneLangs.all(systemId, lang).map(r => r.uri));
|
|
570
583
|
const todo = uris.filter(u => !done.has(unversion(u)));
|
|
571
584
|
if (!todo.length) { this.log(` [${lang}] already complete`); continue; }
|
|
@@ -609,6 +622,32 @@ class Icd11Importer {
|
|
|
609
622
|
return systemId;
|
|
610
623
|
}
|
|
611
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Record the linearization root's title in one language. `root` is the document already
|
|
627
|
+
* fetched for the primary language; other languages cost one extra call each. The root's
|
|
628
|
+
* title carries the language WHO actually answered in, so an untranslated title (WHO falls
|
|
629
|
+
* back to English) is skipped rather than stored under the requested language.
|
|
630
|
+
*/
|
|
631
|
+
async storeSystemTitle(systemId, def, lang, root) {
|
|
632
|
+
if (!root) {
|
|
633
|
+
const r = await this.getEntity(WHO + def.apiRoot, lang);
|
|
634
|
+
if (!r.ok) {
|
|
635
|
+
this.problem('title-failed', r.error, { system: def.code, lang, uri: def.apiRoot });
|
|
636
|
+
this.warn(` ! could not read the ${lang} title of ${def.apiRoot}: ${r.error}`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
root = r.body;
|
|
640
|
+
}
|
|
641
|
+
const title = textOf(root.title);
|
|
642
|
+
if (!title) return;
|
|
643
|
+
const got = langOf(root.title, lang);
|
|
644
|
+
if (got.split('-')[0] !== lang.split('-')[0]) {
|
|
645
|
+
this.log(` [${lang}] no translated title (WHO answered in ${got})`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
this.stmt.insSystemTitle.run(systemId, lang, title);
|
|
649
|
+
}
|
|
650
|
+
|
|
612
651
|
/**
|
|
613
652
|
* Turn the parent and scale-entity uris collected during the crawl into real ids.
|
|
614
653
|
* `rootUri` is the linearization root, which the top-level concepts name as their
|
package/tx/tx.fhir.org.yml
CHANGED
package/tx/workers/lookup.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// POST /CodeSystem/{id}/$lookup
|
|
8
8
|
//
|
|
9
9
|
|
|
10
|
-
const { TerminologyWorker } = require('./worker');
|
|
10
|
+
const { TerminologyWorker, Unknown_Code_in_VersionSCT, SCTVersion } = require('./worker');
|
|
11
11
|
const { FhirCodeSystemProvider } = require('../cs/cs-cs');
|
|
12
12
|
const { Designations} = require("../library/designations");
|
|
13
13
|
const {TxParameters} = require("../params");
|
|
@@ -251,13 +251,16 @@ class LookupWorker extends TerminologyWorker {
|
|
|
251
251
|
const locateResult = await csProvider.locate(code);
|
|
252
252
|
|
|
253
253
|
if (!locateResult || !locateResult.context) {
|
|
254
|
-
|
|
254
|
+
const system = csProvider.system();
|
|
255
|
+
const version = csProvider.version();
|
|
256
|
+
const msgId = Unknown_Code_in_VersionSCT(system, version);
|
|
257
|
+
let message = this.i18n.translate(msgId, params.HTTPLanguages, [code, system, version, SCTVersion(system, version)]);
|
|
255
258
|
if (locateResult?.message) {
|
|
256
259
|
message += ' ('+locateResult.message+')';
|
|
257
260
|
}
|
|
258
261
|
// invalid-code, not not-found: the code system was found, the code in it was not.
|
|
259
262
|
// not-found is for a code system or value set that could not be resolved at all.
|
|
260
|
-
throw new Issue('error', 'not-found', null,
|
|
263
|
+
throw new Issue('error', 'not-found', null, msgId, message, 'invalid-code', 404);
|
|
261
264
|
}
|
|
262
265
|
|
|
263
266
|
const ctxt = locateResult.context;
|
|
@@ -268,7 +271,7 @@ class LookupWorker extends TerminologyWorker {
|
|
|
268
271
|
// name (required)
|
|
269
272
|
responseParams.push({
|
|
270
273
|
name: 'name',
|
|
271
|
-
valueString: csProvider.name()
|
|
274
|
+
valueString: csProvider.name(params.workingLanguages())
|
|
272
275
|
});
|
|
273
276
|
responseParams.push({
|
|
274
277
|
name: 'code',
|