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
package/tx/sct/expressions.js
CHANGED
|
@@ -22,10 +22,14 @@ const NO_REFERENCE = 0xFFFFFFFF;
|
|
|
22
22
|
const MRCM_ATTRIBUTE_DOMAIN_REFSET = 723561005n;
|
|
23
23
|
const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
|
|
24
24
|
// contentTypeId values that apply when validating a postcoordinated expression.
|
|
25
|
-
//
|
|
26
|
-
// rules about authoring precoordinated concepts and do not constrain
|
|
25
|
+
// 723594008 (all precoordinated content) and 723593002 (all new precoordinated
|
|
26
|
+
// content) are rules about authoring precoordinated concepts and do not constrain
|
|
27
|
+
// expressions. (723594008 was used here as the postcoordinated type until Oct 2026,
|
|
28
|
+
// which brought the body-structure part-of attributes into the domain rules and
|
|
29
|
+
// dropped |Has ingredient|, and swapped the range used for the attributes that
|
|
30
|
+
// have a precoordinated-specific range.)
|
|
27
31
|
const MRCM_CONTENT_TYPE_ALL = 723596005n;
|
|
28
|
-
const MRCM_CONTENT_TYPE_POSTCOORDINATED =
|
|
32
|
+
const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723595009n;
|
|
29
33
|
// ruleStrengthId value for an optional rule (the other value, 723597001, is mandatory).
|
|
30
34
|
const MRCM_RULE_STRENGTH_OPTIONAL = 723598006n;
|
|
31
35
|
|
|
@@ -1657,8 +1661,8 @@ class SnomedExpressionServices {
|
|
|
1657
1661
|
this.checkRefinement(refinement);
|
|
1658
1662
|
this.checkRefinementDomain(expression, refinement);
|
|
1659
1663
|
this.checkRefinementRange(refinement);
|
|
1660
|
-
this.checkRefinementGrouped(expression, refinement);
|
|
1661
1664
|
}
|
|
1665
|
+
this.selfGroupRefinements(expression);
|
|
1662
1666
|
}
|
|
1663
1667
|
|
|
1664
1668
|
if (expression.hasRefinementGroups()) {
|
|
@@ -1679,9 +1683,10 @@ class SnomedExpressionServices {
|
|
|
1679
1683
|
* focus concept the attribute is applied to must be in one of the attribute's
|
|
1680
1684
|
* domains (see applicableDomainRules for how several rules combine).
|
|
1681
1685
|
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1686
|
+
* An attribute with no rule in this edition's MRCM is not allowed at all (see
|
|
1687
|
+
* applicableDomainRules). Cardinality (checkExpressionCardinality), grouping
|
|
1688
|
+
* (selfGroupRefinements) and the range constraint (checkRefinementRange, which
|
|
1689
|
+
* needs ECL) are checked elsewhere.
|
|
1685
1690
|
*
|
|
1686
1691
|
* @param {SnomedExpression} expression - the expression the refinement is on
|
|
1687
1692
|
* @param {SnomedRefinement} refinement
|
|
@@ -1692,6 +1697,11 @@ class SnomedExpressionServices {
|
|
|
1692
1697
|
return;
|
|
1693
1698
|
}
|
|
1694
1699
|
const result = this.applicableDomainRules(expression, attribute);
|
|
1700
|
+
if (result.noDomain) {
|
|
1701
|
+
const attrDesc = this.describeConceptForMessage(attribute);
|
|
1702
|
+
throw new Error(`The SNOMED CT concept model does not define any domain for the attribute ${attrDesc},`
|
|
1703
|
+
+ ` so it cannot be used in an expression`);
|
|
1704
|
+
}
|
|
1695
1705
|
if (result.failed) {
|
|
1696
1706
|
const attrDesc = this.describeConceptForMessage(attribute);
|
|
1697
1707
|
const focusDesc = result.focus.map(f => this.describeConceptForMessage(f)).join(' + ');
|
|
@@ -1722,10 +1732,11 @@ class SnomedExpressionServices {
|
|
|
1722
1732
|
*
|
|
1723
1733
|
* @param {SnomedExpression} expression
|
|
1724
1734
|
* @param {number} attribute - concept index of the attribute
|
|
1725
|
-
* @returns {{rules: Array, focus: number[], failed: string|null}} the rules that
|
|
1735
|
+
* @returns {{rules: Array, focus: number[], failed: string|null, noDomain?: boolean}} the rules that
|
|
1726
1736
|
* apply (for cardinality and grouping - empty when the attribute is out of
|
|
1727
1737
|
* domain), the focus concepts, and when the attribute is not allowed on this
|
|
1728
|
-
* focus, a description of the domain(s) it is restricted to
|
|
1738
|
+
* focus, a description of the domain(s) it is restricted to (noDomain is set
|
|
1739
|
+
* when there are none at all)
|
|
1729
1740
|
*/
|
|
1730
1741
|
applicableDomainRules(expression, attribute) {
|
|
1731
1742
|
const rules = this.mrcmAttributeDomains().get(attribute);
|
|
@@ -1735,12 +1746,21 @@ class SnomedExpressionServices {
|
|
|
1735
1746
|
focus.push(concept.reference);
|
|
1736
1747
|
}
|
|
1737
1748
|
}
|
|
1738
|
-
if (
|
|
1739
|
-
//
|
|
1740
|
-
// attributes internationally have no domain rule and appear in no
|
|
1741
|
-
// postcoordination domain template), so there is nothing to check it against.
|
|
1749
|
+
if (this.mrcmAttributeDomains().size === 0 || focus.length === 0) {
|
|
1750
|
+
// No MRCM in this edition (or nothing to check against), so the domain check is off
|
|
1742
1751
|
return { rules: rules || [], focus, failed: null };
|
|
1743
1752
|
}
|
|
1753
|
+
if (!rules || rules.length === 0) {
|
|
1754
|
+
// The MRCM attribute domain reference set is the list of attributes that may
|
|
1755
|
+
// be used, and in which domains: an attribute it says nothing about (for
|
|
1756
|
+
// postcoordinated content) is not allowed anywhere. Internationally that is
|
|
1757
|
+
// a handful of concept model attributes that only appear in precoordinated
|
|
1758
|
+
// definitions (|Has definitional manifestation|, |Subject of information|,
|
|
1759
|
+
// the body structure part-of attributes), and it also catches the linkage
|
|
1760
|
+
// concepts that are not concept model attributes at all. This matches
|
|
1761
|
+
// Ontoserver (Oct 2026); before that an attribute with no rule was not checked.
|
|
1762
|
+
return { rules: [], focus, failed: 'nowhere', noDomain: true };
|
|
1763
|
+
}
|
|
1744
1764
|
|
|
1745
1765
|
const holds = rule => focus.some(f => rule.isRefset
|
|
1746
1766
|
? this.conceptInRefSet(rule.domain, f)
|
|
@@ -1772,44 +1792,77 @@ class SnomedExpressionServices {
|
|
|
1772
1792
|
}
|
|
1773
1793
|
|
|
1774
1794
|
/**
|
|
1775
|
-
*
|
|
1776
|
-
* group
|
|
1777
|
-
*
|
|
1778
|
-
* The MRCM attribute domain reference set carries a `grouped` flag per rule, and it
|
|
1779
|
-
* is a real constraint rather than a stylistic preference: `363698007 |Finding site|`
|
|
1780
|
-
* is grouped, so `40468003:363698007=10200004` is not a valid expression even though
|
|
1781
|
-
* every part of it is - it has to be written `40468003:{363698007=10200004}`. The
|
|
1782
|
-
* distinction matters because a group is what ties an attribute to the others that
|
|
1783
|
-
* apply with it, and an expression that omits the braces has not said which.
|
|
1795
|
+
* Move each ungrouped refinement whose attribute the MRCM marks as grouped into a
|
|
1796
|
+
* relationship group of its own.
|
|
1784
1797
|
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
1798
|
+
* The MRCM attribute domain reference set carries a `grouped` flag per rule:
|
|
1799
|
+
* `363698007 |Finding site|` is grouped, so the classifiable form of
|
|
1800
|
+
* `40468003:363698007=10200004` is `40468003:{363698007=10200004}`. SNOMED's
|
|
1801
|
+
* postcoordination guide calls the ungrouped spelling Close To User Form and
|
|
1802
|
+
* defines the transformation to the Classifiable Form ("Adding a Self-grouped
|
|
1803
|
+
* Attribute"): each such attribute is placed in a group by itself. That is what
|
|
1804
|
+
* this does, so the ungrouped spelling is accepted (as Ontoserver and Snowstorm
|
|
1805
|
+
* accept it) and everything downstream - cardinality, rendering, the normal form
|
|
1806
|
+
* and so subsumption - sees the grouped form. Until Oct 2026 the ungrouped
|
|
1807
|
+
* spelling was rejected instead.
|
|
1790
1808
|
*
|
|
1791
|
-
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1809
|
+
* Only the rules for the domain(s) the focus concept is in count (see
|
|
1810
|
+
* applicableDomainRules); an attribute that is ungrouped in every applicable
|
|
1811
|
+
* rule, or that has no applicable rule, is left where it is.
|
|
1794
1812
|
*
|
|
1795
|
-
* @param {SnomedExpression} expression -
|
|
1796
|
-
*
|
|
1813
|
+
* @param {SnomedExpression} expression - an expression with ungrouped refinements,
|
|
1814
|
+
* whose references have already been resolved and whose domains have been checked
|
|
1797
1815
|
*/
|
|
1798
|
-
|
|
1799
|
-
const
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1816
|
+
selfGroupRefinements(expression) {
|
|
1817
|
+
const keep = [];
|
|
1818
|
+
for (const refinement of expression.refinements) {
|
|
1819
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1820
|
+
let grouped = false;
|
|
1821
|
+
if (attribute !== undefined && attribute !== null && attribute !== NO_REFERENCE) {
|
|
1822
|
+
const rules = this.applicableDomainRules(expression, attribute).rules;
|
|
1823
|
+
grouped = rules.some(rule => rule.grouped);
|
|
1824
|
+
}
|
|
1825
|
+
if (grouped) {
|
|
1826
|
+
const group = new SnomedRefinementGroup();
|
|
1827
|
+
group.refinements.push(refinement);
|
|
1828
|
+
expression.refinementGroups.push(group);
|
|
1829
|
+
} else {
|
|
1830
|
+
keep.push(refinement);
|
|
1831
|
+
}
|
|
1806
1832
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1833
|
+
expression.refinements = keep;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* The inverse of selfGroupRefinements, for rendering an expression for a user: a
|
|
1838
|
+
* group holding a single refinement whose attribute the MRCM never groups (laterality,
|
|
1839
|
+
* say) is written as an ungrouped refinement, which is how the normal form rules put
|
|
1840
|
+
* it. The canonical form groups every refinement (see SnomedExpression.canonical), which
|
|
1841
|
+
* is right for subsumption but reads oddly as output. An attribute with any applicable
|
|
1842
|
+
* grouped rule, or no rule at all, stays in its group.
|
|
1843
|
+
*
|
|
1844
|
+
* @param {SnomedExpression} expression - with its references resolved; modified in place
|
|
1845
|
+
* @returns {SnomedExpression} the same expression
|
|
1846
|
+
*/
|
|
1847
|
+
ungroupNeverGroupedRefinements(expression) {
|
|
1848
|
+
const keep = [];
|
|
1849
|
+
for (const group of expression.refinementGroups) {
|
|
1850
|
+
let ungroup = false;
|
|
1851
|
+
if (group.refinements.length === 1) {
|
|
1852
|
+
const attribute = group.refinements[0].name ? group.refinements[0].name.reference : NO_REFERENCE;
|
|
1853
|
+
if (attribute !== undefined && attribute !== null && attribute !== NO_REFERENCE) {
|
|
1854
|
+
const rules = this.applicableDomainRules(expression, attribute).rules;
|
|
1855
|
+
ungroup = rules.length > 0 && rules.every(rule => !rule.grouped);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (ungroup) {
|
|
1859
|
+
expression.refinements.push(group.refinements[0]);
|
|
1860
|
+
} else {
|
|
1861
|
+
keep.push(group);
|
|
1862
|
+
}
|
|
1809
1863
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
+ ` relationship group, so it cannot be used ungrouped (write it as {${attrDesc} = ...})`);
|
|
1864
|
+
expression.refinementGroups = keep;
|
|
1865
|
+
return expression;
|
|
1813
1866
|
}
|
|
1814
1867
|
|
|
1815
1868
|
/**
|
|
@@ -2022,10 +2075,9 @@ class SnomedExpressionServices {
|
|
|
2022
2075
|
* rangeConstraint ECL string.
|
|
2023
2076
|
*
|
|
2024
2077
|
* Where an attribute has a range specifically for postcoordinated content
|
|
2025
|
-
* (contentTypeId
|
|
2026
|
-
* rather than adding to it
|
|
2027
|
-
*
|
|
2028
|
-
* question of ANDing or ORing them.
|
|
2078
|
+
* (contentTypeId 723595009) that one replaces the general "all content" range
|
|
2079
|
+
* rather than adding to it. After that rule every attribute has exactly one
|
|
2080
|
+
* applicable constraint, so there is no question of ANDing or ORing them.
|
|
2029
2081
|
*
|
|
2030
2082
|
* @returns {Map<number, string>}
|
|
2031
2083
|
*/
|
|
@@ -2260,10 +2312,15 @@ class SnomedExpressionServices {
|
|
|
2260
2312
|
}
|
|
2261
2313
|
}
|
|
2262
2314
|
if (!ok) {
|
|
2315
|
+
const desc = this.describeConceptForMessage(concept.reference);
|
|
2316
|
+
const limitDesc = limits.map(l => {
|
|
2317
|
+
const ix = this.conceptIndexOf(BigInt(l));
|
|
2318
|
+
return ix === null ? l : this.describeConceptForMessage(ix);
|
|
2319
|
+
});
|
|
2263
2320
|
if (limits.length == 1) {
|
|
2264
|
-
throw new Error(`Concept ${
|
|
2321
|
+
throw new Error(`Concept ${desc} is not valid in this context (must be a descendant of ${limitDesc[0]})`);
|
|
2265
2322
|
} else {
|
|
2266
|
-
throw new Error(`Concept ${
|
|
2323
|
+
throw new Error(`Concept ${desc} is not valid in this context (must be a descendant of one of ${limitDesc.join(', ')})`);
|
|
2267
2324
|
}
|
|
2268
2325
|
}
|
|
2269
2326
|
}
|
|
@@ -2420,7 +2477,11 @@ class SnomedExpressionServices {
|
|
|
2420
2477
|
* Validate refinement
|
|
2421
2478
|
*/
|
|
2422
2479
|
checkRefinement(refinement) {
|
|
2423
|
-
|
|
2480
|
+
// Only concept model attributes (410662002) may be used as attributes. The wider
|
|
2481
|
+
// 106237007 |Linkage concept| used to be allowed too, which let in things like
|
|
2482
|
+
// 20401003 |With| and 280452008 |Interpretation| that are not attributes in the
|
|
2483
|
+
// concept model (and could never have an MRCM rule anyway).
|
|
2484
|
+
this.checkConcept(refinement.name, ['410662002']);
|
|
2424
2485
|
this.checkExpression(refinement.value);
|
|
2425
2486
|
}
|
|
2426
2487
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the syndication store as an Atom feed for one tx endpoint, following the
|
|
3
|
+
* Terminology Syndication Feed IG (NCTS ASF profile), with RFC 5005 archived feeds for
|
|
4
|
+
* paging:
|
|
5
|
+
*
|
|
6
|
+
* {endpoint}/syndication the current document - the newest entries, fewer
|
|
7
|
+
* than a page of them - linked to the newest archive
|
|
8
|
+
* {endpoint}/syndication/archive/n archive n (1 = oldest), exactly PAGE_SIZE entries,
|
|
9
|
+
* linked to archive n-1. Entries are never changed or
|
|
10
|
+
* removed, so an archive never changes once it exists
|
|
11
|
+
* {endpoint}/syndication/files/x a resource file copied into the store
|
|
12
|
+
*
|
|
13
|
+
* The IG's query-string filters are not supported.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const { VersionUtilities } = require('../../library/version-utilities');
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 30;
|
|
20
|
+
const ATOM_NS = 'http://www.w3.org/2005/Atom';
|
|
21
|
+
const NCTS_NS = 'http://ns.electronichealth.net.au/ncts/syndication/asf/extensions/1.0.0';
|
|
22
|
+
const FH_NS = 'http://purl.org/syndication/history/1.0';
|
|
23
|
+
const ASF_PROFILE = 'http://ns.electronichealth.net.au/ncts/syndication/asf/profile/1.0.0';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Escape for XML and HTML. Characters XML 1.0 does not allow at all (C0 controls other than
|
|
27
|
+
* tab/newline/return, unpaired surrogates, U+FFFE/U+FFFF) are dropped: they come from package
|
|
28
|
+
* and resource metadata, and one of them would make the document unparseable - for good, in
|
|
29
|
+
* the case of an archive, which never changes.
|
|
30
|
+
*/
|
|
31
|
+
function esc(s) {
|
|
32
|
+
return String(s ?? '')
|
|
33
|
+
// eslint-disable-next-line no-control-regex
|
|
34
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '')
|
|
35
|
+
.replace(/&/g, '&')
|
|
36
|
+
.replace(/</g, '<')
|
|
37
|
+
.replace(/>/g, '>')
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/'/g, ''');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** major.minor for an endpoint's FHIR version */
|
|
43
|
+
function feedFhirVersion(fhirVersion) {
|
|
44
|
+
return VersionUtilities.getMajMin(String(fhirVersion));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The feed id for an endpoint: stable for as long as the store exists, whatever host name
|
|
49
|
+
* the feed is read through (RFC 4287 4.2.6). The archive documents share it (RFC 5005).
|
|
50
|
+
*/
|
|
51
|
+
function feedId(storeId, endpointPath) {
|
|
52
|
+
const h = crypto.createHash('sha1').update(storeId + '|' + endpointPath).digest('hex');
|
|
53
|
+
return `urn:uuid:${h.substring(0, 8)}-${h.substring(8, 12)}-5${h.substring(13, 16)}-` +
|
|
54
|
+
`${((parseInt(h.substring(16, 18), 16) & 0x3f) | 0x80).toString(16)}${h.substring(18, 20)}-${h.substring(20, 32)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which entries go in which document.
|
|
59
|
+
* @param {Object[]} entries - the endpoint's entries, oldest first
|
|
60
|
+
* @param {number|null} page - archive number, or null for the current document
|
|
61
|
+
* @returns {{archives: number, entries: Object[]}|null} null if there is no such archive
|
|
62
|
+
*/
|
|
63
|
+
function selectPage(entries, page) {
|
|
64
|
+
const archives = Math.floor(entries.length / PAGE_SIZE);
|
|
65
|
+
if (page == null) {
|
|
66
|
+
return { archives, entries: entries.slice(archives * PAGE_SIZE) };
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(page) || page < 1 || page > archives) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return { archives, entries: entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function linkHref(entry, base) {
|
|
75
|
+
return entry.link.href || `${base}/syndication/files/${entry.link.file}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isFhirCategory(entry) {
|
|
79
|
+
return entry.category.term.startsWith('FHIR_');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderEntry(entry, base, fhirVersion) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
lines.push(' <entry>');
|
|
85
|
+
lines.push(` <id>${esc(entry.id)}</id>`);
|
|
86
|
+
lines.push(` <title>${esc(entry.title)}</title>`);
|
|
87
|
+
lines.push(` <updated>${esc(entry.inducted)}</updated>`);
|
|
88
|
+
lines.push(` <published>${esc(entry.inducted)}</published>`);
|
|
89
|
+
if (entry.summary) {
|
|
90
|
+
lines.push(` <summary>${esc(entry.summary)}</summary>`);
|
|
91
|
+
}
|
|
92
|
+
if (entry.rights) {
|
|
93
|
+
lines.push(` <rights>${esc(entry.rights)}</rights>`);
|
|
94
|
+
}
|
|
95
|
+
const c = entry.category;
|
|
96
|
+
lines.push(` <category scheme="${esc(c.scheme)}" term="${esc(c.term)}"${c.label ? ` label="${esc(c.label)}"` : ''}/>`);
|
|
97
|
+
const l = entry.link;
|
|
98
|
+
lines.push(` <link rel="alternate" href="${esc(linkHref(entry, base))}" type="${esc(l.type)}" length="${l.length}" ncts:sha256Hash="${esc(l.sha256)}"/>`);
|
|
99
|
+
lines.push(` <ncts:contentItemIdentifier>${esc(entry.identifier)}</ncts:contentItemIdentifier>`);
|
|
100
|
+
lines.push(` <ncts:contentItemVersion>${esc(entry.version)}</ncts:contentItemVersion>`);
|
|
101
|
+
if (isFhirCategory(entry)) {
|
|
102
|
+
lines.push(` <ncts:fhirVersion>${esc(entry.fhirVersion || fhirVersion)}</ncts:fhirVersion>`);
|
|
103
|
+
}
|
|
104
|
+
lines.push(' </entry>');
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {Object} params
|
|
110
|
+
* @param {SyndicationStore} params.store
|
|
111
|
+
* @param {string} params.endpointPath - e.g. /tx/r4
|
|
112
|
+
* @param {string} params.fhirVersion - the endpoint's FHIR version
|
|
113
|
+
* @param {string} params.base - absolute url of the endpoint, e.g. https://tx.fhir.org/tx/r4
|
|
114
|
+
* @param {number|null} params.page - archive number, or null for the current document
|
|
115
|
+
* @param {string} [params.title] - server title
|
|
116
|
+
* @param {string} [params.serverVersion]
|
|
117
|
+
* @returns {string|null} the Atom document, or null if there is no such archive
|
|
118
|
+
*/
|
|
119
|
+
function renderAtom(params) {
|
|
120
|
+
const fhirVersion = feedFhirVersion(params.fhirVersion);
|
|
121
|
+
const all = params.store.entriesFor(fhirVersion);
|
|
122
|
+
const sel = selectPage(all, params.page);
|
|
123
|
+
if (!sel) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const feedUrl = `${params.base}/syndication`;
|
|
127
|
+
const archiveUrl = (n) => `${feedUrl}/archive/${n}`;
|
|
128
|
+
const self = params.page == null ? feedUrl : archiveUrl(params.page);
|
|
129
|
+
// the newest entry in this document; an empty one dates from when the store was started
|
|
130
|
+
const updated = sel.entries.length > 0 ? sel.entries[sel.entries.length - 1].inducted : params.store.created;
|
|
131
|
+
|
|
132
|
+
const out = [];
|
|
133
|
+
out.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
134
|
+
out.push(`<feed xmlns="${ATOM_NS}" xmlns:ncts="${NCTS_NS}" xmlns:fh="${FH_NS}">`);
|
|
135
|
+
out.push(` <id>${esc(feedId(params.store.id, params.endpointPath))}</id>`);
|
|
136
|
+
out.push(` <title>${esc((params.title || 'FHIRsmith') + ' Syndication Feed (FHIR ' + fhirVersion + ')')}</title>`);
|
|
137
|
+
out.push(` <subtitle>Content loaded by the terminology server at ${esc(params.base)}</subtitle>`);
|
|
138
|
+
out.push(` <updated>${esc(updated)}</updated>`);
|
|
139
|
+
out.push(` <generator uri="https://github.com/HealthIntersections/FHIRsmith"${params.serverVersion ? ` version="${esc(params.serverVersion)}"` : ''}>FHIRsmith</generator>`);
|
|
140
|
+
out.push(` <link rel="self" type="application/atom+xml" href="${esc(self)}"/>`);
|
|
141
|
+
out.push(` <link rel="current" type="application/atom+xml" href="${esc(feedUrl)}"/>`);
|
|
142
|
+
if (params.page == null) {
|
|
143
|
+
if (sel.archives > 0) {
|
|
144
|
+
out.push(` <link rel="prev-archive" type="application/atom+xml" href="${esc(archiveUrl(sel.archives))}"/>`);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
out.push(' <fh:archive/>');
|
|
148
|
+
if (params.page > 1) {
|
|
149
|
+
out.push(` <link rel="prev-archive" type="application/atom+xml" href="${esc(archiveUrl(params.page - 1))}"/>`);
|
|
150
|
+
}
|
|
151
|
+
// no next-archive: it would change the newest archive when the next one is made, and an
|
|
152
|
+
// archive never changes (RFC 5005 makes it optional; consumers walk back from current)
|
|
153
|
+
}
|
|
154
|
+
out.push(` <ncts:atomSyndicationFormatProfile>${ASF_PROFILE}</ncts:atomSyndicationFormatProfile>`);
|
|
155
|
+
// newest first; consumers go by <updated>, not order
|
|
156
|
+
for (const entry of [...sel.entries].reverse()) {
|
|
157
|
+
out.push(renderEntry(entry, params.base, fhirVersion));
|
|
158
|
+
}
|
|
159
|
+
out.push('</feed>');
|
|
160
|
+
return out.join('\n') + '\n';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The same document, for people: a table of entries with links to the other documents.
|
|
165
|
+
* Takes the same parameters as renderAtom; returns HTML content (no page), or null.
|
|
166
|
+
*/
|
|
167
|
+
function renderHtml(params) {
|
|
168
|
+
const fhirVersion = feedFhirVersion(params.fhirVersion);
|
|
169
|
+
const all = params.store.entriesFor(fhirVersion);
|
|
170
|
+
const sel = selectPage(all, params.page);
|
|
171
|
+
if (!sel) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const feedUrl = `${params.base}/syndication`;
|
|
175
|
+
const atomUrl = params.page == null ? feedUrl : `${feedUrl}/archive/${params.page}`;
|
|
176
|
+
const h = [];
|
|
177
|
+
h.push('<p>The content this server has loaded, as a <a href="https://build.fhir.org/ig/FHIR/fhir-syndication-ig/en/">Terminology Syndication Feed</a>. ');
|
|
178
|
+
h.push(`Subscribe to <a href="${esc(atomUrl)}?_format=atom">${esc(feedUrl)}</a> in a feed reader or syndication client. `);
|
|
179
|
+
h.push(`${all.length} ${all.length === 1 ? 'entry' : 'entries'} in all.</p>`);
|
|
180
|
+
|
|
181
|
+
const nav = [];
|
|
182
|
+
if (params.page == null) {
|
|
183
|
+
if (sel.archives > 0) {
|
|
184
|
+
nav.push(`<a href="${esc(feedUrl)}/archive/${sel.archives}">Older</a>`);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
if (params.page > 1) {
|
|
188
|
+
nav.push(`<a href="${esc(feedUrl)}/archive/${params.page - 1}">Older</a>`);
|
|
189
|
+
}
|
|
190
|
+
nav.push(params.page < sel.archives
|
|
191
|
+
? `<a href="${esc(feedUrl)}/archive/${params.page + 1}">Newer</a>`
|
|
192
|
+
: `<a href="${esc(feedUrl)}">Newer</a>`);
|
|
193
|
+
nav.push(`<a href="${esc(feedUrl)}">Newest</a>`);
|
|
194
|
+
}
|
|
195
|
+
const navHtml = nav.length > 0 ? `<p>${nav.join(' | ')}</p>` : '';
|
|
196
|
+
h.push(navHtml);
|
|
197
|
+
|
|
198
|
+
if (sel.entries.length === 0) {
|
|
199
|
+
h.push('<p><i>No entries</i></p>');
|
|
200
|
+
} else {
|
|
201
|
+
h.push('<table class="grid">');
|
|
202
|
+
h.push('<tr><th>Title</th><th>Type</th><th>Version</th><th>Added</th><th>Download</th></tr>');
|
|
203
|
+
for (const e of [...sel.entries].reverse()) {
|
|
204
|
+
h.push('<tr>');
|
|
205
|
+
h.push(`<td>${esc(e.title)}${e.summary ? `<br/><span style="color: grey">${esc(e.summary)}</span>` : ''}${e.rights ? `<br/><i>${esc(e.rights)}</i>` : ''}</td>`);
|
|
206
|
+
h.push(`<td>${esc(e.category.term)}${isFhirCategory(e) ? ` (R${esc((e.fhirVersion || fhirVersion).split('.')[0])})` : ''}</td>`);
|
|
207
|
+
h.push(`<td><code>${esc(e.version)}</code></td>`);
|
|
208
|
+
h.push(`<td>${esc(e.inducted.substring(0, 10))}</td>`);
|
|
209
|
+
h.push(`<td><a href="${esc(linkHref(e, params.base))}">${esc(e.link.length.toLocaleString())} bytes</a><br/><span style="font-size: 0.8em; color: grey">sha256: ${esc(e.link.sha256)}</span></td>`);
|
|
210
|
+
h.push('</tr>');
|
|
211
|
+
}
|
|
212
|
+
h.push('</table>');
|
|
213
|
+
}
|
|
214
|
+
h.push(navHtml);
|
|
215
|
+
return h.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { renderAtom, renderHtml, selectPage, feedId, feedFhirVersion, esc, PAGE_SIZE };
|