fhirsmith 0.17.0 → 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.
Files changed (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. package/registry/readme.md +0 -124
@@ -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
- // 723593002 (precoordinated only) and 723595009 (new precoordinated only) are
26
- // rules about authoring precoordinated concepts and do not constrain expressions.
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 = 723594008n;
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
- * Attributes with no rule in this edition's MRCM are not checked, and neither
1683
- * is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
1684
- * or the range constraint (checkRefinementRange, which needs ECL).
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 (!rules || rules.length === 0 || focus.length === 0) {
1739
- // This edition's concept model says nothing about this attribute (3 active
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
- * Check that an attribute the MRCM marks as grouped is actually in a relationship
1776
- * group. This is only ever called for refinements that are NOT in a 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
- * SNOMED's postcoordination guide calls the ungrouped form Close To User Form and
1786
- * defines a transformation ("Adding a Self-grouped Attribute") that converts it to
1787
- * the Classifiable Form. That transformation is not implemented here, so the
1788
- * ungrouped form is rejected rather than silently repaired: telling the caller their
1789
- * expression is not classifiable is honest, quietly guessing what they meant is not.
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
- * As elsewhere in this check, an attribute with no rule in this edition's MRCM is
1792
- * left alone. Only the rules for the domain(s) the focus concept is in count
1793
- * (see applicableDomainRules), and where several of those apply the strictest wins.
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 - the expression the refinement is on
1796
- * @param {SnomedRefinement} refinement - a refinement outside any group
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
- checkRefinementGrouped(expression, refinement) {
1799
- const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1800
- if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1801
- return;
1802
- }
1803
- const rules = this.applicableDomainRules(expression, attribute).rules;
1804
- if (rules.length === 0) {
1805
- return;
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
- if (!rules.some(rule => rule.grouped)) {
1808
- return;
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
- const attrDesc = this.describeConceptForMessage(attribute);
1811
- throw new Error(`The SNOMED CT concept model requires the attribute ${attrDesc} to be in a`
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 723594008) that one replaces the general "all content" range
2026
- * rather than adding to it - 11 attributes internationally do this. After that
2027
- * rule every attribute has exactly one applicable constraint, so there is no
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 ${concept.code} is not valid in this context (must be a ${limits[0]})`);
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 ${concept.code} is not valid in this context (must be a descendent of one of ${limits})`);
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
- this.checkConcept(refinement.name, ['410662002', '106237007']);
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, '&amp;')
36
+ .replace(/</g, '&lt;')
37
+ .replace(/>/g, '&gt;')
38
+ .replace(/"/g, '&quot;')
39
+ .replace(/'/g, '&apos;');
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 };