fhirsmith 0.9.6 → 0.9.7
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 +20 -0
- package/library/folder-content-loader.js +91 -0
- package/npmprojector/npmprojector.js +2 -6
- package/package.json +1 -1
- package/publisher/publisher.js +101 -9
- package/registry/registry.js +6 -6
- package/server.js +6 -2
- package/translations/Messages.properties +1 -1
- package/tx/cs/cs-cs.js +8 -0
- package/tx/cs/cs-loinc.js +1 -0
- package/tx/cs/cs-provider-list.js +2 -1
- package/tx/cs/cs-snomed.js +142 -59
- package/tx/data/snomed-testing.cache +0 -0
- package/tx/library/canonical-resource.js +4 -2
- package/tx/library/designations.js +27 -20
- package/tx/library/renderer.js +303 -22
- package/tx/library/ucum-types.js +4 -1
- package/tx/library.js +65 -21
- package/tx/operation-context.js +13 -23
- package/tx/params.js +36 -8
- package/tx/provider.js +6 -3
- package/tx/tx-html.js +7 -0
- package/tx/tx.js +12 -13
- package/tx/vs/vs-vsac.js +157 -9
- package/tx/workers/expand.js +100 -96
- package/tx/workers/lookup.js +6 -0
- package/tx/workers/read.js +1 -1
- package/tx/workers/translate.js +20 -29
- package/tx/workers/validate.js +18 -10
- package/tx/workers/worker.js +1 -1
- package/tx/xversion/xv-bundle.js +1 -2
- package/tx/xversion/xv-codesystem.js +5 -2
- package/tx/xversion/xv-parameters.js +4 -4
- package/tx/xversion/xv-resource.js +2 -2
- package/tx/xversion/xv-terminologyCapabilities.js +11 -6
- package/tx/xversion/xv-valueset.js +7 -7
- package/publisher/task-draft.js +0 -463
package/tx/library/renderer.js
CHANGED
|
@@ -3,6 +3,27 @@ const {Extensions} = require("./extensions");
|
|
|
3
3
|
const {div} = require("../../library/html");
|
|
4
4
|
const {getValuePrimitive} = require("../../library/utilities");
|
|
5
5
|
const {getValueName} = require("../../library/utilities");
|
|
6
|
+
const {InvalidError} = require("./errors");
|
|
7
|
+
|
|
8
|
+
// Valid FHIR value sets used when validating values before rendering, so that
|
|
9
|
+
// illegal input produces an error that names the offending value rather than
|
|
10
|
+
// being silently rendered or causing a generic crash downstream.
|
|
11
|
+
const VALID_FILTER_OPS = new Set([
|
|
12
|
+
'=', 'is-a', 'descendent-of', 'is-not-a', 'regex', 'in', 'not-in',
|
|
13
|
+
'generalizes', 'child-of', 'descendent-leaf', 'exists'
|
|
14
|
+
]);
|
|
15
|
+
const VALID_CONCEPTMAP_RELATIONSHIPS = new Set([
|
|
16
|
+
'related-to', 'equivalent', 'source-is-narrower-than-target',
|
|
17
|
+
'source-is-broader-than-target', 'not-related-to'
|
|
18
|
+
]);
|
|
19
|
+
const VALID_CONCEPTMAP_EQUIVALENCES = new Set([
|
|
20
|
+
'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower',
|
|
21
|
+
'specializes', 'inexact', 'unmatched', 'disjoint'
|
|
22
|
+
]);
|
|
23
|
+
const VALID_CODESYSTEM_CONTENT = new Set([
|
|
24
|
+
'not-present', 'example', 'fragment', 'complete', 'supplement'
|
|
25
|
+
]);
|
|
26
|
+
const VALID_PUBLICATION_STATUS = new Set(['draft', 'active', 'retired', 'unknown']);
|
|
6
27
|
|
|
7
28
|
/**
|
|
8
29
|
* @typedef {Object} TerminologyLinkResolver
|
|
@@ -239,10 +260,12 @@ class Renderer {
|
|
|
239
260
|
}
|
|
240
261
|
|
|
241
262
|
renderMetadataLastUpdated(res, tbl) {
|
|
242
|
-
if (res.meta?.
|
|
263
|
+
if (res.meta?.lastUpdated) {
|
|
264
|
+
this._requireValidDate(res.meta.lastUpdated, 'meta.lastUpdated');
|
|
243
265
|
let tr = tbl.tr();
|
|
244
|
-
|
|
245
|
-
|
|
266
|
+
// RES_REND_UPDATED is "Last updated: {0}" — supply the formatted date as
|
|
267
|
+
// the {0} parameter so the placeholder is substituted.
|
|
268
|
+
tr.td().b().tx(this.translate('RES_REND_UPDATED', [this.displayDate(res.meta.lastUpdated)]));
|
|
246
269
|
}
|
|
247
270
|
}
|
|
248
271
|
|
|
@@ -318,7 +341,7 @@ class Renderer {
|
|
|
318
341
|
}
|
|
319
342
|
|
|
320
343
|
renderLinkComma(x, uri) {
|
|
321
|
-
let {desc, url} = this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null;
|
|
344
|
+
let {desc, url} = (this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null) || {};
|
|
322
345
|
if (url) {
|
|
323
346
|
x.commaItem(desc, url);
|
|
324
347
|
} else {
|
|
@@ -328,10 +351,17 @@ class Renderer {
|
|
|
328
351
|
|
|
329
352
|
|
|
330
353
|
async renderCoding(x, coding) {
|
|
354
|
+
if (coding === null || coding === undefined || typeof coding !== 'object') {
|
|
355
|
+
this._invalid('Coding', coding, 'invalid Coding');
|
|
356
|
+
}
|
|
357
|
+
if (coding.code !== undefined && coding.code !== null &&
|
|
358
|
+
(typeof coding.code !== 'string' || coding.code.trim() === '')) {
|
|
359
|
+
this._invalid('Coding.code', coding.code, 'invalid code');
|
|
360
|
+
}
|
|
331
361
|
let {
|
|
332
362
|
desc,
|
|
333
363
|
url
|
|
334
|
-
} = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, coding.system, coding.version, coding.code) : null;
|
|
364
|
+
} = (this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, coding.system, coding.version, coding.code) : null) || {};
|
|
335
365
|
if (url) {
|
|
336
366
|
x.ah(url).tx(desc);
|
|
337
367
|
} else {
|
|
@@ -347,10 +377,254 @@ class Renderer {
|
|
|
347
377
|
return this.opContext.i18n.formatPhrasePlural(msgId, this.opContext.langs, num,[]);
|
|
348
378
|
}
|
|
349
379
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Determine the BCP-47 locale to use for formatting, derived from the user's
|
|
382
|
+
* requested languages (parsed from the Accept-Language header). The region
|
|
383
|
+
* subtag (e.g. US vs GB) is what selects day/month ordering and month names.
|
|
384
|
+
* Falls back to 'en-US' when no usable language is available.
|
|
385
|
+
* @returns {string} a BCP-47 locale tag accepted by Intl
|
|
386
|
+
* @private
|
|
387
|
+
*/
|
|
388
|
+
_formatLocale() {
|
|
389
|
+
const langs = this.opContext && this.opContext.langs;
|
|
390
|
+
if (langs) {
|
|
391
|
+
for (const lang of langs) {
|
|
392
|
+
if (lang.language && lang.language !== '*') {
|
|
393
|
+
let tag = lang.language;
|
|
394
|
+
if (lang.script) tag += '-' + lang.script;
|
|
395
|
+
if (lang.region) tag += '-' + lang.region;
|
|
396
|
+
try {
|
|
397
|
+
if (Intl.DateTimeFormat.supportedLocalesOf(tag).length > 0) {
|
|
398
|
+
return tag;
|
|
399
|
+
}
|
|
400
|
+
// Locale is structurally valid but not supported by the runtime;
|
|
401
|
+
// Intl will still resolve it to a sensible fallback, so use it.
|
|
402
|
+
return tag;
|
|
403
|
+
} catch (_) {
|
|
404
|
+
// Structurally invalid tag — skip and try the next language.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return 'en-US';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Validation helpers ──────────────────────────────────────────────────────
|
|
413
|
+
// These exist so that illegal input is reported with an error that names the
|
|
414
|
+
// offending field and value, rather than being silently rendered or causing a
|
|
415
|
+
// generic "Cannot read properties of null" style crash further down.
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Render a value for inclusion in an error message, distinguishing null,
|
|
419
|
+
* undefined, objects, and primitives, and truncating long values.
|
|
420
|
+
* @private
|
|
421
|
+
*/
|
|
422
|
+
_showValue(value) {
|
|
423
|
+
if (value === undefined) return 'undefined';
|
|
424
|
+
if (value === null) return 'null';
|
|
425
|
+
if (typeof value === 'object') {
|
|
426
|
+
let s;
|
|
427
|
+
try { s = JSON.stringify(value); } catch (_) { s = Object.prototype.toString.call(value); }
|
|
428
|
+
return s.length > 80 ? s.slice(0, 77) + '...' : s;
|
|
429
|
+
}
|
|
430
|
+
const s = String(value);
|
|
431
|
+
return `'${s.length > 80 ? s.slice(0, 77) + '...' : s}'`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Throw an InvalidError that identifies the offending value and its location.
|
|
436
|
+
* @param {string} path - dotted path to the offending element (e.g. "ConceptMap.group[0].target[0].relationship")
|
|
437
|
+
* @param {*} value - the offending value
|
|
438
|
+
* @param {string} what - short description of what is wrong (e.g. "invalid ConceptMap relationship")
|
|
439
|
+
* @private
|
|
440
|
+
*/
|
|
441
|
+
_invalid(path, value, what) {
|
|
442
|
+
throw new InvalidError(`${what} at ${path}: ${this._showValue(value)}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Validate, unwrap, and type-check a resource before rendering. Accepts either
|
|
447
|
+
* a raw resource object or a wrapper exposing `.json`. Throws an InvalidError
|
|
448
|
+
* naming the problem when the input is missing, not an object, or of the wrong
|
|
449
|
+
* resourceType.
|
|
450
|
+
* @param {*} res - the resource (or wrapper) to render
|
|
451
|
+
* @param {string} expectedType - the FHIR resourceType this method renders
|
|
452
|
+
* @param {string} method - the calling method name, for the error message
|
|
453
|
+
* @returns {object} the unwrapped resource object
|
|
454
|
+
* @private
|
|
455
|
+
*/
|
|
456
|
+
_resolveResource(res, expectedType, method) {
|
|
457
|
+
if (res === null || res === undefined) {
|
|
458
|
+
throw new InvalidError(`${method}: no resource supplied (got ${res === null ? 'null' : 'undefined'})`);
|
|
459
|
+
}
|
|
460
|
+
if (typeof res !== 'object' || Array.isArray(res)) {
|
|
461
|
+
throw new InvalidError(`${method}: expected a ${expectedType} resource object but got ${Array.isArray(res) ? 'an array' : typeof res}`);
|
|
462
|
+
}
|
|
463
|
+
const r = (res.json !== undefined && res.json !== null) ? res.json : res;
|
|
464
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
465
|
+
throw new InvalidError(`${method}: expected a ${expectedType} resource object but got ${this._showValue(r)}`);
|
|
353
466
|
}
|
|
467
|
+
if (r.resourceType !== undefined && r.resourceType !== expectedType) {
|
|
468
|
+
throw new InvalidError(`${method}: expected resourceType '${expectedType}' but found ${this._showValue(r.resourceType)}`);
|
|
469
|
+
}
|
|
470
|
+
return r;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* True when `value` is a syntactically valid FHIR date/dateTime/instant.
|
|
475
|
+
* Used both to validate (via _requireValidDate) and to drive display.
|
|
476
|
+
* @private
|
|
477
|
+
*/
|
|
478
|
+
_isValidFhirDate(value) {
|
|
479
|
+
if (typeof value !== 'string') return false;
|
|
480
|
+
if (/^\d{4}$/.test(value)) return true;
|
|
481
|
+
|
|
482
|
+
let m = /^(\d{4})-(\d{2})$/.exec(value);
|
|
483
|
+
if (m) {
|
|
484
|
+
const y = Number(m[1]), mo = Number(m[2]);
|
|
485
|
+
const d = new Date(Date.UTC(y, mo - 1, 1));
|
|
486
|
+
return !isNaN(d.getTime()) && d.getUTCFullYear() === y && d.getUTCMonth() === mo - 1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
490
|
+
if (m) {
|
|
491
|
+
const y = Number(m[1]), mo = Number(m[2]), da = Number(m[3]);
|
|
492
|
+
const d = new Date(Date.UTC(y, mo - 1, da));
|
|
493
|
+
return !isNaN(d.getTime()) && d.getUTCFullYear() === y &&
|
|
494
|
+
d.getUTCMonth() === mo - 1 && d.getUTCDate() === da;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (value.includes('T')) {
|
|
498
|
+
const dt = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
499
|
+
if (!dt) return false;
|
|
500
|
+
if (isNaN(new Date(value).getTime())) return false;
|
|
501
|
+
const mo = Number(dt[2]), da = Number(dt[3]), hh = Number(dt[4]), mi = Number(dt[5]), ss = Number(dt[6]);
|
|
502
|
+
return mo >= 1 && mo <= 12 && da >= 1 && da <= 31 && hh <= 23 && mi <= 59 && ss <= 60;
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Require a value (when present) to be a valid FHIR date; otherwise throw an
|
|
509
|
+
* error naming the field and value. Empty/absent values are allowed and
|
|
510
|
+
* returned unchanged.
|
|
511
|
+
* @private
|
|
512
|
+
*/
|
|
513
|
+
_requireValidDate(value, path) {
|
|
514
|
+
if (value === null || value === undefined || value === '') return value;
|
|
515
|
+
if (!this._isValidFhirDate(value)) {
|
|
516
|
+
this._invalid(path, value, 'invalid date');
|
|
517
|
+
}
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Require a value to be a non-empty string code; otherwise throw an error
|
|
523
|
+
* naming the field and value.
|
|
524
|
+
* @private
|
|
525
|
+
*/
|
|
526
|
+
_requireValidCode(value, path) {
|
|
527
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
528
|
+
this._invalid(path, value, 'invalid code');
|
|
529
|
+
}
|
|
530
|
+
return value;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Require a value (when present) to be a member of an allowed set; otherwise
|
|
535
|
+
* throw an error naming the field and value.
|
|
536
|
+
* @private
|
|
537
|
+
*/
|
|
538
|
+
_requireAllowed(value, allowed, path, what) {
|
|
539
|
+
if (value === null || value === undefined) return value;
|
|
540
|
+
if (!allowed.has(value)) {
|
|
541
|
+
this._invalid(path, value, what);
|
|
542
|
+
}
|
|
543
|
+
return value;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Format a FHIR date / dateTime / instant value into a human-readable string
|
|
548
|
+
* using the user's locale. The output precision follows the input precision:
|
|
549
|
+
* a year stays a year, a year-month becomes "Month Year", a full date becomes
|
|
550
|
+
* a localised date, and a dateTime/instant becomes a localised date and time.
|
|
551
|
+
*
|
|
552
|
+
* Unparseable values are returned unchanged so the renderer never throws on
|
|
553
|
+
* unexpected input.
|
|
554
|
+
*
|
|
555
|
+
* @param {string} value - a FHIR date, dateTime, or instant (e.g. "2024",
|
|
556
|
+
* "2024-03", "2024-03-15", "2024-03-15T10:30:00Z")
|
|
557
|
+
* @returns {string} the localised representation, or '' for empty input
|
|
558
|
+
*/
|
|
559
|
+
displayDate(value) {
|
|
560
|
+
if (value === null || value === undefined || value === '') {
|
|
561
|
+
return '';
|
|
562
|
+
}
|
|
563
|
+
if (typeof value !== 'string') {
|
|
564
|
+
value = String(value);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const locale = this._formatLocale();
|
|
568
|
+
|
|
569
|
+
// Year only — nothing to localise.
|
|
570
|
+
if (/^\d{4}$/.test(value)) {
|
|
571
|
+
return value;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Year-month → "Month Year".
|
|
575
|
+
let m = /^(\d{4})-(\d{2})$/.exec(value);
|
|
576
|
+
if (m) {
|
|
577
|
+
const year = Number(m[1]), month = Number(m[2]);
|
|
578
|
+
const d = new Date(Date.UTC(year, month - 1, 1));
|
|
579
|
+
// Reject values that Date silently rolled over (e.g. month 13).
|
|
580
|
+
if (isNaN(d.getTime()) || d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1) {
|
|
581
|
+
return value;
|
|
582
|
+
}
|
|
583
|
+
return new Intl.DateTimeFormat(locale, {
|
|
584
|
+
year: 'numeric', month: 'long', timeZone: 'UTC'
|
|
585
|
+
}).format(d);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Full date → localised date.
|
|
589
|
+
m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
590
|
+
if (m) {
|
|
591
|
+
const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
|
|
592
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
593
|
+
// Reject values that Date silently rolled over (e.g. 2024-02-30).
|
|
594
|
+
if (isNaN(d.getTime()) || d.getUTCFullYear() !== year ||
|
|
595
|
+
d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
|
|
596
|
+
return value;
|
|
597
|
+
}
|
|
598
|
+
return new Intl.DateTimeFormat(locale, {
|
|
599
|
+
year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC'
|
|
600
|
+
}).format(d);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// dateTime / instant → localised date and time.
|
|
604
|
+
if (value.includes('T')) {
|
|
605
|
+
const d = new Date(value);
|
|
606
|
+
if (isNaN(d.getTime())) return value;
|
|
607
|
+
const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(value);
|
|
608
|
+
const opts = {
|
|
609
|
+
year: 'numeric', month: 'long', day: 'numeric',
|
|
610
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
611
|
+
};
|
|
612
|
+
if (hasTimezone) {
|
|
613
|
+
// We don't know the user's timezone, so render the absolute instant in
|
|
614
|
+
// UTC and label it, rather than silently using the server's zone.
|
|
615
|
+
opts.timeZone = 'UTC';
|
|
616
|
+
opts.timeZoneName = 'short';
|
|
617
|
+
}
|
|
618
|
+
return new Intl.DateTimeFormat(locale, opts).format(d);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Unrecognised format — return unchanged.
|
|
622
|
+
return value;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async renderValueSet(vs) {
|
|
626
|
+
vs = this._resolveResource(vs, 'ValueSet', 'renderValueSet');
|
|
627
|
+
this._requireAllowed(vs.status, VALID_PUBLICATION_STATUS, 'ValueSet.status', 'invalid status');
|
|
354
628
|
|
|
355
629
|
let div_ = div();
|
|
356
630
|
div_.h2().tx("Properties");
|
|
@@ -369,9 +643,9 @@ class Renderer {
|
|
|
369
643
|
}
|
|
370
644
|
|
|
371
645
|
async renderCodeSystem(cs, sourcePackage) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
646
|
+
cs = this._resolveResource(cs, 'CodeSystem', 'renderCodeSystem');
|
|
647
|
+
this._requireAllowed(cs.status, VALID_PUBLICATION_STATUS, 'CodeSystem.status', 'invalid status');
|
|
648
|
+
this._requireAllowed(cs.content, VALID_CODESYSTEM_CONTENT, 'CodeSystem.content', 'invalid CodeSystem content');
|
|
375
649
|
|
|
376
650
|
let div_ = div();
|
|
377
651
|
|
|
@@ -399,7 +673,7 @@ class Renderer {
|
|
|
399
673
|
p.tx(" ");
|
|
400
674
|
p.startCommaList("and");
|
|
401
675
|
for (let ext of supplements) {
|
|
402
|
-
this.renderLinkComma(p, ext);
|
|
676
|
+
this.renderLinkComma(p, getValuePrimitive(ext));
|
|
403
677
|
}
|
|
404
678
|
p.stopCommaList();
|
|
405
679
|
p.tx(".");
|
|
@@ -447,6 +721,7 @@ class Renderer {
|
|
|
447
721
|
li.tx(":");
|
|
448
722
|
const ul = li.ul();
|
|
449
723
|
for (let c of inc.concept) {
|
|
724
|
+
this._requireValidCode(c.code, 'ValueSet.compose.include.concept.code');
|
|
450
725
|
const li = ul.li();
|
|
451
726
|
const link = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, c.code) : null;
|
|
452
727
|
if (link) {
|
|
@@ -467,6 +742,7 @@ class Renderer {
|
|
|
467
742
|
li.startCommaList("and");
|
|
468
743
|
for (let f of inc.filter) {
|
|
469
744
|
let op = this.readFilterOp(f);
|
|
745
|
+
this._requireAllowed(op, VALID_FILTER_OPS, 'ValueSet.compose.include.filter.op', 'invalid filter operator');
|
|
470
746
|
if (op == 'exists') {
|
|
471
747
|
if (f.value == "true") {
|
|
472
748
|
li.commaItem(f.property+" "+ this.translate('VALUE_SET_EXISTS'));
|
|
@@ -485,13 +761,15 @@ class Renderer {
|
|
|
485
761
|
}
|
|
486
762
|
li.stopCommaList();
|
|
487
763
|
}
|
|
488
|
-
} else {
|
|
764
|
+
} else if (inc.valueSet && inc.valueSet.length > 0) {
|
|
489
765
|
li.tx(this.translatePlural(inc.valueSet.length, 'VALUE_SET_RULES_INC'));
|
|
490
766
|
li.startCommaList("and");
|
|
491
767
|
for (let vs of inc.valueSet) {
|
|
492
768
|
this.renderLinkComma(li, vs);
|
|
493
769
|
}
|
|
494
770
|
li.stopCommaList();
|
|
771
|
+
} else {
|
|
772
|
+
this._invalid('ValueSet.compose.include', inc, 'invalid ValueSet include (must specify a system or at least one valueSet)');
|
|
495
773
|
}
|
|
496
774
|
}
|
|
497
775
|
|
|
@@ -713,6 +991,7 @@ class Renderer {
|
|
|
713
991
|
}
|
|
714
992
|
|
|
715
993
|
async addConceptRow(tbl, concept, level, cs, columnInfo) {
|
|
994
|
+
this._requireValidCode(concept.code, 'CodeSystem.concept.code');
|
|
716
995
|
const tr = tbl.tr();
|
|
717
996
|
|
|
718
997
|
// Apply styling for deprecated concepts
|
|
@@ -1304,9 +1583,8 @@ class Renderer {
|
|
|
1304
1583
|
}
|
|
1305
1584
|
|
|
1306
1585
|
async renderCapabilityStatement(cs) {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
}
|
|
1586
|
+
cs = this._resolveResource(cs, 'CapabilityStatement', 'renderCapabilityStatement');
|
|
1587
|
+
this._requireAllowed(cs.status, VALID_PUBLICATION_STATUS, 'CapabilityStatement.status', 'invalid status');
|
|
1310
1588
|
|
|
1311
1589
|
let div_ = div();
|
|
1312
1590
|
|
|
@@ -1575,9 +1853,8 @@ class Renderer {
|
|
|
1575
1853
|
}
|
|
1576
1854
|
|
|
1577
1855
|
async renderTerminologyCapabilities(tc) {
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
}
|
|
1856
|
+
tc = this._resolveResource(tc, 'TerminologyCapabilities', 'renderTerminologyCapabilities');
|
|
1857
|
+
this._requireAllowed(tc.status, VALID_PUBLICATION_STATUS, 'TerminologyCapabilities.status', 'invalid status');
|
|
1581
1858
|
|
|
1582
1859
|
let div_ = div();
|
|
1583
1860
|
|
|
@@ -1675,9 +1952,8 @@ class Renderer {
|
|
|
1675
1952
|
* metadata table (reusing renderMetadataTable), then group-by-group rendering.
|
|
1676
1953
|
*/
|
|
1677
1954
|
async renderConceptMap(cm) {
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
}
|
|
1955
|
+
cm = this._resolveResource(cm, 'ConceptMap', 'renderConceptMap');
|
|
1956
|
+
this._requireAllowed(cm.status, VALID_PUBLICATION_STATUS, 'ConceptMap.status', 'invalid status');
|
|
1681
1957
|
|
|
1682
1958
|
let div_ = div();
|
|
1683
1959
|
|
|
@@ -2080,8 +2356,12 @@ class Renderer {
|
|
|
2080
2356
|
*/
|
|
2081
2357
|
renderConceptMapRelationship(tr, tgt) {
|
|
2082
2358
|
if (tgt.relationship) {
|
|
2359
|
+
this._requireAllowed(tgt.relationship, VALID_CONCEPTMAP_RELATIONSHIPS,
|
|
2360
|
+
'ConceptMap.group.element.target.relationship', 'invalid ConceptMap relationship');
|
|
2083
2361
|
tr.td().tx(this.presentRelationshipCode(tgt.relationship));
|
|
2084
2362
|
} else if (tgt.equivalence) {
|
|
2363
|
+
this._requireAllowed(tgt.equivalence, VALID_CONCEPTMAP_EQUIVALENCES,
|
|
2364
|
+
'ConceptMap.group.element.target.equivalence', 'invalid ConceptMap equivalence');
|
|
2085
2365
|
tr.td().tx(this.presentEquivalenceCode(tgt.equivalence));
|
|
2086
2366
|
} else {
|
|
2087
2367
|
tr.td().tx("(" + "equivalent" + ")");
|
|
@@ -2252,6 +2532,7 @@ class Renderer {
|
|
|
2252
2532
|
return f.op;
|
|
2253
2533
|
}
|
|
2254
2534
|
}
|
|
2535
|
+
|
|
2255
2536
|
}
|
|
2256
2537
|
|
|
2257
2538
|
module.exports = { Renderer };
|
package/tx/library/ucum-types.js
CHANGED
|
@@ -939,7 +939,10 @@ class Registry {
|
|
|
939
939
|
}
|
|
940
940
|
|
|
941
941
|
register(handler) {
|
|
942
|
-
|
|
942
|
+
// Key on getCode() — the interface every handler implements. Some handlers
|
|
943
|
+
// (CelsiusHandler/FahrenheitHandler) override getCode() without setting a
|
|
944
|
+
// `.code` field, so keying on `.code` would register them under `undefined`.
|
|
945
|
+
this.handlers.set(handler.getCode(), handler);
|
|
943
946
|
}
|
|
944
947
|
}
|
|
945
948
|
|
package/tx/library.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const fs = require('fs').promises;
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const yaml = require('yaml'); // npm install yaml
|
|
4
5
|
const { PackageManager, PackageContentLoader } = require('../library/package-manager');
|
|
6
|
+
const { FolderContentLoader } = require('../library/folder-content-loader');
|
|
5
7
|
const { CodeSystem } = require("./library/codesystem");
|
|
6
8
|
const {CountryCodeFactoryProvider} = require("./cs/cs-country");
|
|
7
9
|
const {Iso4217FactoryProvider} = require("./cs/cs-currency");
|
|
@@ -295,6 +297,10 @@ class Library {
|
|
|
295
297
|
await this.loadOcl(details, isDefault, mode);
|
|
296
298
|
break;
|
|
297
299
|
|
|
300
|
+
case 'folder':
|
|
301
|
+
await this.loadFolder(details, isDefault, mode);
|
|
302
|
+
break;
|
|
303
|
+
|
|
298
304
|
default:
|
|
299
305
|
throw new Error(`Unknown source type: ${type}`);
|
|
300
306
|
}
|
|
@@ -598,6 +604,23 @@ class Library {
|
|
|
598
604
|
version = parts[1];
|
|
599
605
|
}
|
|
600
606
|
const packagePath = await packageManager.fetch(packageId, version);
|
|
607
|
+
await this.#loadPackageFromPath(packagePath, mode, csOnly);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async loadUrl(packageManager, url, isDefault, mode, csOnly) {
|
|
611
|
+
const packagePath = await packageManager.fetchUrl(url);
|
|
612
|
+
await this.#loadPackageFromPath(packagePath, mode, csOnly);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Shared loader for npm- and url-sourced packages. Given a fetched package
|
|
617
|
+
* path, loads its CodeSystems (and, unless csOnly, its ValueSets and
|
|
618
|
+
* ConceptMaps) into this library. Factored out of loadNpm/loadUrl so the two
|
|
619
|
+
* cannot drift apart (a past divergence here used Map-style .set() on the
|
|
620
|
+
* array-backed codeSystems list, breaking url sources).
|
|
621
|
+
* @private
|
|
622
|
+
*/
|
|
623
|
+
async #loadPackageFromPath(packagePath, mode, csOnly) {
|
|
601
624
|
if (mode === "fetch" || mode === "cs") {
|
|
602
625
|
return;
|
|
603
626
|
}
|
|
@@ -634,43 +657,64 @@ class Library {
|
|
|
634
657
|
this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0);
|
|
635
658
|
}
|
|
636
659
|
|
|
637
|
-
|
|
638
|
-
|
|
660
|
+
/**
|
|
661
|
+
* Loads CodeSystem / ValueSet / ConceptMap resources from any *.json file in a folder.
|
|
662
|
+
* The folder is scanned (top level only); each JSON file is read and routed to the
|
|
663
|
+
* appropriate provider based on its resourceType. Files that fail to parse or whose
|
|
664
|
+
* resourceType isn't one of the three terminology types are silently skipped.
|
|
665
|
+
*
|
|
666
|
+
* Relative paths are resolved against the project root (same convention as loadUcum etc.).
|
|
667
|
+
*
|
|
668
|
+
* @param {string} details - The folder to scan
|
|
669
|
+
* @param {boolean} isDefault - Unused; folder sources don't register factories
|
|
670
|
+
* @param {string} mode - One of "fetch", "cs", "npm"
|
|
671
|
+
*/
|
|
672
|
+
// eslint-disable-next-line no-unused-vars
|
|
673
|
+
async loadFolder(details, isDefault, mode) {
|
|
639
674
|
if (mode === "fetch" || mode === "cs") {
|
|
640
675
|
return;
|
|
641
676
|
}
|
|
642
|
-
|
|
643
|
-
const
|
|
677
|
+
|
|
678
|
+
const folderPath = path.isAbsolute(details)
|
|
679
|
+
? details
|
|
680
|
+
: path.resolve(path.join(__dirname, '..', details));
|
|
681
|
+
|
|
682
|
+
// Park the Package*Provider SQLite caches under the terminology cache rather
|
|
683
|
+
// than polluting the user's source folder, and wipe between runs so edits to
|
|
684
|
+
// the source folder are reliably picked up.
|
|
685
|
+
const hash = crypto.createHash('sha1').update(folderPath).digest('hex').substring(0, 16);
|
|
686
|
+
const cacheSubdir = path.join(this.cacheFolder, 'folder-source-' + hash);
|
|
687
|
+
await fs.rm(cacheSubdir, { recursive: true, force: true });
|
|
688
|
+
await fs.mkdir(cacheSubdir, { recursive: true });
|
|
689
|
+
|
|
690
|
+
const contentLoader = new FolderContentLoader(folderPath, cacheSubdir);
|
|
644
691
|
await contentLoader.initialize();
|
|
645
692
|
|
|
646
|
-
this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
|
|
693
|
+
this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
|
|
647
694
|
|
|
648
|
-
|
|
649
|
-
const
|
|
695
|
+
const cp = new ListCodeSystemProvider();
|
|
696
|
+
const csEntries = await contentLoader.getResourcesByType("CodeSystem");
|
|
650
697
|
let csc = 0;
|
|
651
|
-
for (const
|
|
652
|
-
const cs = new CodeSystem(await contentLoader.loadFile(
|
|
698
|
+
for (const entry of csEntries) {
|
|
699
|
+
const cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion()));
|
|
653
700
|
if (this.#isIgnored(cs.url, cs.version)) {
|
|
654
701
|
this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`);
|
|
655
702
|
continue;
|
|
656
703
|
}
|
|
657
704
|
cs.sourcePackage = contentLoader.pid();
|
|
658
|
-
cp.codeSystems.
|
|
659
|
-
cp.codeSystems.set(cs.vurl, cs);
|
|
705
|
+
cp.codeSystems.push(cs);
|
|
660
706
|
csc++;
|
|
661
707
|
}
|
|
662
708
|
this.codeSystemProviders.push(cp);
|
|
663
|
-
let vs = null;
|
|
664
|
-
if (!csOnly) {
|
|
665
|
-
vs = new PackageValueSetProvider(contentLoader);
|
|
666
|
-
await vs.initialize();
|
|
667
|
-
this.valueSetProviders.push(vs);
|
|
668
|
-
const cm = new PackageConceptMapProvider(contentLoader);
|
|
669
|
-
await cm.initialize();
|
|
670
|
-
this.conceptMapProviders.push(cm);
|
|
671
|
-
}
|
|
672
709
|
|
|
673
|
-
|
|
710
|
+
const vs = new PackageValueSetProvider(contentLoader);
|
|
711
|
+
await vs.initialize();
|
|
712
|
+
this.valueSetProviders.push(vs);
|
|
713
|
+
const cm = new PackageConceptMapProvider(contentLoader);
|
|
714
|
+
await cm.initialize();
|
|
715
|
+
this.conceptMapProviders.push(cm);
|
|
716
|
+
|
|
717
|
+
this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs.valueSetMap.size);
|
|
674
718
|
}
|
|
675
719
|
|
|
676
720
|
/**
|
package/tx/operation-context.js
CHANGED
|
@@ -204,6 +204,10 @@ class ExpansionCache {
|
|
|
204
204
|
this.cache = new Map();
|
|
205
205
|
this.maxSize = maxSize;
|
|
206
206
|
this.memoryThresholdBytes = memoryThresholdMB * 1024 * 1024;
|
|
207
|
+
// When true, every expansion is cached regardless of how long it took
|
|
208
|
+
// (bypasses MIN_CACHE_TIME_MS). Used by the test runner to force the cache
|
|
209
|
+
// path so cache correctness (e.g. language in the key) is exercised.
|
|
210
|
+
this.forceCaching = false;
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
/**
|
|
@@ -284,8 +288,9 @@ class ExpansionCache {
|
|
|
284
288
|
* @returns {boolean} True if cached, false if duration too short
|
|
285
289
|
*/
|
|
286
290
|
set(key, expansion, durationMs) {
|
|
287
|
-
// Only cache if expansion took significant time
|
|
288
|
-
|
|
291
|
+
// Only cache if expansion took significant time, unless forceCaching is on
|
|
292
|
+
// (in which case everything is cached regardless of duration).
|
|
293
|
+
if (!this.forceCaching && durationMs < ExpansionCache.MIN_CACHE_TIME_MS) {
|
|
289
294
|
return false;
|
|
290
295
|
}
|
|
291
296
|
|
|
@@ -357,21 +362,6 @@ class ExpansionCache {
|
|
|
357
362
|
return false;
|
|
358
363
|
}
|
|
359
364
|
|
|
360
|
-
/**
|
|
361
|
-
* Force-store an expansion regardless of duration (for testing)
|
|
362
|
-
* @param {string} key - Hash key
|
|
363
|
-
* @param {Object} expansion - The expanded ValueSet
|
|
364
|
-
*/
|
|
365
|
-
forceSet(key, expansion) {
|
|
366
|
-
this.cache.set(key, {
|
|
367
|
-
expansion: expansion,
|
|
368
|
-
createdAt: Date.now(),
|
|
369
|
-
lastUsed: Date.now(),
|
|
370
|
-
durationMs: 0,
|
|
371
|
-
hitCount: 0
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
|
|
375
365
|
/**
|
|
376
366
|
* Clear a specific entry
|
|
377
367
|
* @param {string} key - Hash key
|
|
@@ -388,22 +378,22 @@ class ExpansionCache {
|
|
|
388
378
|
}
|
|
389
379
|
|
|
390
380
|
/**
|
|
391
|
-
* Get cache statistics
|
|
381
|
+
* Get cache statistics.
|
|
382
|
+
* NB: named getStats(), not stats() — the `stats` field (the ServerStats
|
|
383
|
+
* passed to the constructor) would shadow a method called `stats`, making it
|
|
384
|
+
* unreachable.
|
|
392
385
|
* @returns {Object} Stats object
|
|
393
386
|
*/
|
|
394
|
-
|
|
387
|
+
getStats() {
|
|
395
388
|
let totalHits = 0;
|
|
396
|
-
let totalDuration = 0;
|
|
397
389
|
for (const entry of this.cache.values()) {
|
|
398
390
|
totalHits += entry.hitCount;
|
|
399
|
-
totalDuration += entry.durationMs;
|
|
400
391
|
}
|
|
401
392
|
return {
|
|
402
393
|
size: this.cache.size,
|
|
403
394
|
maxSize: this.maxSize,
|
|
404
395
|
memoryThresholdMB: this.memoryThresholdBytes > 0 ? this.memoryThresholdBytes / (1024 * 1024) : 0,
|
|
405
|
-
totalHits
|
|
406
|
-
totalDurationSaved: totalHits > 0 ? totalDuration * totalHits : 0
|
|
396
|
+
totalHits
|
|
407
397
|
};
|
|
408
398
|
}
|
|
409
399
|
|