fhirsmith 0.18.1 → 0.18.2
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 +41 -9
- package/ips/ips.js +251 -26
- package/ips/jurisdictions.js +65 -0
- package/ips/pipeline.js +67 -6
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +48 -5
- package/ips/render.js +317 -46
- package/ips/scripts.js +46 -0
- package/ips/store.js +55 -0
- package/package.json +3 -3
- package/registry/api.js +179 -1
- package/registry/crawler.js +2 -0
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry.js +53 -2
- package/tx/README.md +4 -0
- package/tx/cm/cm-package.js +21 -15
- package/tx/cs/cs-cs.js +51 -10
- package/tx/cs/cs-loinc.js +8 -1
- package/tx/cs/cs-snomed.js +253 -31
- package/tx/importers/import-sct.module.js +69 -4
- package/tx/library/tho-precedence.js +40 -0
- package/tx/provider.js +36 -25
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +34 -11
- package/tx/sct/structures.js +9 -2
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/vs/vs-package.js +17 -13
- package/tx/vs/vs-vsac.js +1 -1
package/ips/profile-editor.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* the rest: a row of the second - type, system, code or value set, the rest), and
|
|
15
15
|
* other rules (FHIRPath). A row's expression is made from it. Saving posts the title and the
|
|
16
16
|
* constraints (key, description, expression); the server checks them, and keeps the keys.
|
|
17
|
+
* With cfg.mode 'rows' it is the profile's own page instead: the rows are the page's tables,
|
|
18
|
+
* and one at a time is added or edited, in a dialog, and saved by itself.
|
|
17
19
|
*/
|
|
18
20
|
(function () {
|
|
19
21
|
'use strict';
|
|
@@ -22,6 +24,8 @@
|
|
|
22
24
|
var LOINC = cfg.sectionSystem;
|
|
23
25
|
var dirty = false;
|
|
24
26
|
var saving = false;
|
|
27
|
+
// what to draw again when a row's fields change shape: the whole editor, or the row being edited
|
|
28
|
+
var redraw = function () { draw(); };
|
|
25
29
|
|
|
26
30
|
function el(tag, attrs, children) {
|
|
27
31
|
var e = document.createElement(tag);
|
|
@@ -359,11 +363,13 @@
|
|
|
359
363
|
url.hidden = select.value !== '*';
|
|
360
364
|
url.addEventListener('input', function () { r.entSystem = url.value.trim(); changed(); onChange(); });
|
|
361
365
|
select.addEventListener('change', function () {
|
|
362
|
-
var
|
|
366
|
+
var was = r.entSystem;
|
|
367
|
+
var wasVs = was === VS;
|
|
363
368
|
r.entSystem = select.value === '*' ? url.value.trim() : select.value;
|
|
364
369
|
if (!select.value) { r.entCode = ''; r.entCard = ''; r.entVs = ''; }
|
|
365
370
|
changed();
|
|
366
|
-
|
|
371
|
+
// the code (or value set) input comes and goes with the choice
|
|
372
|
+
if (!was || wasVs !== (r.entSystem === VS) || !select.value || select.value === '*') { redraw(); } else { onChange(); }
|
|
367
373
|
});
|
|
368
374
|
cell.appendChild(el('div', { class: 'ips-pf-pair' }, [select, url]));
|
|
369
375
|
if (r.entSystem === VS) {
|
|
@@ -414,7 +420,7 @@
|
|
|
414
420
|
r.resSystem = select.value === '*' ? url.value.trim() : select.value;
|
|
415
421
|
changed();
|
|
416
422
|
// the code column becomes a value set's, or a code's
|
|
417
|
-
if (wasVs !== (r.resSystem === VS)) {
|
|
423
|
+
if (wasVs !== (r.resSystem === VS)) { redraw(); } else { onChange(); }
|
|
418
424
|
});
|
|
419
425
|
cell.appendChild(select);
|
|
420
426
|
cell.appendChild(url);
|
|
@@ -573,6 +579,279 @@
|
|
|
573
579
|
.catch(function (e) { saving = false; show(['The profile was not saved: ' + e.message]); });
|
|
574
580
|
}
|
|
575
581
|
|
|
582
|
+
// ---- the profile's page: a rule at a time, added or edited in a dialog ----
|
|
583
|
+
|
|
584
|
+
// what must be true of the resources a rule is about: the rest of the rule, by kind
|
|
585
|
+
var NAME_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)?$/;
|
|
586
|
+
var RULE_KINDS = [
|
|
587
|
+
['present', 'Must be present somewhere'],
|
|
588
|
+
['subject', 'Patient must match the Document'],
|
|
589
|
+
['element', 'Must have an element'],
|
|
590
|
+
['conforms', 'Conform to a profile'],
|
|
591
|
+
['category', 'Has a category'],
|
|
592
|
+
['other', 'Something else (FHIRPath)']
|
|
593
|
+
];
|
|
594
|
+
function allOf(body) { return new RegExp('^\\.\\s*(?:' + body + '|all\\s*\\(\\s*' + body + '\\s*\\))$'); }
|
|
595
|
+
var CONFORMS_RE = allOf('conformsTo\\s*\\(\\s*' + STR + '\\s*\\)');
|
|
596
|
+
var CATEGORY_RE = allOf('category\\s*\\.\\s*hasCoding\\s*\\(\\s*' + STR + '\\s*,\\s*' + STR + '\\s*(?:,\\s*' + STR + '\\s*)?\\)');
|
|
597
|
+
|
|
598
|
+
/** the rule's kind, and its fields, from the rest of a rule about resources */
|
|
599
|
+
function ruleFields(r) {
|
|
600
|
+
var rest = (r.resRest || '').trim();
|
|
601
|
+
var m;
|
|
602
|
+
r.ruleKind = 'other';
|
|
603
|
+
r.ruleOther = rest;
|
|
604
|
+
r.ruleUrl = ''; r.ruleSystem = cfg.categorySystem || ''; r.ruleCode = ''; r.ruleVersion = ''; r.rulePath = ''; r.ruleDisplay = '';
|
|
605
|
+
r.ruleAll = true;
|
|
606
|
+
if (/^\.\s*exists\s*\(\s*\)$/.test(rest)) {
|
|
607
|
+
r.ruleKind = 'present';
|
|
608
|
+
} else if (/^\.\s*all\s*\(\s*sameSubject\s*\(\s*\)\s*\)$/.test(rest)) {
|
|
609
|
+
r.ruleKind = 'subject';
|
|
610
|
+
} else if ((m = CONFORMS_RE.exec(rest))) {
|
|
611
|
+
r.ruleKind = 'conforms';
|
|
612
|
+
r.ruleAll = m[1] === undefined;
|
|
613
|
+
r.ruleUrl = unquote(m[1] !== undefined ? m[1] : m[2]);
|
|
614
|
+
} else if ((m = CATEGORY_RE.exec(rest))) {
|
|
615
|
+
r.ruleKind = 'category';
|
|
616
|
+
r.ruleAll = m[1] === undefined;
|
|
617
|
+
var g = m[1] !== undefined ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]];
|
|
618
|
+
r.ruleSystem = unquote(g[0]); r.ruleCode = unquote(g[1]); r.ruleVersion = g[2] !== undefined ? unquote(g[2]) : '';
|
|
619
|
+
} else if ((m = /^\.\s*([A-Za-z][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z][A-Za-z0-9_]*)?)\s*\.\s*exists\s*\(\s*\)$/.exec(rest))) {
|
|
620
|
+
r.ruleKind = 'element';
|
|
621
|
+
r.rulePath = m[1].replace(/\s+/g, '');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** the rest of a rule about resources, from its kind and fields, or null with what is wrong */
|
|
625
|
+
function ruleRest(r) {
|
|
626
|
+
var each = function (body) { return r.ruleAll ? '.all(' + body + ')' : '.' + body; };
|
|
627
|
+
switch (r.ruleKind) {
|
|
628
|
+
case 'present': return { rest: '.exists()', problems: [] };
|
|
629
|
+
case 'subject': return { rest: '.all(sameSubject())', problems: [] };
|
|
630
|
+
case 'element': return NAME_RE.test(r.rulePath.trim()) ? { rest: '.' + r.rulePath.trim() + '.exists()', problems: [] } : { rest: null, problems: ['rulePath'] };
|
|
631
|
+
case 'conforms': return CANONICAL_RE.test(r.ruleUrl.trim()) ? { rest: each('conformsTo(' + quote(r.ruleUrl.trim()) + ')'), problems: [] } : { rest: null, problems: ['ruleUrl'] };
|
|
632
|
+
case 'category': {
|
|
633
|
+
var problems = [];
|
|
634
|
+
if (!r.ruleSystem.trim()) { problems.push('ruleSystem'); }
|
|
635
|
+
if (!r.ruleCode.trim()) { problems.push('ruleCode'); }
|
|
636
|
+
if (problems.length) { return { rest: null, problems: problems }; }
|
|
637
|
+
return { rest: each('category.hasCoding(' + quote(r.ruleSystem.trim()) + ', ' + quote(r.ruleCode.trim()) + (r.ruleVersion.trim() ? ', ' + quote(r.ruleVersion.trim()) : '') + ')'), problems: [] };
|
|
638
|
+
}
|
|
639
|
+
default: {
|
|
640
|
+
var rest = (r.ruleOther || '').trim();
|
|
641
|
+
return rest && rest !== '.' ? { rest: rest.charAt(0) === '.' ? rest : '.' + rest, problems: [] } : { rest: null, problems: ['ruleOther'] };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/** the expression of a row in the dialog, or null with what is wrong */
|
|
646
|
+
function dialogExpression(r) {
|
|
647
|
+
if (r.mode !== 'resource') { return rowExpression(r); }
|
|
648
|
+
var t = ruleRest(r);
|
|
649
|
+
r.resRest = t.rest || '';
|
|
650
|
+
var e = resourceExpression(r);
|
|
651
|
+
return { expression: t.rest ? e.expression : null, problems: t.problems.concat(e.problems.filter(function (p) { return p !== 'resRest'; })) };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function rowsMode() {
|
|
655
|
+
var MODE = { section: 'section', resource: 'resource', other: 'text' };
|
|
656
|
+
var TITLES = { section: 'a required section or entry', resource: 'a rule about resources', text: 'a rule' };
|
|
657
|
+
var byKey = {};
|
|
658
|
+
(cfg.profile.constraints || []).forEach(function (c) { byKey[c.key] = c; });
|
|
659
|
+
var dialog = el('dialog', { class: 'ips-pf-dialog', 'aria-labelledby': 'ips-pf-dialog-title' });
|
|
660
|
+
document.body.appendChild(dialog);
|
|
661
|
+
var editing = null;
|
|
662
|
+
|
|
663
|
+
function showIn(box, problems, kind) {
|
|
664
|
+
box.innerHTML = '';
|
|
665
|
+
if (!problems.length) { return; }
|
|
666
|
+
var alert = el('div', { class: 'alert alert-' + (kind || 'danger'), style: 'margin: 0' });
|
|
667
|
+
if (problems.length === 1) { alert.textContent = problems[0]; } else {
|
|
668
|
+
alert.appendChild(el('ul', { style: 'margin: 0' }, problems.map(function (p) { return el('li', { text: p }); })));
|
|
669
|
+
}
|
|
670
|
+
box.appendChild(alert);
|
|
671
|
+
}
|
|
672
|
+
// a labelled part of the dialog: a label, what goes in it, and a hint
|
|
673
|
+
function field(label, controls, hint) {
|
|
674
|
+
return el('div', { class: 'ips-pf-field' }, [el('label', { class: 'ips-pf-label', text: label })].concat(controls).concat(hint ? [el('div', { class: 'ips-pf-hint', text: hint })] : []));
|
|
675
|
+
}
|
|
676
|
+
function howMany(r, fieldName, update, fields) {
|
|
677
|
+
var input = cardInput(r, fieldName, update);
|
|
678
|
+
fields[fieldName] = input;
|
|
679
|
+
return el('div', { class: 'ips-pf-pair ips-pf-how' }, [el('span', { text: 'How many' }), input,
|
|
680
|
+
el('span', { class: 'ips-pf-hint', text: 'blank: at least one; or min..max - 1..1, 0..1, 1..*' })]);
|
|
681
|
+
}
|
|
682
|
+
// a code system for the category: one of the named ones, or another by URL
|
|
683
|
+
function categorySystem(r, update) {
|
|
684
|
+
var cell = el('div', { class: 'ips-pf-pair' });
|
|
685
|
+
var select = el('select', { class: 'form-control ips-ed-input' });
|
|
686
|
+
cfg.systems.forEach(function (s) { select.appendChild(el('option', { value: s.url, text: s.name })); });
|
|
687
|
+
(cfg.categorySystems || []).forEach(function (s) { select.appendChild(el('option', { value: s.url, text: s.name })); });
|
|
688
|
+
select.appendChild(el('option', { value: '*', text: 'Another system...' }));
|
|
689
|
+
var known = Array.prototype.some.call(select.options, function (o) { return o.value === r.ruleSystem; });
|
|
690
|
+
select.value = known ? r.ruleSystem : '*';
|
|
691
|
+
var url = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'code system URL' });
|
|
692
|
+
url.value = known ? '' : r.ruleSystem;
|
|
693
|
+
url.hidden = known;
|
|
694
|
+
url.addEventListener('input', function () { r.ruleSystem = url.value.trim(); changed(); update(); });
|
|
695
|
+
select.addEventListener('change', function () {
|
|
696
|
+
url.hidden = select.value !== '*';
|
|
697
|
+
r.ruleSystem = select.value === '*' ? url.value.trim() : select.value;
|
|
698
|
+
changed();
|
|
699
|
+
update();
|
|
700
|
+
});
|
|
701
|
+
cell.appendChild(select);
|
|
702
|
+
cell.appendChild(url);
|
|
703
|
+
cell.url = url;
|
|
704
|
+
return cell;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** the dialog's contents, (re)drawn for the row being edited */
|
|
708
|
+
function drawDialog() {
|
|
709
|
+
var e = editing;
|
|
710
|
+
var r = e.r;
|
|
711
|
+
dialog.innerHTML = '';
|
|
712
|
+
var fields = {};
|
|
713
|
+
var exprLine = el('div', { class: 'ips-pf-expr' });
|
|
714
|
+
var update = function () {
|
|
715
|
+
var t = dialogExpression(r);
|
|
716
|
+
exprLine.textContent = r.mode === 'text' ? '' : (t.expression || '(incomplete - the fields in red)');
|
|
717
|
+
Object.keys(fields).forEach(function (k) { fields[k].classList.toggle('ips-pf-bad', t.problems.indexOf(k) >= 0); });
|
|
718
|
+
};
|
|
719
|
+
var body = el('div', { class: 'ips-pf-dialog-body' });
|
|
720
|
+
var human = el('textarea', { class: 'form-control', rows: 2, placeholder: 'e.g. A problem list, with the patient\'s hypertension' });
|
|
721
|
+
human.value = r.human;
|
|
722
|
+
human.addEventListener('input', function () { r.human = human.value; changed(); });
|
|
723
|
+
body.appendChild(field('Expectation', [human], 'What is expected, as people will read it.'));
|
|
724
|
+
|
|
725
|
+
if (r.mode === 'section') {
|
|
726
|
+
body.appendChild(field('Section', [sectionCell(r, update), howMany(r, 'secCard', update, fields)]));
|
|
727
|
+
var ent = entryCell(r, update);
|
|
728
|
+
if (ent.code) { fields.entCode = ent.code.input; }
|
|
729
|
+
if (ent.vs) { fields.entVs = ent.vs.input; }
|
|
730
|
+
body.appendChild(field('Entries', [ent].concat(r.entSystem ? [howMany(r, 'entCard', update, fields)] : []),
|
|
731
|
+
'Optionally, entries the section must have: with a code in a code system, or a code in a value set.'));
|
|
732
|
+
} else if (r.mode === 'resource') {
|
|
733
|
+
var type = typeCell(r, update);
|
|
734
|
+
var system = systemCell(r, update);
|
|
735
|
+
var byVs = r.resSystem === VS;
|
|
736
|
+
var code = byVs ? vsInput(r, 'resVs', 'resVsTitle', update) : codeInput(r, 'resCode', 'resDisplay', function () { return r.resSystem; }, update);
|
|
737
|
+
fields.resType = type.other;
|
|
738
|
+
fields.resSystem = system.url;
|
|
739
|
+
fields[byVs ? 'resVs' : 'resCode'] = code.input;
|
|
740
|
+
body.appendChild(field('Resources', [type], 'The type of resource, or any.'));
|
|
741
|
+
body.appendChild(field('With a code', [system, code], 'Anywhere in the document, whatever section they are in.'));
|
|
742
|
+
var kind = el('select', { class: 'form-control ips-ed-input' });
|
|
743
|
+
RULE_KINDS.forEach(function (k) { kind.appendChild(el('option', { value: k[0], text: k[1] })); });
|
|
744
|
+
kind.value = r.ruleKind;
|
|
745
|
+
kind.addEventListener('change', function () { r.ruleKind = kind.value; changed(); drawDialog(); });
|
|
746
|
+
var more = [];
|
|
747
|
+
if (r.ruleKind === 'element') {
|
|
748
|
+
var path = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'e.g. onset, or code.text' });
|
|
749
|
+
path.value = r.rulePath;
|
|
750
|
+
path.addEventListener('input', function () { r.rulePath = path.value; changed(); update(); });
|
|
751
|
+
fields.rulePath = path;
|
|
752
|
+
more.push(field('Element', [path], 'An element of the resources, or an element of one of their elements (one dot, no deeper).'));
|
|
753
|
+
} else if (r.ruleKind === 'conforms') {
|
|
754
|
+
var url = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'the profile\'s canonical URL' });
|
|
755
|
+
url.value = r.ruleUrl;
|
|
756
|
+
url.addEventListener('input', function () { r.ruleUrl = url.value; changed(); update(); });
|
|
757
|
+
fields.ruleUrl = url;
|
|
758
|
+
more.push(field('Profile', [url], 'Each of the resources must conform to it. It can be one of the clinical profiles, or a profile the validator has loaded.'));
|
|
759
|
+
} else if (r.ruleKind === 'category') {
|
|
760
|
+
var csys = categorySystem(r, update);
|
|
761
|
+
var ccode = codeInput(r, 'ruleCode', 'ruleDisplay', function () { return r.ruleSystem; }, update);
|
|
762
|
+
var version = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'version (optional)' });
|
|
763
|
+
version.value = r.ruleVersion;
|
|
764
|
+
version.addEventListener('input', function () { r.ruleVersion = version.value; changed(); update(); });
|
|
765
|
+
fields.ruleSystem = csys.url;
|
|
766
|
+
fields.ruleCode = ccode.input;
|
|
767
|
+
more.push(field('Category', [csys, ccode, version], 'Each of the resources must have a category with this code.'));
|
|
768
|
+
} else if (r.ruleKind === 'other') {
|
|
769
|
+
var other = el('textarea', { class: 'form-control ips-ed-input', rows: 3, placeholder: '.count() = 1', style: 'font-family: monospace' });
|
|
770
|
+
other.value = r.ruleOther;
|
|
771
|
+
other.addEventListener('input', function () { r.ruleOther = other.value; changed(); update(); });
|
|
772
|
+
fields.ruleOther = other;
|
|
773
|
+
more.push(field('FHIRPath', [other], 'FHIRPath on the resources found, starting with a dot.'));
|
|
774
|
+
}
|
|
775
|
+
body.appendChild(field('What must be true', [kind].concat(more)));
|
|
776
|
+
} else {
|
|
777
|
+
var text = el('textarea', { class: 'form-control ips-ed-input', rows: 5, placeholder: 'A FHIRPath expression on the document (the Bundle), true when it meets the expectation', style: 'font-family: monospace' });
|
|
778
|
+
text.value = r.expression;
|
|
779
|
+
text.addEventListener('input', function () { r.expression = text.value; changed(); });
|
|
780
|
+
body.appendChild(field('FHIRPath', [text], 'Evaluated on the document (the Bundle): the rule is met when it is true.'));
|
|
781
|
+
}
|
|
782
|
+
if (r.mode !== 'text') { body.appendChild(field('As FHIRPath', [exprLine])); }
|
|
783
|
+
body.appendChild(e.msg);
|
|
784
|
+
|
|
785
|
+
var save = el('button', { type: 'button', class: 'btn btn-primary', text: 'Save', onclick: function () { saveRow(); } });
|
|
786
|
+
var cancel = el('button', { type: 'button', class: 'btn btn-default', text: 'Cancel', onclick: function () { stop(false); } });
|
|
787
|
+
e.save = save;
|
|
788
|
+
dialog.appendChild(el('div', { class: 'ips-pf-dialog-head' }, [
|
|
789
|
+
el('h4', { id: 'ips-pf-dialog-title', text: (r.key ? 'Edit ' : 'Add ') + TITLES[r.mode] }),
|
|
790
|
+
el('button', { type: 'button', class: 'ips-pf-dialog-x', 'aria-label': 'Close', text: '×', onclick: function () { stop(false); } })
|
|
791
|
+
]));
|
|
792
|
+
dialog.appendChild(body);
|
|
793
|
+
dialog.appendChild(el('div', { class: 'ips-pf-dialog-foot' }, [cancel, ' ', save]));
|
|
794
|
+
update();
|
|
795
|
+
if (!e.focused) { e.focused = true; human.focus(); }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function start(r) {
|
|
799
|
+
if (editing) { return; }
|
|
800
|
+
if (r.mode === 'resource') { ruleFields(r); }
|
|
801
|
+
editing = { r: r, msg: el('div', { class: 'ips-pf-messages' }) };
|
|
802
|
+
dirty = false;
|
|
803
|
+
drawDialog();
|
|
804
|
+
dialog.showModal();
|
|
805
|
+
}
|
|
806
|
+
// close the dialog: when there are changes, only if the user says so (unless saved)
|
|
807
|
+
function stop(saved) {
|
|
808
|
+
if (!editing) { return; }
|
|
809
|
+
if (!saved && dirty && !window.confirm('Discard your changes to this rule?')) { return; }
|
|
810
|
+
editing = null;
|
|
811
|
+
dirty = false;
|
|
812
|
+
dialog.close();
|
|
813
|
+
}
|
|
814
|
+
dialog.addEventListener('cancel', function (ev) { ev.preventDefault(); stop(false); });
|
|
815
|
+
|
|
816
|
+
function saveRow() {
|
|
817
|
+
var e = editing;
|
|
818
|
+
if (!e || saving) { return; }
|
|
819
|
+
var t = dialogExpression(e.r);
|
|
820
|
+
if (!t.expression && e.r.mode !== 'text') { showIn(e.msg, ['The rule is incomplete (the fields in red)']); return; }
|
|
821
|
+
saving = true;
|
|
822
|
+
e.save.disabled = true;
|
|
823
|
+
showIn(e.msg, ['Saving...'], 'info');
|
|
824
|
+
var url = cfg.urls.constraint + (e.r.key ? '/' + encodeURIComponent(e.r.key) : '');
|
|
825
|
+
fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
826
|
+
body: JSON.stringify({ human: e.r.human, expression: t.expression || '', _csrf: cfg.csrf }) })
|
|
827
|
+
.then(function (res) {
|
|
828
|
+
return res.json().then(function (j) { return { ok: res.ok, j: j }; }, function () { return { ok: false, j: { problems: ['The server answered ' + res.status + (res.status === 403 ? ' (has your session expired? reload the page)' : '')] } }; });
|
|
829
|
+
})
|
|
830
|
+
.then(function (a) {
|
|
831
|
+
saving = false;
|
|
832
|
+
if (a.ok) { stop(true); location.reload(); return; }
|
|
833
|
+
e.save.disabled = false;
|
|
834
|
+
showIn(e.msg, a.j.problems || ['The rule was not saved']);
|
|
835
|
+
})
|
|
836
|
+
.catch(function (x) { saving = false; e.save.disabled = false; showIn(e.msg, ['The rule was not saved: ' + x.message]); });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
redraw = drawDialog;
|
|
840
|
+
Array.prototype.forEach.call(document.querySelectorAll('.ips-pf-edit'), function (b) {
|
|
841
|
+
b.addEventListener('click', function () {
|
|
842
|
+
var c = byKey[b.closest('tr').getAttribute('data-key')];
|
|
843
|
+
if (c) { start(rowFrom(c)); }
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
Array.prototype.forEach.call(document.querySelectorAll('.ips-pf-add'), function (b) {
|
|
847
|
+
b.addEventListener('click', function () {
|
|
848
|
+
var r = rowFrom({});
|
|
849
|
+
r.mode = MODE[b.getAttribute('data-part')];
|
|
850
|
+
start(r);
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
576
855
|
window.addEventListener('beforeunload', function (e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
|
|
577
|
-
draw();
|
|
856
|
+
if (cfg.mode === 'rows') { rowsMode(); } else { draw(); }
|
|
578
857
|
})();
|
package/ips/readme.md
CHANGED
|
@@ -37,7 +37,9 @@ network. Without it there are no links and no analysis.
|
|
|
37
37
|
|
|
38
38
|
`/ips` lists the projects. The administrator logs in at `/ips/login` and, at
|
|
39
39
|
`/ips/admin/projects` and `/ips/admin/users`, creates and deletes projects and users, and
|
|
40
|
-
says which users manage which projects.
|
|
40
|
+
says which users manage which projects. The administrator also looks after the
|
|
41
|
+
[clinical profiles](#clinical-profiles) and the [national profiles](#national-profiles),
|
|
42
|
+
which apply across all projects. A project has a code - 2-32 lower case letters,
|
|
41
43
|
digits and hyphens, fixed once it exists - and lives at `/ips/{code}`. Deleting a project
|
|
42
44
|
deletes its documents.
|
|
43
45
|
|
|
@@ -156,9 +158,9 @@ it (`ips/pipeline.js`), and marks it `processed` with the steps' notes, or `erro
|
|
|
156
158
|
message of the step that failed. Each step's own state (`waiting`, `running`, `done` or
|
|
157
159
|
`failed`, with its note or error) is kept in its result row as it goes, which is what the
|
|
158
160
|
document page's step line shows; processing again clears every step's result first. The pipeline, in order, will be: **translate** CodeableConcepts
|
|
159
|
-
to the international terminologies; generate the **narrative**; **validate** the document;
|
|
160
|
-
measure its quality with PIQI; check it against the **clinical profiles**; **assess** it with AI. Translation, the narrative,
|
|
161
|
-
validation, the **terminology check**, the clinical profiles check and the AI assessment exist so far (PIQI does not). A step keeps what it finds in one of two places: as a result stored against the
|
|
161
|
+
to the international terminologies; generate the **narrative**; **validate** the document; validate it against the
|
|
162
|
+
**national profiles**; measure its quality with PIQI; check it against the **clinical profiles**; **assess** it with AI. Translation, the narrative,
|
|
163
|
+
validation, the national profiles, the **terminology check**, the clinical profiles check and the AI assessment exist so far (PIQI does not). A step keeps what it finds in one of two places: as a result stored against the
|
|
162
164
|
document in `document_results` (one row per step - the validation outcome), or in the
|
|
163
165
|
**analysed copy** - a second copy of the document (`documents.analysed_json`) that the steps
|
|
164
166
|
adorn with extensions, so that the master document is never changed. The analysed copy is
|
|
@@ -236,7 +238,12 @@ view shows it.
|
|
|
236
238
|
`modules.ips.validator` configures a FHIR validator service, exactly as the SHL module's
|
|
237
239
|
does (`fhir-validator-wrapper`: the validator CLI jar in the data folder's `bin` directory,
|
|
238
240
|
run as a service on its own `port`, with `txServer` - tx.fhir.org, by default - and the IPS
|
|
239
|
-
package loaded).
|
|
241
|
+
package loaded). `jar` names a validator jar of its own instead - a validator built locally from
|
|
242
|
+
org.hl7.fhir.core, say: absolute, or relative to the data folder (`bin/local/validator_cli.jar`) - which
|
|
243
|
+
is used as it is, never downloaded or updated. Keep such a jar out of `bin/validator_cli.jar`: the
|
|
244
|
+
wrapper takes that jar's version from the `.version` file it wrote when it downloaded the release
|
|
245
|
+
there, so a different jar in its place is started as if it were that release (and is replaced by
|
|
246
|
+
the next one). It starts in the background when the module does, and the queue waits for
|
|
240
247
|
it. Each document - its analysed copy, so the translations are seen (the validator will
|
|
241
248
|
remark on the translation extension until it is given its definition) - is validated against the IPS Bundle profile
|
|
242
249
|
(`http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips`; set `profiles` to validate
|
|
@@ -246,6 +253,23 @@ its `validation` view: the counts, each issue's severity, code, location and mes
|
|
|
246
253
|
the raw OperationOutcome. Without a configured, running validator the step fails, and the
|
|
247
254
|
document's status is `error`.
|
|
248
255
|
|
|
256
|
+
### National profile validation
|
|
257
|
+
|
|
258
|
+
The national step runs after validation. It validates the document (the analysed copy, as validation does) against each
|
|
259
|
+
[national profile](#national-profiles) in turn: the national IG's package is loaded into the validator (once - a package
|
|
260
|
+
that has loaded, or has failed to, is remembered until the national profiles are changed), then the document is
|
|
261
|
+
validated against the IG's profile on Bundle, with the same `checkIpsCodes` setting. For each profile the result keeps
|
|
262
|
+
the OperationOutcome, its counts, and whether the document passed - it passes when the validator reports no errors (or
|
|
263
|
+
fatal issues). A profile whose package can't be loaded, or that the validator can't validate against, is kept with the
|
|
264
|
+
reason and counts as not checked; it doesn't stop the pipeline. With no national profiles there is nothing to do, and no
|
|
265
|
+
validator is needed; with some, the step fails, as validation does, without a configured, running validator.
|
|
266
|
+
|
|
267
|
+
The document's **National Profiles** view lists the profiles as they were when the document was processed, with the
|
|
268
|
+
outcome (passed, failed, or not checked) and the issue counts for each; a profile's page shows its validation as the
|
|
269
|
+
validation view shows the base validation (`?view=national&np={n}`). The packages loaded for national profiles stay
|
|
270
|
+
loaded in the validator alongside its own (`packages`), so two national IGs that define the same things differently can
|
|
271
|
+
affect each other.
|
|
272
|
+
|
|
249
273
|
### Terminology check
|
|
250
274
|
|
|
251
275
|
After validation, every CodeableConcept the terminology view lists is checked for its
|
|
@@ -403,12 +427,31 @@ The editor (`/ips/StructureDefinition/new`, or Edit on a profile) is in three pa
|
|
|
403
427
|
- **Rules about resources:** the third shape, each a row of a table with four columns (the type, which is optional, the system, the code, and the rest of the rule as FHIRPath starting with a dot).
|
|
404
428
|
- **Other rules:** any other FHIRPath.
|
|
405
429
|
|
|
430
|
+
The page that shows a profile is the same for everyone. Its first part is a table of three columns: the **Expectation** (the description), the **Section** (an IPS section by its title, or `system|code`) and the **Entry** (`Code is system|code`, or `Code is in` a value set, by its title, or its URL when the title isn't known). A code system is shown by name when it is a known one. A cardinality, `[m..n]`, follows a section or an entry unless it is `1..1` or not given (none means at least one). The second part is also a table of three columns: the **Expectation**, the **Resource Set** (the type, as `MedicationAdministrations where` or `Any resource where`, then the code as in the Entry column) and the **Rule**, which is the rest of the expression in words where it is one of these: `.exists()` is "Must be present somewhere"; `.all(sameSubject())` is "Patient must match the Document"; `.x.exists()` and `.x.y.exists()` are "Must have an x" and "Must have an x.y" (no deeper); `.conformsTo(url)` (or `.all(conformsTo(url))`) is "Conform to" the profile, named by its title when it is one of the clinical profiles or by its jurisdiction and package when it is a national profile; and `.category.hasCoding(system, code [, version])` (or inside `.all()`) is "Has category system|code". Any other rest is shown as FHIRPath, and so are the other rules. On the same page the administrator edits the rules one at a time: each row has Edit and Delete, and each part has an Add button. Add and Edit open a dialog for the one rule - the expectation, then the parts of the rule, each with what it means (for a rule about resources, what must be true of them is a choice: present somewhere, the patient matches the document's, an element, conformance to a profile, a category, or other FHIRPath), and the FHIRPath it makes - and Save stores that one rule by itself (`POST /ips/StructureDefinition/{id}/constraint` to add, `.../constraint/{key}` to change, `.../constraint/{key}/delete` to delete). An edited rule keeps its key. The full editor is still there (Edit all), for the title, or for many changes at once.
|
|
431
|
+
|
|
406
432
|
A rule moves between the parts when it is switched between table and FHIRPath, and it lands in the part whose shape it fits. A section is one of the IPS sections, or any code in any system. An entry or resource code is a code in a named code system (SNOMED CT, LOINC, ICD-10...) or in any other given by URL, and it is looked up on the terminology server (`GET /ips/lookup?system=&q=`, for the administrator only). Choosing "In a value set..." as the system matches entries or resources by value set instead. The code box becomes a value set search (`GET /ips/lookup-vs?q=`, for the administrator only): this module's own value sets that match the title come first, then the terminology server's (a `ValueSet?title=` search). A suggestion shows its title, with its URL underneath. A canonical can also be typed or pasted in, with `|version` to pin a version. When the editor opens, it looks up the title of each value set by URL (`?url=`). If the terminology server doesn't know a value set, it says so as a warning, not an error: the value set has to be known when documents are checked, not when the profile is saved. A cardinality is blank (no bounds) or `min..max`, where max may be `*`. Before a profile is saved, every expression is parsed and run once against an empty document. That catches unknown functions and wrong numbers of arguments as well as syntax errors.
|
|
407
433
|
|
|
408
434
|
A profile can be downloaded as JSON (`?download=1`), and a downloaded profile can be uploaded again as a new profile, with a new id, url and keys. An upload must be no more than a profile like this: a profile on Bundle with no snapshot, and one differential element (the root) carrying constraints that have only a key, an `error` severity, a description, an expression and a source. Anything more is rejected, with the reasons. A profile downloaded when clinical profiles were on Composition is accepted and moved onto Bundle, as profiles already stored are when the server starts: `Composition.section(...)` becomes `Bundle.section(...)`, and any other `Composition.x` becomes `Bundle.entry.resource.ofType(Composition).x`.
|
|
409
435
|
|
|
410
436
|
As FHIR: `GET /ips/StructureDefinition` is a searchset of them all, and `GET /ips/StructureDefinition/{id}` is one of them.
|
|
411
437
|
|
|
438
|
+
## National profiles
|
|
439
|
+
|
|
440
|
+
A national profile is the profile a national implementation guide defines for an IPS document. Each has four parts:
|
|
441
|
+
the jurisdiction it is for, the IG's package id (e.g. `hl7.fhir.au.ips`), the package version, and the canonical URL
|
|
442
|
+
of the IG's profile on Bundle. National profiles are shared across all projects (`/ips/national-profiles`). Everyone can see them; only the
|
|
443
|
+
administrator adds, edits and deletes them. There is at most one for a package id and version. Every document is
|
|
444
|
+
validated against each of them by the [national profiles step](#national-profile-validation).
|
|
445
|
+
|
|
446
|
+
The jurisdiction is a code from `http://hl7.org/fhir/ValueSet/jurisdiction`: a country, by its ISO 3166 two-letter
|
|
447
|
+
code (`AU`; lower case is accepted, and stored in upper case), or a region, by its UN M49 three-digit code (`150` for
|
|
448
|
+
Europe, `053` for Australia and New Zealand). The pages show the country's or region's name with it, from the code
|
|
449
|
+
lists the terminology module holds (`tx/cs/cs-country.js`, `tx/cs/cs-areacode.js`); ISO's user-assigned and 'unknown' codes
|
|
450
|
+
(`XK`, `XX`, `ZZ`...) and countries' M49 numeric codes aren't accepted. National profiles made before the jurisdiction was asked
|
|
451
|
+
for show it as not given, until they are saved with one. The package id must be a FHIR package id (lower case letters, digits, `-` and `_`, in two or more parts separated by
|
|
452
|
+
dots), the version a package version (`1.0.0`, `1.1.0-ballot`), and the profile an http or https URL. `GET
|
|
453
|
+
/ips/national-profiles` with `Accept: application/json` returns them as `[{jurisdiction, packageId, version, url}]`.
|
|
454
|
+
|
|
412
455
|
## FHIRPath functions
|
|
413
456
|
|
|
414
457
|
`ips/fhirpath-ips.js` adds two functions to fhirpath.js, for expressions on IPS documents:
|