fhirsmith 0.14.2 → 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 +56 -1
- package/config-template.json +10 -0
- package/library/changelog.js +57 -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/crawler.js +17 -0
- package/registry/fhirsmith-releases.js +3 -6
- package/registry/model.js +3 -0
- package/registry/registry.js +28 -3
- package/translations/Messages.properties +3 -0
- package/tx/README.md +20 -4
- package/tx/cm/cm-api.js +25 -10
- package/tx/cm/cm-database.js +0 -4
- package/tx/cm/cm-package.js +53 -14
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-icd11.js +23 -6
- package/tx/cs/cs-provider-api.js +22 -7
- package/tx/cs/cs-provider-list.js +10 -9
- 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/library/resource-ids.js +107 -0
- package/tx/library.js +96 -24
- package/tx/ocl/cm-ocl.cjs +23 -15
- package/tx/ocl/cs-ocl.cjs +9 -10
- package/tx/ocl/vs-ocl.cjs +17 -17
- package/tx/provider.js +80 -13
- package/tx/tx.fhir.org.yml +17 -9
- package/tx/tx.js +7 -1
- package/tx/vs/vs-api.js +25 -10
- package/tx/vs/vs-database.js +0 -4
- package/tx/vs/vs-package.js +48 -31
- package/tx/vs/vs-vsac.js +20 -5
- package/tx/workers/lookup.js +7 -4
- package/tx/workers/metadata.js +2 -1
- package/tx/workers/read.js +3 -18
|
@@ -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/crawler.js
CHANGED
|
@@ -339,6 +339,20 @@ class RegistryCrawler {
|
|
|
339
339
|
return version;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* CapabilityStatement.software.releaseDate, if it means anything. A date within a few
|
|
344
|
+
* minutes of now is the server's clock, not a release date: FHIRsmith up to 0.14.2 filled
|
|
345
|
+
* it in with the time of the request, and other software may do the same.
|
|
346
|
+
*/
|
|
347
|
+
reportedReleaseDate(software, now = Date.now()) {
|
|
348
|
+
const value = software && software.releaseDate;
|
|
349
|
+
const t = value ? new Date(value).getTime() : NaN;
|
|
350
|
+
if (isNaN(t) || Math.abs(now - t) < 10 * 60 * 1000) {
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
return String(value);
|
|
354
|
+
}
|
|
355
|
+
|
|
342
356
|
/**
|
|
343
357
|
* A server that can't be reached this time is still running whatever it was running
|
|
344
358
|
* last time we saw it - keep that, so the software page doesn't lose track of it
|
|
@@ -353,6 +367,7 @@ class RegistryCrawler {
|
|
|
353
367
|
if (prev.address === version.address && prev.software) {
|
|
354
368
|
version.software = prev.software;
|
|
355
369
|
version.softwareVersion = prev.softwareVersion || '';
|
|
370
|
+
version.softwareReleaseDate = prev.softwareReleaseDate || '';
|
|
356
371
|
return;
|
|
357
372
|
}
|
|
358
373
|
}
|
|
@@ -371,6 +386,7 @@ class RegistryCrawler {
|
|
|
371
386
|
version.version = capability.fhirVersion || '3.0.2';
|
|
372
387
|
version.software = capability.software ? capability.software.name : "unknown";
|
|
373
388
|
version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
|
|
389
|
+
version.softwareReleaseDate = this.reportedReleaseDate(capability.software);
|
|
374
390
|
|
|
375
391
|
// Get terminology capabilities (R3 uses Parameters resource)
|
|
376
392
|
try {
|
|
@@ -416,6 +432,7 @@ class RegistryCrawler {
|
|
|
416
432
|
version.version = capability.fhirVersion || defVersion;
|
|
417
433
|
version.software = capability.software ? capability.software.name : "unknown";
|
|
418
434
|
version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
|
|
435
|
+
version.softwareReleaseDate = this.reportedReleaseDate(capability.software);
|
|
419
436
|
|
|
420
437
|
let set = new Set();
|
|
421
438
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// succeeds (or if it can't be reached), the dated headings in this server's own
|
|
7
7
|
// CHANGELOG.md are used instead - those only go up to this server's own version.
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const { readReleaseDates } = require('../library/changelog');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const axios = require('axios');
|
|
12
12
|
|
|
@@ -72,12 +72,9 @@ class FhirsmithReleases {
|
|
|
72
72
|
*/
|
|
73
73
|
loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) {
|
|
74
74
|
try {
|
|
75
|
-
const text = fs.readFileSync(changelogPath, 'utf8');
|
|
76
75
|
const list = [];
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
while ((m = re.exec(text)) !== null) {
|
|
80
|
-
list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') });
|
|
76
|
+
for (const [version, date] of readReleaseDates(changelogPath)) {
|
|
77
|
+
list.push({ version, date: new Date(date + 'T00:00:00Z') });
|
|
81
78
|
}
|
|
82
79
|
this.setReleases(list, 'CHANGELOG.md');
|
|
83
80
|
} catch (error) {
|
package/registry/model.js
CHANGED
|
@@ -12,6 +12,7 @@ class ServerVersionInformation {
|
|
|
12
12
|
this.lastTat = '';
|
|
13
13
|
this.software = ''; // what software is running
|
|
14
14
|
this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one
|
|
15
|
+
this.softwareReleaseDate = ''; // CapabilityStatement.software.releaseDate, if it is a real date
|
|
15
16
|
this.codeSystems = []; // Array of strings (sorted, unique)
|
|
16
17
|
this.valueSets = []; // Array of strings (sorted, unique)
|
|
17
18
|
}
|
|
@@ -60,6 +61,7 @@ class ServerVersionInformation {
|
|
|
60
61
|
terminologies: this.codeSystems,
|
|
61
62
|
software: this.software,
|
|
62
63
|
'software-version': this.softwareVersion,
|
|
64
|
+
'software-release-date': this.softwareReleaseDate,
|
|
63
65
|
valuesets: this.valueSets
|
|
64
66
|
};
|
|
65
67
|
}
|
|
@@ -74,6 +76,7 @@ class ServerVersionInformation {
|
|
|
74
76
|
instance.lastTat = json.lastTat || '';
|
|
75
77
|
instance.software = json.software;
|
|
76
78
|
instance.softwareVersion = json['software-version'] || '';
|
|
79
|
+
instance.softwareReleaseDate = json['software-release-date'] || '';
|
|
77
80
|
instance.codeSystems = json.terminologies || [];
|
|
78
81
|
instance.valueSets = json.valuesets || [];
|
|
79
82
|
return instance;
|
package/registry/registry.js
CHANGED
|
@@ -512,6 +512,7 @@ class RegistryModule {
|
|
|
512
512
|
fhirVersion: version.version || '',
|
|
513
513
|
software: version.software && version.software !== 'unknown' ? version.software : '',
|
|
514
514
|
softwareVersion: version.softwareVersion || '',
|
|
515
|
+
softwareReleaseDate: version.softwareReleaseDate || '',
|
|
515
516
|
error: version.error,
|
|
516
517
|
lastSuccess: version.lastSuccess
|
|
517
518
|
});
|
|
@@ -556,7 +557,7 @@ class RegistryModule {
|
|
|
556
557
|
|
|
557
558
|
_renderReleaseCells(row, now) {
|
|
558
559
|
if (!isFhirsmith(row.software) || !this.releases) {
|
|
559
|
-
return
|
|
560
|
+
return this._renderReportedReleaseCells(row, now);
|
|
560
561
|
}
|
|
561
562
|
const d = this.releases.describe(row.softwareVersion, now);
|
|
562
563
|
const behind = d.behind ? ` (${d.behind} release${d.behind === 1 ? '' : 's'} behind)` : '';
|
|
@@ -571,11 +572,35 @@ class RegistryModule {
|
|
|
571
572
|
`<td><span class="${cls}">${describeAge(d.ageDays)}${behind}</span></td>`;
|
|
572
573
|
}
|
|
573
574
|
case 'dev':
|
|
574
|
-
return
|
|
575
|
+
return `<td>${this._reportedReleaseDay(row)}</td><td>development build` +
|
|
575
576
|
(d.release ? ` after v${escape(d.release.version)}` : '') + behind + '</td>';
|
|
576
577
|
default:
|
|
577
|
-
return
|
|
578
|
+
return this._renderReportedReleaseCells(row, now);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// what the server says about its release (CapabilityStatement.software.releaseDate) -
|
|
583
|
+
// the only source for software other than FHIRsmith
|
|
584
|
+
_reportedReleaseDay(row) {
|
|
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);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
_renderReportedReleaseCells(row, now) {
|
|
598
|
+
const day = this._reportedReleaseDay(row);
|
|
599
|
+
if (!day) {
|
|
600
|
+
return '<td></td><td></td>';
|
|
578
601
|
}
|
|
602
|
+
const days = Math.max(0, Math.floor((now - new Date(row.softwareReleaseDate).getTime()) / (24 * 60 * 60 * 1000)));
|
|
603
|
+
return `<td>${day}</td><td>${describeAge(days)}</td>`;
|
|
579
604
|
}
|
|
580
605
|
|
|
581
606
|
/**
|
|
@@ -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/README.md
CHANGED
|
@@ -237,12 +237,28 @@ shouldn't be - too big. Note that that files in the list below are considered to
|
|
|
237
237
|
Each source entry follows the format:
|
|
238
238
|
|
|
239
239
|
```
|
|
240
|
-
type[!]:details
|
|
240
|
+
type[!]:details[=prefix]
|
|
241
241
|
```
|
|
242
242
|
|
|
243
243
|
- **type**: The source type (see Source Types below)
|
|
244
244
|
- **!** (optional): Append to type to mark this source as the default for its code system. When multiple versions of the same code system are loaded, the default is used when no version is specified.
|
|
245
245
|
- **details**: Type-specific configuration (filename, package name, or internal provider name)
|
|
246
|
+
- **prefix** (optional): The id prefix for the resources the source provides - see [Resource Ids](#resource-ids). Everything after the last `=` in the entry is the prefix, so a `url` source whose url contains a `=` must give one.
|
|
247
|
+
|
|
248
|
+
### Resource Ids
|
|
249
|
+
|
|
250
|
+
Every CodeSystem, ValueSet and ConceptMap is served with an id in the id space of the source it came from: the source's prefix, `-`, and the id the resource has in the source. With
|
|
251
|
+
|
|
252
|
+
```yaml
|
|
253
|
+
- npm:hl7.terminology=tho
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
THO's v3-ActCode code system is `CodeSystem/tho-v3-ActCode`. No resource is served on the id it has in its source - different sources can use the same ids (hl7.terminology and the FHIR core packages both have a CodeSystem `v3-MaritalStatus`), so an unprefixed id can't say which one is meant, and it finds nothing.
|
|
257
|
+
|
|
258
|
+
- A prefix may contain letters, digits and `.` (no `-`), up to 32 characters, and each prefix may only be given once
|
|
259
|
+
- If a source isn't given a prefix, it gets a number. Numbers depend on the order of the sources (and on which sources provide which kinds of resource), so they change when the YAML changes. Give every source that provides resources a prefix if its ids should stay the same - i.e. on any server where people link to resources by id
|
|
260
|
+
- `core` is the FHIR core package each endpoint loads for its FHIR version (`CodeSystem/core-administrative-gender`), and `x` is the code systems the server implements natively (`CodeSystem/x-...`); neither can be given as a prefix
|
|
261
|
+
- Sources that only provide code systems natively (`internal`, `snomed`, `loinc` etc.) don't serve resources by id, so a prefix does nothing for them
|
|
246
262
|
|
|
247
263
|
### Source Types
|
|
248
264
|
|
|
@@ -464,9 +480,9 @@ sources:
|
|
|
464
480
|
- snomed:sct_us_20250901.cache
|
|
465
481
|
|
|
466
482
|
# FHIR packages
|
|
467
|
-
- npm:hl7.terminology
|
|
468
|
-
- npm:hl7.fhir.us.core
|
|
469
|
-
- npm:us.nlm.vsac
|
|
483
|
+
- npm:hl7.terminology=tho
|
|
484
|
+
- npm:hl7.fhir.us.core=us
|
|
485
|
+
- npm:us.nlm.vsac=vsac
|
|
470
486
|
```
|
|
471
487
|
|
|
472
488
|
Also see tx.fhir.org.yml for the production configuration for tx.fhir.org
|
package/tx/cm/cm-api.js
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
|
+
const { assignIdSpace } = require('../library/resource-ids');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Abstract base class for Concept Map providers
|
|
3
5
|
* Defines the interface that all Concept Map providers must implement
|
|
4
6
|
*/
|
|
5
7
|
class AbstractConceptMapProvider {
|
|
6
8
|
/**
|
|
7
|
-
* {
|
|
9
|
+
* {string} The prefix of this provider's id space (set by assignIds) - given for its source in the library
|
|
10
|
+
* YAML, autonumbered, or 'core' for an endpoint's core package. Every concept map this
|
|
11
|
+
* provider provides has an id that starts with spaceId + '-' (see tx/library/resource-ids.js)
|
|
8
12
|
*/
|
|
9
13
|
spaceId;
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
|
-
*
|
|
13
|
-
* in
|
|
16
|
+
* Put this provider in an id space: set spaceId, and prefix the ids of the concept maps it
|
|
17
|
+
* provides with spaceId + '-'. A provider is put in one id space, once - asking for a
|
|
18
|
+
* different one later is an error (see assignIdSpace)
|
|
14
19
|
*
|
|
15
|
-
* @param {
|
|
20
|
+
* @param {string} spaceId
|
|
16
21
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
assignIds(spaceId) {
|
|
23
|
+
if (assignIdSpace(this, spaceId)) {
|
|
24
|
+
this.prefixIds();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Prefix the ids of the concept maps this provider provides with spaceId + '-', once spaceId
|
|
30
|
+
* is set (by assignIds). Concept maps the provider loads later must be given ids
|
|
31
|
+
* in the same space.
|
|
32
|
+
*/
|
|
33
|
+
prefixIds() {
|
|
34
|
+
throw new Error('prefixIds must be implemented by AbstractConceptMapProvider subclass');
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -32,11 +47,11 @@ class AbstractConceptMapProvider {
|
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
/**
|
|
35
|
-
* Fetches a specific Concept Map by id.
|
|
36
|
-
*
|
|
50
|
+
* Fetches a specific Concept Map by id. The id is in this provider's space (spaceId + '-' +
|
|
51
|
+
* the concept map's own id); an id that isn't - including an unprefixed one - finds nothing
|
|
37
52
|
*
|
|
38
53
|
* @param {string} id - The id of the Concept Map
|
|
39
|
-
* @returns {Promise<ConceptMap>} The requested Concept Map
|
|
54
|
+
* @returns {Promise<ConceptMap>} The requested Concept Map, or null
|
|
40
55
|
* @throws {Error} Must be implemented by subclasses
|
|
41
56
|
*/
|
|
42
57
|
// eslint-disable-next-line no-unused-vars
|
package/tx/cm/cm-database.js
CHANGED
package/tx/cm/cm-package.js
CHANGED
|
@@ -5,6 +5,7 @@ const { ConceptMapDatabase } = require('./cm-database');
|
|
|
5
5
|
const { VersionUtilities } = require('../../library/version-utilities');
|
|
6
6
|
const {validateParameter} = require("../../library/utilities");
|
|
7
7
|
const {ConceptMap} = require("../library/conceptmap");
|
|
8
|
+
const { applyIdPrefix, stripIdPrefix } = require('../library/resource-ids');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Package-based ConceptMap provider using shared database layer
|
|
@@ -34,6 +35,7 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
34
35
|
if (this.initialized) {
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
38
|
+
await this.packageLoader.initialize();
|
|
37
39
|
|
|
38
40
|
const dbExists = await this.database.exists();
|
|
39
41
|
|
|
@@ -43,6 +45,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
this.conceptMapMap = await this.database.loadAllConceptMaps();
|
|
48
|
+
// nothing, unless the spaceId was assigned before the provider was initialized
|
|
49
|
+
this.#applyIdPrefix();
|
|
46
50
|
this.initialized = true;
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -128,7 +132,9 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
128
132
|
this._validateSearchParams(searchParams);
|
|
129
133
|
|
|
130
134
|
if (this.USE_DATABASE_SEARCH) {
|
|
131
|
-
|
|
135
|
+
const results = await this.database.search(this.spaceId, searchParams, elements);
|
|
136
|
+
return this.#excludedIds.size === 0 ? results
|
|
137
|
+
: results.filter(r => !this.#excludedIds.has(stripIdPrefix(r.id, this.spaceId)));
|
|
132
138
|
} else {
|
|
133
139
|
const matches = [];
|
|
134
140
|
const seen = new Set(); // Track by URL to avoid duplicates from versioned keys
|
|
@@ -194,9 +200,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
194
200
|
|
|
195
201
|
if (isMatch) {
|
|
196
202
|
seen.add(vsUrl);
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
matches.push(result);
|
|
203
|
+
// the id is already prefixed (see assignIds)
|
|
204
|
+
matches.push(json);
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
207
|
|
|
@@ -298,19 +303,53 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
async fetchConceptMapById(id) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
306
|
+
// the map is keyed by the concept map's own id (and its url); the concept map itself
|
|
307
|
+
// carries the prefixed id, which is how a url key that happens to match is excluded
|
|
308
|
+
const cm = this.conceptMapMap.get(stripIdPrefix(id, this.spaceId));
|
|
309
|
+
return cm && cm.id === id ? cm : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
sourcePackage() {
|
|
313
|
+
return this.packageLoader.id();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// the concept maps left out by excludeUrls, by their own (unprefixed) id
|
|
317
|
+
#excludedIds = new Set();
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Leave out the concept maps with any of these urls: they can't be fetched, they aren't
|
|
321
|
+
* found by search, and they aren't used for translation. The FHIR core package's concept
|
|
322
|
+
* maps are left out where hl7.terminology provides the same url (see
|
|
323
|
+
* Provider.applyTHOPrecedence). Must be called once the provider is initialized - the
|
|
324
|
+
* database is shared, so it isn't changed.
|
|
325
|
+
*
|
|
326
|
+
* @param {Set<string>} urls
|
|
327
|
+
*/
|
|
328
|
+
excludeUrls(urls) {
|
|
329
|
+
const excluded = new Set();
|
|
330
|
+
for (const cm of this.conceptMapMap.values()) {
|
|
331
|
+
if (cm && urls.has(cm.url)) {
|
|
332
|
+
excluded.add(cm);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const [key, cm] of [...this.conceptMapMap]) {
|
|
336
|
+
if (excluded.has(cm)) {
|
|
337
|
+
this.conceptMapMap.delete(key);
|
|
338
|
+
}
|
|
308
339
|
}
|
|
340
|
+
for (const cm of excluded) {
|
|
341
|
+
this.#excludedIds.add(stripIdPrefix(cm.id, this.spaceId) ?? cm.id);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
prefixIds() {
|
|
346
|
+
this.#applyIdPrefix();
|
|
309
347
|
}
|
|
310
348
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
349
|
+
#applyIdPrefix() {
|
|
350
|
+
for (const cm of new Set(this.conceptMapMap.values())) {
|
|
351
|
+
applyIdPrefix(cm, this.spaceId);
|
|
352
|
+
}
|
|
314
353
|
}
|
|
315
354
|
|
|
316
355
|
async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem) {
|
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
|