fhirsmith 0.16.1 → 0.18.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 +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- 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/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The syndication store: the content that has flowed through the tx library, as the
|
|
3
|
+
* Terminology Syndication Feed IG (https://build.fhir.org/ig/FHIR/fhir-syndication-ig/)
|
|
4
|
+
* will publish it.
|
|
5
|
+
*
|
|
6
|
+
* The store is a folder (terminology-cache/syndication) holding syndication.json - the
|
|
7
|
+
* metadata for every entry - and files/, copies of the naked resource files the library
|
|
8
|
+
* loaded. Packages and binaries are not copied: packages are permanently available from
|
|
9
|
+
* npm.fhir.org, and the binaries are write-once in the external store they were downloaded
|
|
10
|
+
* from, so their entries link there.
|
|
11
|
+
*
|
|
12
|
+
* Rules (see the syndication design doc in the FHIRsmith project):
|
|
13
|
+
* - content is inducted as the library loads it. Nothing is back-filled: anything the store
|
|
14
|
+
* has not seen before is new, and gets the time it was inducted
|
|
15
|
+
* - the store only grows. Entries are never changed or removed, and there are no retract
|
|
16
|
+
* entries. A later update to the same content (a changed file for the same url|version)
|
|
17
|
+
* is a new entry, so contentItemVersion is deliberately not unique (the IG says it must
|
|
18
|
+
* be; the spec authors agree that rule is wrong)
|
|
19
|
+
* - one store serves every endpoint. Only the core packages are FHIR version specific; their
|
|
20
|
+
* entries carry fhirVersion, and every other entry is in every endpoint's feed
|
|
21
|
+
*
|
|
22
|
+
* Induction is queued and done in the background, one item at a time, so that downloading
|
|
23
|
+
* a package to hash it never holds up the server starting. A failed induction is logged and
|
|
24
|
+
* not recorded, so it is tried again the next time the content is loaded.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const fsp = fs.promises;
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
const NCTS_SCHEME = 'http://ns.electronichealth.net.au/ncts/syndication/asf/scheme/1.0.0';
|
|
33
|
+
const FHIRSMITH_SCHEME = 'http://healthintersections.com.au/syndication/fhirsmith/1.0.0';
|
|
34
|
+
const PACKAGE_SERVER = 'https://npm.fhir.org/web/';
|
|
35
|
+
const STORE_FORMAT = 1;
|
|
36
|
+
// no fetch is allowed to hold induction (or server shutdown, which waits for it) for longer
|
|
37
|
+
const FETCH_TIMEOUT_MS = 120000;
|
|
38
|
+
|
|
39
|
+
/** library source type -> category term in the FHIRsmith scheme, for the binary sources */
|
|
40
|
+
const BINARY_TERMS = {
|
|
41
|
+
snomed: 'SNOMED_CACHE',
|
|
42
|
+
loinc: 'LOINC_DB',
|
|
43
|
+
rxnorm: 'RXNORM_DB',
|
|
44
|
+
ndc: 'NDC_DB',
|
|
45
|
+
unii: 'UNII_DB',
|
|
46
|
+
omop: 'OMOP_DB',
|
|
47
|
+
icd11: 'ICD11_DB',
|
|
48
|
+
cpt: 'CPT_DB'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const RESOURCE_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
|
|
52
|
+
|
|
53
|
+
const SNOMED_RIGHTS = 'Use of SNOMED CT is subject to use limitations: a licence from SNOMED International or a national release centre is required';
|
|
54
|
+
const OTHER_RIGHTS = 'Use of this content is subject to the applicable licence';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a package version is a build rather than a release - #current and its kin are not
|
|
58
|
+
* syndicated, since they change under the same version.
|
|
59
|
+
*/
|
|
60
|
+
function isBuildVersion(version) {
|
|
61
|
+
if (!version) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const v = String(version);
|
|
65
|
+
return /^(current|dev)/.test(v) || v.includes('cibuild');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SHA-256 of a file, streamed (the binaries run to gigabytes) */
|
|
69
|
+
function hashFile(filePath) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const hash = crypto.createHash('sha256');
|
|
72
|
+
const stream = fs.createReadStream(filePath);
|
|
73
|
+
stream.on('error', reject);
|
|
74
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
75
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hashBuffer(buffer) {
|
|
80
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The SHA-256 and length of a response body, streamed rather than held in memory (some
|
|
85
|
+
* packages are hundreds of megabytes)
|
|
86
|
+
*/
|
|
87
|
+
async function hashResponse(response) {
|
|
88
|
+
const hash = crypto.createHash('sha256');
|
|
89
|
+
let length = 0;
|
|
90
|
+
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
|
|
91
|
+
for await (const chunk of response.body) {
|
|
92
|
+
hash.update(chunk);
|
|
93
|
+
length += chunk.length;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
97
|
+
hash.update(buffer);
|
|
98
|
+
length = buffer.length;
|
|
99
|
+
}
|
|
100
|
+
return { sha256: hash.digest('hex'), length };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class SyndicationStore {
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} folder - the store folder (created if need be)
|
|
106
|
+
* @param {Object} log - logger
|
|
107
|
+
* @param {Object} [options]
|
|
108
|
+
* @param {Function} [options.fetch] - fetch implementation (tests)
|
|
109
|
+
* @param {Function} [options.now] - clock (tests): returns a Date
|
|
110
|
+
*/
|
|
111
|
+
constructor(folder, log, options = {}) {
|
|
112
|
+
this.folder = folder;
|
|
113
|
+
this.filesFolder = path.join(folder, 'files');
|
|
114
|
+
this.jsonPath = path.join(folder, 'syndication.json');
|
|
115
|
+
this.log = log;
|
|
116
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
117
|
+
this.now = options.now || (() => new Date());
|
|
118
|
+
this.doc = null;
|
|
119
|
+
this.queue = Promise.resolve();
|
|
120
|
+
this.pending = 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the store, or start it if there isn't one: the store is defined from now on.
|
|
125
|
+
*/
|
|
126
|
+
open() {
|
|
127
|
+
fs.mkdirSync(this.filesFolder, { recursive: true });
|
|
128
|
+
if (fs.existsSync(this.jsonPath)) {
|
|
129
|
+
const doc = JSON.parse(fs.readFileSync(this.jsonPath, 'utf8'));
|
|
130
|
+
if (!doc || !Array.isArray(doc.entries) || !doc.id) {
|
|
131
|
+
throw new Error(`The syndication store ${this.jsonPath} is not valid`);
|
|
132
|
+
}
|
|
133
|
+
this.doc = doc;
|
|
134
|
+
} else {
|
|
135
|
+
this.doc = {
|
|
136
|
+
format: STORE_FORMAT,
|
|
137
|
+
id: crypto.randomUUID(),
|
|
138
|
+
created: this.now().toISOString(),
|
|
139
|
+
entries: []
|
|
140
|
+
};
|
|
141
|
+
this.#save();
|
|
142
|
+
this.log.info(`Syndication store started in ${this.folder}`);
|
|
143
|
+
}
|
|
144
|
+
this.#index();
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#index() {
|
|
149
|
+
// key -> the most recent entry with that key
|
|
150
|
+
this.byKey = new Map();
|
|
151
|
+
// name -> path, for each file copied into the store (the only files it serves)
|
|
152
|
+
this.files = new Map();
|
|
153
|
+
for (const entry of this.doc.entries) {
|
|
154
|
+
this.#indexEntry(entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#indexEntry(entry) {
|
|
159
|
+
this.byKey.set(entry.key, entry);
|
|
160
|
+
if (entry.link && entry.link.file) {
|
|
161
|
+
this.files.set(entry.link.file, path.join(this.filesFolder, path.basename(entry.link.file)));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#save() {
|
|
166
|
+
const tmp = this.jsonPath + '.tmp';
|
|
167
|
+
fs.writeFileSync(tmp, JSON.stringify(this.doc, null, 2));
|
|
168
|
+
fs.renameSync(tmp, this.jsonPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** {string} the store's own id, from which each endpoint's feed id is made */
|
|
172
|
+
get id() {
|
|
173
|
+
return this.doc.id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get created() {
|
|
177
|
+
return this.doc.created;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** {Object[]} every entry, in the order they were inducted */
|
|
181
|
+
get entries() {
|
|
182
|
+
return this.doc.entries;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The entries in the feed for an endpoint at the given FHIR version: everything that isn't
|
|
187
|
+
* version specific, plus that version's own.
|
|
188
|
+
* @param {string} fhirVersion - major.minor
|
|
189
|
+
*/
|
|
190
|
+
entriesFor(fhirVersion) {
|
|
191
|
+
return this.doc.entries.filter(e => !e.fhirVersion || e.fhirVersion === fhirVersion);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolves when everything queued so far has been inducted (or has failed) */
|
|
195
|
+
whenIdle() {
|
|
196
|
+
return this.queue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#enqueue(label, task) {
|
|
200
|
+
this.pending++;
|
|
201
|
+
this.queue = this.queue.then(async () => {
|
|
202
|
+
try {
|
|
203
|
+
await task();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.log.warn(`Syndication: unable to induct ${label}: ${error.message}`);
|
|
206
|
+
} finally {
|
|
207
|
+
this.pending--;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return this.queue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#add(entry) {
|
|
214
|
+
const now = this.now().toISOString();
|
|
215
|
+
const full = {
|
|
216
|
+
id: 'urn:uuid:' + crypto.randomUUID(),
|
|
217
|
+
inducted: now,
|
|
218
|
+
...entry
|
|
219
|
+
};
|
|
220
|
+
this.doc.entries.push(full);
|
|
221
|
+
this.#indexEntry(full);
|
|
222
|
+
this.#save();
|
|
223
|
+
this.log.info(`Syndication: inducted ${full.title} (${full.version})`);
|
|
224
|
+
return full;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** download a url, keeping only its SHA-256 and length */
|
|
228
|
+
async #downloadHash(url) {
|
|
229
|
+
const response = await this.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new Error(`HTTP ${response.status} fetching ${url}`);
|
|
232
|
+
}
|
|
233
|
+
return hashResponse(response);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A package the library loaded (npm: or url: source, or an endpoint's core package).
|
|
238
|
+
*
|
|
239
|
+
* @param {Object} pkg
|
|
240
|
+
* @param {string} pkg.id - package id
|
|
241
|
+
* @param {string} pkg.version - package version
|
|
242
|
+
* @param {string} [pkg.canonical] - canonical from package.json
|
|
243
|
+
* @param {string} [pkg.title] - title from package.json
|
|
244
|
+
* @param {string} [pkg.description] - description from package.json
|
|
245
|
+
* @param {string} [pkg.url] - where it came from, for a url: source; otherwise npm.fhir.org
|
|
246
|
+
* @param {string} [pkg.fhirVersion] - major.minor, for a version specific (core) package only
|
|
247
|
+
* @param {string} [pkg.requestedVersion] - the version the library asked for
|
|
248
|
+
*/
|
|
249
|
+
inductPackage(pkg) {
|
|
250
|
+
const label = `package ${pkg.id}#${pkg.version}`;
|
|
251
|
+
if (isBuildVersion(pkg.version) || isBuildVersion(pkg.requestedVersion)) {
|
|
252
|
+
return this.queue;
|
|
253
|
+
}
|
|
254
|
+
const key = `package:${pkg.id}#${pkg.version}`;
|
|
255
|
+
if (this.byKey.has(key)) {
|
|
256
|
+
return this.queue;
|
|
257
|
+
}
|
|
258
|
+
return this.#enqueue(label, async () => {
|
|
259
|
+
if (this.byKey.has(key)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!pkg.canonical) {
|
|
263
|
+
throw new Error('the package has no canonical in package.json');
|
|
264
|
+
}
|
|
265
|
+
const href = pkg.url || `${PACKAGE_SERVER}${encodeURIComponent(pkg.id)}-${encodeURIComponent(pkg.version)}.tgz`;
|
|
266
|
+
// packages never change, so the copy on the server is the one that is hashed
|
|
267
|
+
const { sha256, length } = await this.#downloadHash(href);
|
|
268
|
+
const entry = {
|
|
269
|
+
key,
|
|
270
|
+
kind: 'package',
|
|
271
|
+
category: { scheme: NCTS_SCHEME, term: 'FHIR_Package', label: 'FHIR Package' },
|
|
272
|
+
identifier: pkg.canonical,
|
|
273
|
+
version: `${pkg.canonical}|${pkg.version}`,
|
|
274
|
+
title: pkg.title ? `${pkg.title} (${pkg.id}#${pkg.version})` : `${pkg.id}#${pkg.version}`,
|
|
275
|
+
link: { href, type: 'application/gzip', length, sha256 }
|
|
276
|
+
};
|
|
277
|
+
if (pkg.description) {
|
|
278
|
+
entry.summary = pkg.description;
|
|
279
|
+
}
|
|
280
|
+
if (pkg.fhirVersion) {
|
|
281
|
+
entry.fhirVersion = pkg.fhirVersion;
|
|
282
|
+
}
|
|
283
|
+
this.#add(entry);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A resource the library loaded from a file: or folder: source. Resources without a version
|
|
289
|
+
* are not syndicated. The file is copied into the store, and a later file with different
|
|
290
|
+
* content for the same url|version is a new entry.
|
|
291
|
+
*
|
|
292
|
+
* @param {Object} res
|
|
293
|
+
* @param {string} res.filePath - the file it was loaded from
|
|
294
|
+
* @param {string} res.resourceType
|
|
295
|
+
* @param {string} res.url
|
|
296
|
+
* @param {string} res.version
|
|
297
|
+
* @param {string} [res.title]
|
|
298
|
+
* @param {string} [res.description]
|
|
299
|
+
*/
|
|
300
|
+
inductResource(res) {
|
|
301
|
+
if (!res.url || !res.version || !RESOURCE_TYPES.includes(res.resourceType)) {
|
|
302
|
+
return this.queue;
|
|
303
|
+
}
|
|
304
|
+
const label = `${res.resourceType} ${res.url}|${res.version}`;
|
|
305
|
+
const key = `resource:${res.url}|${res.version}`;
|
|
306
|
+
return this.#enqueue(label, async () => {
|
|
307
|
+
const bytes = await fsp.readFile(res.filePath);
|
|
308
|
+
const sha256 = hashBuffer(bytes);
|
|
309
|
+
const last = this.byKey.get(key);
|
|
310
|
+
if (last && last.link.sha256 === sha256) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const fileName = crypto.randomUUID() + '.json';
|
|
314
|
+
await fsp.writeFile(path.join(this.filesFolder, fileName), bytes);
|
|
315
|
+
const entry = {
|
|
316
|
+
key,
|
|
317
|
+
kind: 'resource',
|
|
318
|
+
category: { scheme: NCTS_SCHEME, term: 'FHIR_' + res.resourceType, label: 'FHIR ' + res.resourceType },
|
|
319
|
+
identifier: res.url,
|
|
320
|
+
version: `${res.url}|${res.version}`,
|
|
321
|
+
title: res.title || res.url,
|
|
322
|
+
// relative: the feed makes it absolute under the endpoint the feed is read from
|
|
323
|
+
link: { file: fileName, type: 'application/fhir+json', length: bytes.length, sha256 }
|
|
324
|
+
};
|
|
325
|
+
if (res.description) {
|
|
326
|
+
entry.summary = res.description;
|
|
327
|
+
}
|
|
328
|
+
this.#add(entry);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A binary the library loaded (SNOMED cache, LOINC database etc). Only binaries that were
|
|
334
|
+
* published to the external store (baseUrl) are syndicated - a file that is only local is a
|
|
335
|
+
* development build. The binary is not copied: the entry links to the external store.
|
|
336
|
+
* Binary file names change whenever their content does, so the file name is the key.
|
|
337
|
+
*
|
|
338
|
+
* @param {Object} bin
|
|
339
|
+
* @param {string} bin.sourceType - library source type (snomed, loinc ...)
|
|
340
|
+
* @param {string} bin.filePath - the local copy
|
|
341
|
+
* @param {string} bin.baseUrl - the external store the library downloads binaries from
|
|
342
|
+
* @param {string} bin.system - code system uri
|
|
343
|
+
* @param {string} bin.version - code system version (for SNOMED, the version uri)
|
|
344
|
+
* @param {string} [bin.name] - code system name
|
|
345
|
+
*/
|
|
346
|
+
inductBinary(bin) {
|
|
347
|
+
const term = BINARY_TERMS[bin.sourceType];
|
|
348
|
+
const fileName = path.basename(bin.filePath);
|
|
349
|
+
if (!term || !bin.baseUrl) {
|
|
350
|
+
return this.queue;
|
|
351
|
+
}
|
|
352
|
+
// the full CPT database is licensed content, and is never distributed; the public
|
|
353
|
+
// fragment is
|
|
354
|
+
if (bin.sourceType === 'cpt' && !fileName.toLowerCase().includes('fragment')) {
|
|
355
|
+
return this.queue;
|
|
356
|
+
}
|
|
357
|
+
const key = `binary:${fileName}`;
|
|
358
|
+
if (this.byKey.has(key)) {
|
|
359
|
+
return this.queue;
|
|
360
|
+
}
|
|
361
|
+
return this.#enqueue(`${bin.sourceType} file ${fileName}`, async () => {
|
|
362
|
+
if (this.byKey.has(key)) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const href = bin.baseUrl.replace(/\/+$/, '') + '/' + fileName;
|
|
366
|
+
const stat = await fsp.stat(bin.filePath);
|
|
367
|
+
const head = await this.fetch(href, { method: 'HEAD', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
368
|
+
const length = Number(head.headers.get('content-length'));
|
|
369
|
+
if (!head.ok || length !== stat.size) {
|
|
370
|
+
this.log.info(`Syndication: not syndicating ${fileName}: it is not published at ${href} (a local build)`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const sha256 = await hashFile(bin.filePath);
|
|
374
|
+
let identifier = bin.system;
|
|
375
|
+
let version = bin.version ? `${bin.system}|${bin.version}` : bin.system;
|
|
376
|
+
const sct = /^(.*)\/version\/[^/]+$/.exec(bin.version || '');
|
|
377
|
+
if (bin.sourceType === 'snomed' && sct) {
|
|
378
|
+
// SNOMED editions are identified by their module uri, and versioned by the version uri
|
|
379
|
+
identifier = sct[1];
|
|
380
|
+
version = bin.version;
|
|
381
|
+
}
|
|
382
|
+
const name = bin.name || bin.system;
|
|
383
|
+
this.#add({
|
|
384
|
+
key,
|
|
385
|
+
kind: 'binary',
|
|
386
|
+
category: { scheme: FHIRSMITH_SCHEME, term, label: term },
|
|
387
|
+
identifier,
|
|
388
|
+
version,
|
|
389
|
+
title: `${name}${bin.version && !name.includes(bin.version) ? ' ' + bin.version : ''} (${fileName})`,
|
|
390
|
+
summary: `FHIRsmith ${term} file ${fileName}`,
|
|
391
|
+
rights: bin.sourceType === 'snomed' ? SNOMED_RIGHTS : OTHER_RIGHTS,
|
|
392
|
+
link: { href, type: 'application/octet-stream', length: stat.size, sha256 }
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The local path of a file copied into the store, or null if there isn't one by that name.
|
|
399
|
+
* The name is only ever looked up among the files the store's entries record - the path
|
|
400
|
+
* returned is the store's own, never one made from the name asked for.
|
|
401
|
+
* @param {string} name
|
|
402
|
+
*/
|
|
403
|
+
filePath(name) {
|
|
404
|
+
if (typeof name !== 'string' || !/^[0-9a-f-]{36}\.json$/.test(name)) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const p = this.files.get(name);
|
|
408
|
+
return p && fs.existsSync(p) ? p : null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
SyndicationStore, isBuildVersion, hashFile,
|
|
414
|
+
NCTS_SCHEME, FHIRSMITH_SCHEME, BINARY_TERMS
|
|
415
|
+
};
|
package/tx/tx-html.js
CHANGED
|
@@ -9,6 +9,7 @@ const htmlServer = require('../library/html-server');
|
|
|
9
9
|
const Logger = require('../library/logger');
|
|
10
10
|
const packageJson = require("../package.json");
|
|
11
11
|
const escape = require('escape-html');
|
|
12
|
+
const { checkResourceNarratives } = require('../library/narrative-check');
|
|
12
13
|
const {ExpandWorker} = require("./workers/expand");
|
|
13
14
|
const ValueSet = require("./library/valueset");
|
|
14
15
|
const {CodeSystemXML} = require("./xml/codesystem-xml");
|
|
@@ -154,6 +155,14 @@ function publishesLibrarySource() {
|
|
|
154
155
|
return publishLibrarySource;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// Whether this server has a syndication feed (modules.tx.syndication.enabled): gates the nav
|
|
159
|
+
// item and the feed autodiscovery link, as publishLibrarySource does for the library
|
|
160
|
+
let syndicationFeed = false;
|
|
161
|
+
|
|
162
|
+
function setSyndicationFeed(enabled) {
|
|
163
|
+
syndicationFeed = enabled === true;
|
|
164
|
+
}
|
|
165
|
+
|
|
157
166
|
/**
|
|
158
167
|
* Highlight a YAML document for display.
|
|
159
168
|
*
|
|
@@ -268,6 +277,11 @@ class TxHtmlRenderer {
|
|
|
268
277
|
options.apiLink = '<a href="' + escape(endpoint.path) + '/openapi" style="color: gold">API</a> | ';
|
|
269
278
|
options.apiHead = '<link rel="service-desc" type="application/json" href="' + escape(endpoint.path) + '/openapi.json"/>';
|
|
270
279
|
}
|
|
280
|
+
if (syndicationFeed) {
|
|
281
|
+
options.libraryLink += '<a href="' + escape(endpoint.path) + '/syndication" style="color: gold">Syndication</a> | ';
|
|
282
|
+
options.apiHead = (options.apiHead || '') + '<link rel="alternate" type="application/atom+xml" title="Syndication Feed" href="'
|
|
283
|
+
+ escape(endpoint.path) + '/syndication?_format=atom"/>';
|
|
284
|
+
}
|
|
271
285
|
|
|
272
286
|
return htmlServer.renderPage('tx', title, content, options);
|
|
273
287
|
}
|
|
@@ -1017,7 +1031,7 @@ class TxHtmlRenderer {
|
|
|
1017
1031
|
} else if (_fmt == "html/xml") {
|
|
1018
1032
|
html += await this.renderResourceXml(json);
|
|
1019
1033
|
} else if (_fmt == "html/narrative") {
|
|
1020
|
-
html += await this.
|
|
1034
|
+
html += await this.renderOriginalNarrative(json);
|
|
1021
1035
|
} else if (_fmt == "html/ops") {
|
|
1022
1036
|
html += await this.liquid.renderFile('codesystem-operations', {
|
|
1023
1037
|
opsId: this.generateResourceId(),
|
|
@@ -1040,9 +1054,10 @@ class TxHtmlRenderer {
|
|
|
1040
1054
|
|
|
1041
1055
|
tab(b, name, rtype, type, id) {
|
|
1042
1056
|
if (b) {
|
|
1043
|
-
return `<li class="active"><a href="#">${name}</a></li>`;
|
|
1057
|
+
return `<li class="active"><a href="#">${escape(name)}</a></li>`;
|
|
1044
1058
|
} else {
|
|
1045
|
-
|
|
1059
|
+
const href = `${this.path}/${encodeURIComponent(rtype)}/${encodeURIComponent(id)}?_format=${encodeURIComponent(type)}`;
|
|
1060
|
+
return `<li><a href="${escape(href)}">${escape(name)}</a></li>`;
|
|
1046
1061
|
}
|
|
1047
1062
|
}
|
|
1048
1063
|
/**
|
|
@@ -1078,7 +1093,7 @@ class TxHtmlRenderer {
|
|
|
1078
1093
|
} else if (_fmt == "html/xml") {
|
|
1079
1094
|
html += await this.renderResourceXml(json);
|
|
1080
1095
|
} else if (_fmt == "html/narrative") {
|
|
1081
|
-
html += await this.
|
|
1096
|
+
html += await this.renderOriginalNarrative(json);
|
|
1082
1097
|
} else if (_fmt == "html/ops") {
|
|
1083
1098
|
html += await this.liquid.renderFile('valueset-operations', {
|
|
1084
1099
|
opsId: this.generateResourceId(),
|
|
@@ -1218,7 +1233,7 @@ class TxHtmlRenderer {
|
|
|
1218
1233
|
} else if (_fmt == "html/json") {
|
|
1219
1234
|
html += await this.renderResourceJson(json);
|
|
1220
1235
|
} else if (_fmt == "html/narrative") {
|
|
1221
|
-
html += await this.
|
|
1236
|
+
html += await this.renderOriginalNarrative(json);
|
|
1222
1237
|
} else if (_fmt == "html/ops") {
|
|
1223
1238
|
const sourceSet = new Set();
|
|
1224
1239
|
const codeSet = new Set();
|
|
@@ -1725,7 +1740,7 @@ class TxHtmlRenderer {
|
|
|
1725
1740
|
html += '<div class="card-header">Bundle</div>';
|
|
1726
1741
|
html += '<div class="card-body">';
|
|
1727
1742
|
html += `<p><strong>Type:</strong> ${escape(json.type)}</p>`;
|
|
1728
|
-
html += `<p><strong>Total:</strong> ${json.total
|
|
1743
|
+
html += `<p><strong>Total:</strong> ${escape(String(json.total ?? 'N/A'))}</p>`;
|
|
1729
1744
|
html += '</div>';
|
|
1730
1745
|
html += '</div>';
|
|
1731
1746
|
|
|
@@ -1826,6 +1841,18 @@ class TxHtmlRenderer {
|
|
|
1826
1841
|
return html;
|
|
1827
1842
|
}
|
|
1828
1843
|
|
|
1844
|
+
/**
|
|
1845
|
+
* The resource's own narrative, as it is - unless it carries active content, which is
|
|
1846
|
+
* refused at load time but may reach here from a source that isn't loaded that way
|
|
1847
|
+
*/
|
|
1848
|
+
async renderOriginalNarrative(json) {
|
|
1849
|
+
const found = checkResourceNarratives({ resourceType: json.resourceType, id: json.id, text: json.text });
|
|
1850
|
+
if (found) {
|
|
1851
|
+
return `<div class="alert alert-danger">This narrative is not shown: ${escape(found)}</div>`;
|
|
1852
|
+
}
|
|
1853
|
+
return this.renderResourceWithNarrative(json, json.text?.div);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1829
1856
|
async renderResourceJson(json) {
|
|
1830
1857
|
let html = "";
|
|
1831
1858
|
html += `<div class="json-content" style="margin-top: 10px;">`;
|
|
@@ -1864,7 +1891,8 @@ class TxHtmlRenderer {
|
|
|
1864
1891
|
return await this.liquid.renderFile('operations-form', {
|
|
1865
1892
|
vcSystemId,
|
|
1866
1893
|
inferSystemId,
|
|
1867
|
-
|
|
1894
|
+
// inside a <script>: no '<', so no value can close it
|
|
1895
|
+
valueSetsJson: JSON.stringify(json.valueSets || []).replace(/</g, '\\u003c')
|
|
1868
1896
|
});
|
|
1869
1897
|
}
|
|
1870
1898
|
|
|
@@ -1887,5 +1915,6 @@ class TxHtmlRenderer {
|
|
|
1887
1915
|
|
|
1888
1916
|
module.exports = {
|
|
1889
1917
|
TxHtmlRenderer, loadTemplate, acceptsHtml,
|
|
1890
|
-
setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
|
|
1918
|
+
setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage,
|
|
1919
|
+
setSyndicationFeed
|
|
1891
1920
|
};
|
package/tx/tx.fhir.org.yml
CHANGED
|
@@ -26,15 +26,13 @@ sources:
|
|
|
26
26
|
- unii:unii_20240622.db
|
|
27
27
|
- snomed:sct_intl_20240201.cache
|
|
28
28
|
- snomed!:sct_intl_20250201.cache
|
|
29
|
-
- snomed:sct_ch_20230607.cache
|
|
30
|
-
- snomed:sct_dk_20260331.cache
|
|
31
29
|
- snomed:sct_ips_20241216.cache
|
|
32
30
|
- snomed:sct_nl_20240930.cache
|
|
33
31
|
- snomed:sct_uk_20230412.cache
|
|
34
32
|
- snomed:sct_us_20230301.cache
|
|
35
33
|
- snomed:sct_us_20240301.cache
|
|
36
34
|
- snomed:sct_us_20250901.cache
|
|
37
|
-
- snomed:
|
|
35
|
+
- snomed:sct_test_20250909c.cache
|
|
38
36
|
- cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
|
|
39
37
|
- omop:omop_v20250227.db
|
|
40
38
|
- npm:hl7.terminology=tho
|
|
@@ -44,6 +42,7 @@ sources:
|
|
|
44
42
|
- npm:hl7.fhir.us.core=us
|
|
45
43
|
- npm:us.cdc.phinvads=cdc
|
|
46
44
|
- npm:hl7.fhir.uv.sdc=sdc
|
|
45
|
+
- npm:hl7.fhir.uv.ips=ips
|
|
47
46
|
- internal:vsac=vsac
|
|
48
47
|
- icd11:icd11-2026-01-b.db
|
|
49
48
|
|