fhirsmith 0.13.1 → 0.13.3
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 +43 -0
- package/config-template.json +13 -1
- package/library/html-server.js +3 -1
- package/package.json +3 -3
- package/publisher/publisher.js +9 -2
- package/translations/Messages.properties +1 -0
- package/tx/README.md +22 -0
- package/tx/cs/cs-mimetypes.js +198 -12
- package/tx/cs/cs-snomed.js +103 -41
- package/tx/html/tx-template.html +1 -0
- package/tx/sct/expressions.js +34 -9
- package/tx/tx-html.js +107 -3
- package/tx/tx.js +44 -0
- package/tx/workers/validate.js +36 -13
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [v0.13.3] - 2026-09-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
|
|
13
|
+
language reference set for only six editions (International, US, UK and the test edition);
|
|
14
|
+
every other edition fell through to "the first description marked Preferred in any language
|
|
15
|
+
reference set", which on a multi-language edition means whichever description happened to be
|
|
16
|
+
stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
|
|
17
|
+
(21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
|
|
18
|
+
no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
|
|
19
|
+
Editions not in the table now default to US then GB English, and the $lookup designation
|
|
20
|
+
order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
|
|
21
|
+
no longer depends on import order. An edition with no English language reference set at all
|
|
22
|
+
still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
|
|
23
|
+
`bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
|
|
24
|
+
is the GB English preferred term picked only because it is stored before the US one - the same
|
|
25
|
+
concept already expanded as "Counseling". The expectation is now "Counseling".
|
|
26
|
+
- Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
|
|
27
|
+
while its value came from the edition display, so on a multi-language edition a French or
|
|
28
|
+
Dutch term was published as English - which put it in an implementation guide's English
|
|
29
|
+
display column and made the real French designation read as a duplicate of it.
|
|
30
|
+
|
|
31
|
+
### Tx Conformance Statement
|
|
32
|
+
|
|
33
|
+
FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4
|
|
34
|
+
|
|
35
|
+
## [v0.13.2] - 2026-09-05
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Add support for publishLibrarySource
|
|
40
|
+
-
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Fix error in Snomed CT and ICD-11 where normal form difference and case difference being conflated
|
|
44
|
+
- Fix error in mime-type subsumption analysis
|
|
45
|
+
- adjust mime type validation to not be case sensitive
|
|
46
|
+
|
|
47
|
+
### Tx Conformance Statement
|
|
48
|
+
|
|
49
|
+
FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4)
|
|
50
|
+
|
|
8
51
|
## [v0.13.1] - 2026-09-05
|
|
9
52
|
|
|
10
53
|
### Fixed
|
package/config-template.json
CHANGED
|
@@ -94,7 +94,14 @@
|
|
|
94
94
|
// (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
|
|
95
95
|
// Omit to leave them in place - the push will then fail. Relative paths resolve under the
|
|
96
96
|
// publisher data folder
|
|
97
|
-
"large-file-archive": "/absolute/path/to/large-file-archive"
|
|
97
|
+
"large-file-archive": "/absolute/path/to/large-file-archive",
|
|
98
|
+
// where to clone the IG registry from. Omit for the default,
|
|
99
|
+
// git@github.com:FHIR/ig-registry.git. Set this to an ssh_config Host alias when the
|
|
100
|
+
// server should push the registry with a repo-scoped deploy key instead of an account
|
|
101
|
+
// key - e.g. "git@github-ig-registry:FHIR/ig-registry.git", with a matching Host block
|
|
102
|
+
// in the service user's ~/.ssh/config carrying IdentityFile and IdentitiesOnly yes.
|
|
103
|
+
// The alias is what lets ssh tell this remote apart from the website remote.
|
|
104
|
+
"ig-registry-url": "git@github.com:FHIR/ig-registry.git"
|
|
98
105
|
},
|
|
99
106
|
"npmprojector": {
|
|
100
107
|
"enabled": true,
|
|
@@ -150,6 +157,11 @@
|
|
|
150
157
|
"tx": {
|
|
151
158
|
"enabled": false,
|
|
152
159
|
"librarySource": "/absolute/path/to/library.yml",
|
|
160
|
+
// publish the library source above at <endpoint>/library, so users can see what this
|
|
161
|
+
// server actually loads. One URL, content negotiated: a browser gets a formatted page
|
|
162
|
+
// (and a "Library" item in the tx nav bar), anything else gets the YAML file itself.
|
|
163
|
+
// Off by default - the file names every database, cache and package the server runs.
|
|
164
|
+
"publishLibrarySource": false,
|
|
153
165
|
"cacheTimeout": 30,
|
|
154
166
|
"expansionCacheSize": 1000,
|
|
155
167
|
"expansionCacheMemoryThreshold": 0,
|
package/library/html-server.js
CHANGED
|
@@ -80,7 +80,9 @@ class HtmlServer {
|
|
|
80
80
|
.replace(/\[%fhir-version%\]/g, escape(renderOptions.fhirversion))
|
|
81
81
|
.replace(/\[%ms%\]/g, escape(renderOptions.processingTime.toString()))
|
|
82
82
|
.replace(/\[%sponsorMessage%\]/g, sponsorMessage)
|
|
83
|
-
.replace(/\[%about%\]/g, renderOptions.about || '')
|
|
83
|
+
.replace(/\[%about%\]/g, renderOptions.about || '')
|
|
84
|
+
// raw HTML, like [%about%]: the caller decides whether the nav item exists at all
|
|
85
|
+
.replace(/\[%library-link%\]/g, renderOptions.libraryLink || '');
|
|
84
86
|
|
|
85
87
|
// Handle any custom template variables
|
|
86
88
|
if (options.templateVars) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.13.
|
|
4
|
-
"txVersion": "1.9.
|
|
3
|
+
"version": "0.13.3",
|
|
4
|
+
"txVersion": "1.9.5-SNAPSHOT",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"engines": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"express-rate-limit": "^7.4.1",
|
|
47
47
|
"express-session": "^1.19.0",
|
|
48
48
|
"fast-xml-parser": "^5.3.4",
|
|
49
|
-
"fhir-validator-wrapper": "1.
|
|
49
|
+
"fhir-validator-wrapper": "1.4.0",
|
|
50
50
|
"fhirpath": "^4.8.3",
|
|
51
51
|
"fs-extra": "^11.3.3",
|
|
52
52
|
"ini": "^6.0.0",
|
package/publisher/publisher.js
CHANGED
|
@@ -1117,8 +1117,15 @@ class PublisherModule {
|
|
|
1117
1117
|
// Step 1: Clone supporting repositories into the task directory
|
|
1118
1118
|
const registryDir = path.join(taskDir, 'ig-registry');
|
|
1119
1119
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1120
|
+
// The URL is configurable so that the credential arrangement can live in server config
|
|
1121
|
+
// rather than here. A deployment that pushes ig-registry with a repo-scoped deploy key
|
|
1122
|
+
// rather than an account key points this at an ssh_config Host alias carrying that key,
|
|
1123
|
+
// e.g. git@github-ig-registry:FHIR/ig-registry.git - the alias is the only way ssh can
|
|
1124
|
+
// tell this remote apart from the website remote, both being github.com.
|
|
1125
|
+
const igRegistryUrl = this.config['ig-registry-url'] || 'git@github.com:FHIR/ig-registry.git';
|
|
1126
|
+
|
|
1127
|
+
await this.runCommand('git', ['clone', igRegistryUrl, registryDir],
|
|
1128
|
+
{}, task.id, 'Cloning ig-registry from ' + igRegistryUrl);
|
|
1122
1129
|
|
|
1123
1130
|
// Use website-configured history templates path if provided, otherwise clone the default repo
|
|
1124
1131
|
let historyDir;
|
|
@@ -97,6 +97,7 @@ CDA_UNKNOWN_TEMPLATE_EXT = The CDA Template {0} / {1} is not known
|
|
|
97
97
|
CDATA_is_not_allowed = CDATA is not allowed
|
|
98
98
|
Circular_snapshot_references_detected_cannot_generate_snapshot_stack__ = Circular snapshot references detected; cannot generate snapshot (stack = {0})
|
|
99
99
|
CODE_CASE_DIFFERENCE = The code ''{0}'' differs from the correct code ''{1}'' by case. Although the code system ''{2}'' is case insensitive, implementers are strongly encouraged to use the correct case anyway
|
|
100
|
+
CODE_NOT_IN_NORMAL_FORM = The code ''{0}'' is valid, but it is not the normal form for the code system ''{2}''; the normal form is ''{1}''
|
|
100
101
|
Code_found_in_expansion_however_ = Code found in expansion, however: {0}
|
|
101
102
|
CODESYSTEM_CONCEPT_NO_DEFINITION = HL7 Defined CodeSystems should ensure that every concept has a definition
|
|
102
103
|
CODESYSTEM_CONCEPT_NO_DISPLAY = HL7 Defined CodeSystems should ensure that every concept has a display
|
package/tx/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Add the `tx` section to your `config.json`:
|
|
|
25
25
|
"tx": {
|
|
26
26
|
"enabled": true,
|
|
27
27
|
"librarySource": "/path/to/library.yml",
|
|
28
|
+
"publishLibrarySource": false,
|
|
28
29
|
"cacheTimeout": 30,
|
|
29
30
|
"internalLimit" : 10000,
|
|
30
31
|
"externalLimit" : 1000,
|
|
@@ -66,6 +67,7 @@ Add the `tx` section to your `config.json`:
|
|
|
66
67
|
| `expansionCacheSize` | integer | No | Maximum number of expanded ValueSets to cache. Default: 1000 |
|
|
67
68
|
| `expansionCacheMemoryThreshold` | integer | No | Heap memory usage in MB that triggers evicting oldest half of expansion cache. 0 = disabled. Default: 0 |
|
|
68
69
|
| `librarySource` | string | Yes | Path to the YAML file that defines the terminology sources to load |
|
|
70
|
+
| `publishLibrarySource` | boolean | No | Publish that YAML file at `/{path}/library`. Default: false |
|
|
69
71
|
| `internalLimit` | integer | No | Largest number of codes in internal expansions |
|
|
70
72
|
| `externalLimit` | integer | No | Largest number of codes the server will return in an expansion |
|
|
71
73
|
| `endpoints` | array | Yes | List of endpoint configurations (at least one required) |
|
|
@@ -125,6 +127,26 @@ Each endpoint provides the following FHIR terminology operations:
|
|
|
125
127
|
Each endpoint also provides:
|
|
126
128
|
- `GET /{path}/metadata` - Returns a CapabilityStatement describing the endpoint's capabilities
|
|
127
129
|
- `GET /{path}/` - Returns basic endpoint information
|
|
130
|
+
- `GET /{path}/library` - Returns the library source YAML, if `publishLibrarySource` is set
|
|
131
|
+
|
|
132
|
+
### Publishing the library source
|
|
133
|
+
|
|
134
|
+
`publishLibrarySource` lets users see what the server actually loads. It is off by default,
|
|
135
|
+
because the library YAML names every database, cache and package the server runs, and not
|
|
136
|
+
every deployment wants that public. Turn it on and each endpoint serves the file at
|
|
137
|
+
`/{path}/library`, and a **Library** item appears in the tx navigation bar.
|
|
138
|
+
|
|
139
|
+
There is one URL, content negotiated the same way the rest of the module negotiates:
|
|
140
|
+
|
|
141
|
+
| Request | Response |
|
|
142
|
+
|---------|----------|
|
|
143
|
+
| `Accept: text/html` (i.e. a browser) | the file, syntax highlighted, in the tx page template |
|
|
144
|
+
| anything else | the file itself, as `application/yaml` |
|
|
145
|
+
| `?_format=html` / `?_format=yaml` | forces either representation |
|
|
146
|
+
|
|
147
|
+
The file is read from disk per request, so editing it is reflected immediately - note that
|
|
148
|
+
this means the published file can be ahead of what the running server actually loaded, if it
|
|
149
|
+
has been edited since startup.
|
|
128
150
|
|
|
129
151
|
## Library Configuration
|
|
130
152
|
|
package/tx/cs/cs-mimetypes.js
CHANGED
|
@@ -36,6 +36,99 @@ const { Issue } = require("../library/operation-outcome");
|
|
|
36
36
|
*/
|
|
37
37
|
const UNDERSTOOD_PARAMETERS = new Set(['charset', 'format', 'delsp', 'version']);
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Parameters whose VALUES are case-insensitive.
|
|
41
|
+
*
|
|
42
|
+
* RFC 2045 section 5.1 makes type, subtype and parameter names case-insensitive, and then says
|
|
43
|
+
* of values: "Parameter values are normally case sensitive, but sometimes are interpreted in a
|
|
44
|
+
* case-insensitive fashion, depending on the intended use." So a value is only folded where its
|
|
45
|
+
* own definition says to fold it, and this is the list of those we know of:
|
|
46
|
+
*
|
|
47
|
+
* - charset RFC 2046 section 4.1.2: "Unlike some other parameter values, the values of the
|
|
48
|
+
* charset parameter are NOT case sensitive." (RFC 9110 section 8.3.2 agrees.)
|
|
49
|
+
* - format RFC 3676 section 4, of Format and DelSp together: "(Neither the parameter names
|
|
50
|
+
* - delsp nor values are case sensitive.)"
|
|
51
|
+
*
|
|
52
|
+
* `version` is deliberately absent: there is no general definition of a version parameter -
|
|
53
|
+
* it exists only per-registration (text/vcard has one, text/html deliberately removed one) -
|
|
54
|
+
* so there is nothing that says its values fold, and folding them could equate two versions
|
|
55
|
+
* that a registration means to distinguish.
|
|
56
|
+
*
|
|
57
|
+
* @type {Set<string>}
|
|
58
|
+
*/
|
|
59
|
+
const CASE_INSENSITIVE_VALUES = new Set(['charset', 'format', 'delsp']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Media types whose registration gives a parameter a DEFAULT value.
|
|
63
|
+
*
|
|
64
|
+
* This is the thing that breaks the otherwise tidy rule that a parameter narrows a media type.
|
|
65
|
+
* Where a parameter has a default, the bare form is not the general case - it already carries
|
|
66
|
+
* that value - so `text/plain` does not subsume `text/plain; charset=utf-8`, the two are
|
|
67
|
+
* different things. Applying the default to whichever side omits it, and then comparing as
|
|
68
|
+
* usual, gets this right in both directions: it also makes `text/plain` and
|
|
69
|
+
* `text/plain; charset=us-ascii` come out equivalent, which they are and which we used to
|
|
70
|
+
* get wrong in the other direction.
|
|
71
|
+
*
|
|
72
|
+
* There is no list to load this from. IANA's registry schema has no parameter element at all -
|
|
73
|
+
* a record is a name, a deprecation flag, cross-references and a pointer to a template - and
|
|
74
|
+
* defaults live only in the prose of each defining RFC. `text/plain`, the type that needs this
|
|
75
|
+
* most, has no IANA registration template whatsoever ("No registration template available"),
|
|
76
|
+
* so all of the below comes from the RFCs directly:
|
|
77
|
+
*
|
|
78
|
+
* - text/plain charset: RFC 2046 section 4.1.2 made US-ASCII the default, and RFC 6657
|
|
79
|
+
* section 4 exists specifically to say it stayed: "The default "charset" parameter value
|
|
80
|
+
* for "text/plain" is unchanged from [RFC2046] and remains as "US-ASCII"."
|
|
81
|
+
* - text/plain format and delsp: RFC 3676 section 4, "If Format is not specified, or if the
|
|
82
|
+
* value is not recognized, a value of Fixed is assumed" and "If DelSp is not specified, or
|
|
83
|
+
* if its value is not recognized, a value of No is assumed."
|
|
84
|
+
* - text/vcard charset: RFC 6350 section 3.1, "The charset ... for vCard is UTF-8 ... There is
|
|
85
|
+
* no way to override this." A default expressed as a prohibition, but a default.
|
|
86
|
+
*
|
|
87
|
+
* Keyed by lower-case type/subtype; the inner map is lower-case parameter name to the value
|
|
88
|
+
* the bare form implies. Values are stored folded, matching CASE_INSENSITIVE_VALUES.
|
|
89
|
+
*
|
|
90
|
+
* @type {Map<string, Map<string, string>>}
|
|
91
|
+
*/
|
|
92
|
+
const PARAMETER_DEFAULTS = new Map([
|
|
93
|
+
['text/plain', new Map([['charset', 'us-ascii'], ['format', 'fixed'], ['delsp', 'no']])],
|
|
94
|
+
['text/vcard', new Map([['charset', 'utf-8']])]
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* text/* types whose registration says explicitly that charset has NO default.
|
|
99
|
+
*
|
|
100
|
+
* RFC 6657 section 3 left a residual rule behind that is the awkward part of all this:
|
|
101
|
+
* "existing "text/*" registrations that fail to specify how the charset is determined still
|
|
102
|
+
* default to US-ASCII." So for a text/* type, silence in the registration is not the absence
|
|
103
|
+
* of a default - it IS a default, of US-ASCII. That set cannot be enumerated without reading
|
|
104
|
+
* every legacy text/* registration and judging whether it "clearly specifies", so instead of
|
|
105
|
+
* guessing, charset on an unlisted text/* type yields cannot-determine.
|
|
106
|
+
*
|
|
107
|
+
* This set is the escape hatch: text/* types we have actually read, which say there is no
|
|
108
|
+
* default, and for which the narrowing rule therefore holds.
|
|
109
|
+
*
|
|
110
|
+
* - text/markdown: RFC 7763 section 2, "charset: Per Section 4.2.1 of [RFC6838], charset is
|
|
111
|
+
* REQUIRED. There is no default value because neither [MDSYNTAX] nor many popular
|
|
112
|
+
* implementations at the time of this registration do either."
|
|
113
|
+
* - text/html: a judgement rather than a quoted default, and the only entry here that is.
|
|
114
|
+
* Neither registration states one - RFC 2854 section 6 recites the RFC 2046 and RFC 2616
|
|
115
|
+
* rules, observes that they conflict, and only recommends being explicit; the current IANA
|
|
116
|
+
* registration is the W3C one, whose whole optional-parameters text is that charset
|
|
117
|
+
* "may be provided to definitively specify the document's character encoding, overriding
|
|
118
|
+
* any character encoding declarations in the document". HTML determines its encoding in
|
|
119
|
+
* band, and the phrase "overriding any character encoding declarations in the document"
|
|
120
|
+
* only makes sense if the absence of the parameter means "whatever the document says"
|
|
121
|
+
* rather than "US-ASCII". Listed because text/html is the most asked-about media type
|
|
122
|
+
* there is, and declining to answer for it is a poor trade for a defensible reading.
|
|
123
|
+
*
|
|
124
|
+
* Non-text types need no entry: RFC 6657 is a text/* rule, so silence elsewhere really is
|
|
125
|
+
* silence (application/xml, for instance - RFC 7303 section 3.2 tells consumers they "SHOULD
|
|
126
|
+
* NOT assume a default encoding").
|
|
127
|
+
*
|
|
128
|
+
* @type {Set<string>}
|
|
129
|
+
*/
|
|
130
|
+
const TEXT_TYPES_WITH_NO_CHARSET_DEFAULT = new Set(['text/markdown', 'text/html']);
|
|
131
|
+
|
|
39
132
|
// The IANA media types registry. Only the names are kept - the registry is a few MB of
|
|
40
133
|
// cross-references and templates, and all the `registered` filter asks is whether a type is in it
|
|
41
134
|
const IANA_MEDIA_TYPES_URL = 'https://www.iana.org/assignments/media-types/media-types.xml';
|
|
@@ -46,6 +139,63 @@ class MimeTypeConcept {
|
|
|
46
139
|
constructor(code) {
|
|
47
140
|
this.code = code;
|
|
48
141
|
this.mimeType = this.#parseMimeType(code);
|
|
142
|
+
this.normalized = this.#normalize(code);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The canonical spelling of this media type.
|
|
147
|
+
*
|
|
148
|
+
* Four things are canonicalised, and each is something the specs say carries no meaning:
|
|
149
|
+
*
|
|
150
|
+
* - case, in the type, the subtype, every parameter name, and the values of the parameters
|
|
151
|
+
* in CASE_INSENSITIVE_VALUES (RFC 2045 section 5.1, RFC 2046 section 4.1.2, RFC 3676
|
|
152
|
+
* section 4). Every other parameter value keeps its case: RFC 2045 makes values
|
|
153
|
+
* case-sensitive unless their own definition says otherwise, and a boundary or a profile
|
|
154
|
+
* URI can genuinely differ by case
|
|
155
|
+
* - parameter ORDER, which is not significant, so sorting by name gives two spellings of one
|
|
156
|
+
* media type one spelling. Without this, `text/plain; charset=utf-8; format=flowed` and
|
|
157
|
+
* `text/plain; format=flowed; charset=utf-8` would have two different "normal" forms,
|
|
158
|
+
* which is not a normal form at all
|
|
159
|
+
* - the separator, which becomes '; '
|
|
160
|
+
* - surrounding and internal padding
|
|
161
|
+
*
|
|
162
|
+
* Quoting is NOT canonicalised. A quoted value can always be written quoted, so removing the
|
|
163
|
+
* quotes needs to know that the value contains no tspecials, and getting that wrong produces
|
|
164
|
+
* a "canonical" form that is not a legal media type. Left for someone who needs it.
|
|
165
|
+
*
|
|
166
|
+
* Note the consequence for validation: reordering or respacing makes this differ from the
|
|
167
|
+
* submitted code by more than case, so it is reported as a normal-form difference rather
|
|
168
|
+
* than a case difference. That is the honest description of what changed.
|
|
169
|
+
*/
|
|
170
|
+
#normalize(code) {
|
|
171
|
+
if (!this.mimeType.isValid) {
|
|
172
|
+
return code;
|
|
173
|
+
}
|
|
174
|
+
const segments = code.split(';');
|
|
175
|
+
const params = [];
|
|
176
|
+
for (const segment of segments.slice(1)) {
|
|
177
|
+
const trimmed = segment.trim();
|
|
178
|
+
if (trimmed === '') {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const eq = trimmed.indexOf('=');
|
|
182
|
+
if (eq === -1) {
|
|
183
|
+
// not a name=value pair. The parser ignores it, but dropping it here would make the
|
|
184
|
+
// canonical form say something the submitted code did not, so it is carried through -
|
|
185
|
+
// untouched, since only parameter NAMES fold and nothing says this is one
|
|
186
|
+
params.push({ name: trimmed.toLowerCase(), text: trimmed });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const name = trimmed.slice(0, eq).trim().toLowerCase();
|
|
190
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
191
|
+
params.push({
|
|
192
|
+
name: name,
|
|
193
|
+
text: name + '=' + (CASE_INSENSITIVE_VALUES.has(name) ? value.toLowerCase() : value)
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// by name, then by the whole parameter, so a repeated name is still ordered predictably
|
|
197
|
+
params.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : (a.text < b.text ? -1 : a.text > b.text ? 1 : 0)));
|
|
198
|
+
return [segments[0].trim().toLowerCase(), ...params.map(p => p.text)].join('; ');
|
|
49
199
|
}
|
|
50
200
|
|
|
51
201
|
#parseMimeType(code) {
|
|
@@ -71,7 +221,7 @@ class MimeTypeConcept {
|
|
|
71
221
|
if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) {
|
|
72
222
|
value = value.slice(1, -1);
|
|
73
223
|
}
|
|
74
|
-
if (name
|
|
224
|
+
if (CASE_INSENSITIVE_VALUES.has(name)) {
|
|
75
225
|
value = value.toLowerCase();
|
|
76
226
|
}
|
|
77
227
|
if (name) {
|
|
@@ -196,7 +346,7 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
196
346
|
async code(code) {
|
|
197
347
|
|
|
198
348
|
const ctxt = await this.#ensureContext(code);
|
|
199
|
-
return ctxt ? ctxt.
|
|
349
|
+
return ctxt ? ctxt.normalized : null;
|
|
200
350
|
}
|
|
201
351
|
|
|
202
352
|
async display(code) {
|
|
@@ -206,14 +356,15 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
206
356
|
return null;
|
|
207
357
|
}
|
|
208
358
|
|
|
209
|
-
// Check supplements first
|
|
210
|
-
|
|
359
|
+
// Check supplements first, by the normalised code - a supplement that designates
|
|
360
|
+
// 'text/plain' has to be found for a request that spells it 'TEXT/Plain'
|
|
361
|
+
const suppDisplay = this._displayFromSupplements(ctxt.normalized);
|
|
211
362
|
if (suppDisplay) {
|
|
212
363
|
return suppDisplay;
|
|
213
364
|
}
|
|
214
365
|
|
|
215
|
-
// Default display is the code itself, trimmed
|
|
216
|
-
return ctxt.
|
|
366
|
+
// Default display is the code itself, normalised and trimmed
|
|
367
|
+
return ctxt.normalized.trim();
|
|
217
368
|
}
|
|
218
369
|
|
|
219
370
|
async definition(code) {
|
|
@@ -314,11 +465,27 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
314
465
|
return 'not-subsumed';
|
|
315
466
|
}
|
|
316
467
|
|
|
468
|
+
// Where the registration gives a parameter a default, the bare form already carries that
|
|
469
|
+
// value, so fill it in on both sides before comparing anything. After this, a parameter
|
|
470
|
+
// that defaults is present on both sides and the narrowing logic below never sees it as
|
|
471
|
+
// "added" - which is the point: adding format=flowed to text/plain does not narrow it, it
|
|
472
|
+
// contradicts the format=fixed the bare form meant all along.
|
|
473
|
+
const key = a.type + '/' + a.subtype;
|
|
474
|
+
const defaults = PARAMETER_DEFAULTS.get(key);
|
|
475
|
+
const aParams = new Map(a.params);
|
|
476
|
+
const bParams = new Map(b.params);
|
|
477
|
+
if (defaults) {
|
|
478
|
+
for (const [name, value] of defaults) {
|
|
479
|
+
if (!aParams.has(name)) aParams.set(name, value);
|
|
480
|
+
if (!bParams.has(name)) bParams.set(name, value);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
317
484
|
// Only the parameters the two codes DISAGREE on can decide the answer - one present
|
|
318
485
|
// on one side only, or present on both with different values. A parameter carried
|
|
319
486
|
// identically by both, understood or not, cannot affect the result and is ignored.
|
|
320
|
-
const deciding = [...new Set([...
|
|
321
|
-
.filter(name =>
|
|
487
|
+
const deciding = [...new Set([...aParams.keys(), ...bParams.keys()])]
|
|
488
|
+
.filter(name => aParams.get(name) !== bParams.get(name));
|
|
322
489
|
const unknown = deciding.filter(name => !UNDERSTOOD_PARAMETERS.has(name));
|
|
323
490
|
if (unknown.length > 0) {
|
|
324
491
|
throw cannotDetermineSubsumption(
|
|
@@ -331,15 +498,34 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
331
498
|
+ 'that may be wrong');
|
|
332
499
|
}
|
|
333
500
|
|
|
501
|
+
// RFC 6657 section 3: a text/* registration that does not say how the charset is determined
|
|
502
|
+
// still defaults to US-ASCII. So on a text/* type we have not read, we do not know whether
|
|
503
|
+
// the side without a charset means "any charset" or "US-ASCII", and that is exactly the
|
|
504
|
+
// difference between subsumes and not-subsumed. Only bites when charset is present on one
|
|
505
|
+
// side and absent on the other - two explicit values are just two values.
|
|
506
|
+
if (deciding.includes('charset') && a.type === 'text'
|
|
507
|
+
&& !(defaults && defaults.has('charset'))
|
|
508
|
+
&& !TEXT_TYPES_WITH_NO_CHARSET_DEFAULT.has(key)
|
|
509
|
+
&& aParams.has('charset') !== bParams.has('charset')) {
|
|
510
|
+
throw cannotDetermineSubsumption(
|
|
511
|
+
`Unable to determine the subsumption relationship between '${a.source}' and `
|
|
512
|
+
+ `'${b.source}': one carries a charset and the other does not, and this server does `
|
|
513
|
+
+ `not know whether '${key}' gives charset a default value. RFC 6657 leaves every `
|
|
514
|
+
+ 'text/* registration that does not say how the charset is determined defaulting to '
|
|
515
|
+
+ 'US-ASCII, so the absence of a charset may mean "any charset" or may mean "US-ASCII", '
|
|
516
|
+
+ 'and those give opposite answers. No outcome is returned rather than one that may be '
|
|
517
|
+
+ 'wrong');
|
|
518
|
+
}
|
|
519
|
+
|
|
334
520
|
// Any parameter they both carry has to agree, or neither includes the other
|
|
335
|
-
for (const [name, value] of
|
|
336
|
-
if (
|
|
521
|
+
for (const [name, value] of aParams) {
|
|
522
|
+
if (bParams.has(name) && bParams.get(name) !== value) {
|
|
337
523
|
return 'not-subsumed';
|
|
338
524
|
}
|
|
339
525
|
}
|
|
340
526
|
|
|
341
|
-
const aInB = [...
|
|
342
|
-
const bInA = [...
|
|
527
|
+
const aInB = [...aParams.keys()].every(name => bParams.has(name));
|
|
528
|
+
const bInA = [...bParams.keys()].every(name => aParams.has(name));
|
|
343
529
|
|
|
344
530
|
if (aInB && bInA) {
|
|
345
531
|
return 'equivalent';
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -28,6 +28,21 @@ const {debugLog} = require("../operation-context");
|
|
|
28
28
|
// shared - it simply recomputes.
|
|
29
29
|
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
30
30
|
|
|
31
|
+
// Description language index -> language code. The index is assigned at import
|
|
32
|
+
// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
|
|
33
|
+
// be kept in step with it.
|
|
34
|
+
const SCT_LANGUAGE_CODES = {
|
|
35
|
+
1: 'en',
|
|
36
|
+
2: 'fr',
|
|
37
|
+
3: 'nl',
|
|
38
|
+
4: 'es',
|
|
39
|
+
5: 'sv',
|
|
40
|
+
6: 'da',
|
|
41
|
+
7: 'de',
|
|
42
|
+
8: 'it',
|
|
43
|
+
9: 'cs'
|
|
44
|
+
};
|
|
45
|
+
|
|
31
46
|
// Context kinds matching Pascal enum
|
|
32
47
|
const SnomedProviderContextKind = {
|
|
33
48
|
CODE: 0,
|
|
@@ -346,8 +361,20 @@ class SnomedServices {
|
|
|
346
361
|
// preferred synonym in more than one dialect at once (the International
|
|
347
362
|
// edition ships both US English 509007 and GB English 508004, each marking a
|
|
348
363
|
// different synonym Preferred). The edition default decides which term is
|
|
349
|
-
// shown.
|
|
350
|
-
//
|
|
364
|
+
// shown.
|
|
365
|
+
//
|
|
366
|
+
// Editions not listed here default to English (US then GB) rather than to
|
|
367
|
+
// "preferred synonym in any language refset". A national edition carries a
|
|
368
|
+
// Preferred synonym in each of its own language refsets as well as in English
|
|
369
|
+
// (e.g. the Belgian edition 11000172109 marks a French term Preferred in
|
|
370
|
+
// 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
|
|
371
|
+
// whichever description happened to be stored first - French for one concept,
|
|
372
|
+
// Dutch for the next, English for a third. A caller who asked for no
|
|
373
|
+
// particular language got an arbitrary mix. English is the only defensible
|
|
374
|
+
// default: it is the one language every edition has, and it is what a request
|
|
375
|
+
// with no displayLanguage is taken to mean everywhere else in the server. An
|
|
376
|
+
// edition with no English at all still falls through to the any-refset step
|
|
377
|
+
// below, so nothing regresses.
|
|
351
378
|
_displayRefsetOrder() {
|
|
352
379
|
if (this._dispOrder) return this._dispOrder;
|
|
353
380
|
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
@@ -361,7 +388,7 @@ class SnomedServices {
|
|
|
361
388
|
'83821000000107': [GB], // UK Edition
|
|
362
389
|
'999000021000000109': [GB] // UK Clinical Edition
|
|
363
390
|
};
|
|
364
|
-
const ids = byEdition[String(this.edition)] || [];
|
|
391
|
+
const ids = byEdition[String(this.edition)] || [US, GB];
|
|
365
392
|
this._dispOrder = ids.map(idx).filter((i) => i >= 0);
|
|
366
393
|
return this._dispOrder;
|
|
367
394
|
}
|
|
@@ -400,59 +427,80 @@ class SnomedServices {
|
|
|
400
427
|
return false;
|
|
401
428
|
}
|
|
402
429
|
|
|
403
|
-
// Return a concept's display
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
430
|
+
// Return a concept's display, together with the language it is actually in:
|
|
431
|
+
// the synonym marked Preferred in the edition's display language reference
|
|
432
|
+
// set; failing that a preferred synonym in any language refset; failing that
|
|
433
|
+
// the FSN; failing that the first active description. Previously this
|
|
434
|
+
// returned the first active description outright, which is only the preferred
|
|
435
|
+
// term by accident of import order.
|
|
436
|
+
//
|
|
437
|
+
// The language matters as much as the term: the display is emitted as a
|
|
438
|
+
// designation (DesignationUse.DISPLAY) during $expand, and tagging a French
|
|
439
|
+
// term as en-US puts it in an IG's English display column and makes the real
|
|
440
|
+
// French designation look like a duplicate.
|
|
441
|
+
//
|
|
442
|
+
// Returns {term, lang} - lang is a language code, or null when no description
|
|
443
|
+
// could be read.
|
|
444
|
+
getDisplayNameEx(reference = 0) {
|
|
445
|
+
const none = { term: '', lang: null };
|
|
408
446
|
try {
|
|
409
447
|
const concept = this.concepts.getConcept(reference);
|
|
410
448
|
const descriptionsRef = concept.descriptions;
|
|
411
449
|
|
|
412
450
|
if (descriptionsRef === 0) {
|
|
413
|
-
return
|
|
451
|
+
return none;
|
|
414
452
|
}
|
|
415
453
|
|
|
416
454
|
const descriptionIndices = this.refs.getReferences(descriptionsRef);
|
|
417
455
|
const K = this._displayConstants();
|
|
456
|
+
const pick = (description) => ({
|
|
457
|
+
term: this.strings.getEntry(description.iDesc).trim(),
|
|
458
|
+
lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
|
|
459
|
+
});
|
|
418
460
|
|
|
419
461
|
// 1. Preferred synonym in the edition's default display refset(s), tried
|
|
420
462
|
// in priority order. This is what disambiguates dialects: on the
|
|
421
463
|
// International edition a concept may be Preferred in both US and GB
|
|
422
|
-
// English, and the edition default (US English) must win.
|
|
464
|
+
// English, and the edition default (US English) must win. It is also
|
|
465
|
+
// what keeps a multi-language national edition from picking a
|
|
466
|
+
// different language for each concept.
|
|
423
467
|
for (const refsetIdx of this._displayRefsetOrder()) {
|
|
424
468
|
for (const descIndex of descriptionIndices) {
|
|
425
469
|
const description = this.descriptions.getDescription(descIndex);
|
|
426
470
|
if (!description.active || description.kind !== K.synonym) continue;
|
|
427
471
|
if (this._descAcceptability(description, refsetIdx) === K.preferred) {
|
|
428
|
-
return
|
|
472
|
+
return pick(description);
|
|
429
473
|
}
|
|
430
474
|
}
|
|
431
475
|
}
|
|
432
476
|
|
|
433
477
|
// 2. Fallback: preferred synonym in ANY language refset; then FSN; then
|
|
434
|
-
// the first active description.
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
let
|
|
478
|
+
// the first active description. Reached only by editions with no
|
|
479
|
+
// English language refset at all, and by concepts with no preferred
|
|
480
|
+
// synonym in the edition's display refsets.
|
|
481
|
+
let fsn = null;
|
|
482
|
+
let firstActive = null;
|
|
438
483
|
for (const descIndex of descriptionIndices) {
|
|
439
484
|
const description = this.descriptions.getDescription(descIndex);
|
|
440
485
|
if (!description.active) continue;
|
|
441
|
-
|
|
442
|
-
if (firstActive === '') firstActive = term;
|
|
486
|
+
if (firstActive === null) firstActive = pick(description);
|
|
443
487
|
if (this._synonymIsPreferred(description)) {
|
|
444
|
-
return
|
|
488
|
+
return pick(description); // preferred synonym (any language refset)
|
|
445
489
|
}
|
|
446
|
-
if (description.kind === K.fsn &&
|
|
447
|
-
|
|
490
|
+
if (description.kind === K.fsn && fsn === null) {
|
|
491
|
+
fsn = pick(description);
|
|
448
492
|
}
|
|
449
493
|
}
|
|
450
|
-
return
|
|
494
|
+
return fsn || firstActive || none;
|
|
451
495
|
} catch (error) {
|
|
452
|
-
return
|
|
496
|
+
return none;
|
|
453
497
|
}
|
|
454
498
|
}
|
|
455
499
|
|
|
500
|
+
getDisplayName(reference = 0) {
|
|
501
|
+
return this.getDisplayNameEx(reference).term;
|
|
502
|
+
}
|
|
503
|
+
|
|
456
504
|
getConceptDescendants(reference) {
|
|
457
505
|
try {
|
|
458
506
|
const allDescRef = this.concepts.getAllDesc(reference);
|
|
@@ -1687,8 +1735,16 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1687
1735
|
if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
|
|
1688
1736
|
}
|
|
1689
1737
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1738
|
+
// Tagged with the language the term is actually in, not a
|
|
1739
|
+
// hard-coded en-US: on a multi-language edition the edition
|
|
1740
|
+
// display can be French or Dutch, and mislabelling it as English
|
|
1741
|
+
// is what puts it in an IG's English display column and makes the
|
|
1742
|
+
// real French designation look like a duplicate of it. English
|
|
1743
|
+
// stays 'en-US' (the term comes from the US English language
|
|
1744
|
+
// reference set), so nothing changes for English editions.
|
|
1745
|
+
const display = this.sct.getDisplayNameEx(ctxt.getReference());
|
|
1746
|
+
const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
|
|
1747
|
+
if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
|
|
1692
1748
|
// FSNs before synonyms, matching the designation order the published
|
|
1693
1749
|
// tx-ecosystem expectations were written against (sct/expand-inactive).
|
|
1694
1750
|
// Which of the two is chosen as the display is settled by
|
|
@@ -1697,14 +1753,31 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1697
1753
|
for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1698
1754
|
for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1699
1755
|
} else {
|
|
1700
|
-
// $lookup: emit every description
|
|
1701
|
-
// display
|
|
1756
|
+
// $lookup: emit every description. Order matters only for the
|
|
1757
|
+
// display: a request with no displayLanguage takes the first
|
|
1758
|
+
// designation Designations._isPreferred() accepts, which is any
|
|
1759
|
+
// SNOMED synonym. Ranking by the edition's display language
|
|
1760
|
+
// reference set first (rather than by "preferred in any refset")
|
|
1761
|
+
// keeps that pick in one language - otherwise a national edition
|
|
1762
|
+
// answers $lookup in French for one concept and Dutch for the
|
|
1763
|
+
// next, depending on description storage order.
|
|
1764
|
+
const K2 = this.sct._displayConstants();
|
|
1765
|
+
const displayRefsets = this.sct._displayRefsetOrder();
|
|
1766
|
+
// Buckets: 0 = preferred in the edition's display refset, 1 =
|
|
1767
|
+
// preferred in some other language refset, 2 = everything else
|
|
1768
|
+
// (FSNs and acceptable synonyms, left in their original order -
|
|
1769
|
+
// Array.sort is stable, so nothing else about the output moves).
|
|
1770
|
+
const rank = (description) => {
|
|
1771
|
+
if (description.kind !== K2.synonym) return 2;
|
|
1772
|
+
for (const refsetIdx of displayRefsets) {
|
|
1773
|
+
if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
|
|
1774
|
+
}
|
|
1775
|
+
return this.sct._synonymIsPreferred(description) ? 1 : 2;
|
|
1776
|
+
};
|
|
1702
1777
|
const orderedIndices = descriptionIndices.slice().sort((a, b) => {
|
|
1703
1778
|
const da = this.sct.descriptions.getDescription(a);
|
|
1704
1779
|
const db = this.sct.descriptions.getDescription(b);
|
|
1705
|
-
|
|
1706
|
-
const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
|
|
1707
|
-
return pa - pb;
|
|
1780
|
+
return rank(da) - rank(db);
|
|
1708
1781
|
});
|
|
1709
1782
|
for (const descIndex of orderedIndices) {
|
|
1710
1783
|
const description = this.sct.descriptions.getDescription(descIndex);
|
|
@@ -1733,18 +1806,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1733
1806
|
}
|
|
1734
1807
|
|
|
1735
1808
|
getLanguageCode(langIndex) {
|
|
1736
|
-
|
|
1737
|
-
1: 'en',
|
|
1738
|
-
2: 'fr',
|
|
1739
|
-
3: 'nl',
|
|
1740
|
-
4: 'es',
|
|
1741
|
-
5: 'sv',
|
|
1742
|
-
6: 'da',
|
|
1743
|
-
7: 'de',
|
|
1744
|
-
8: 'it',
|
|
1745
|
-
9: 'cs'
|
|
1746
|
-
};
|
|
1747
|
-
return languageMap[langIndex] || 'en';
|
|
1809
|
+
return SCT_LANGUAGE_CODES[langIndex] || 'en';
|
|
1748
1810
|
}
|
|
1749
1811
|
|
|
1750
1812
|
// Lookup methods
|
package/tx/html/tx-template.html
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
<a href="[%endpoint-path%]/metadata?mode=terminology" style="color: gold">Terminology Capabilities</a> |
|
|
91
91
|
<a href="[%endpoint-path%]/op.html" style="color: gold">Operations</a> |
|
|
92
92
|
<a href="[%endpoint-path%]/problems.html" style="color: gold">Problems</a> |
|
|
93
|
+
[%library-link%]
|
|
93
94
|
|
|
94
95
|
</div>
|
|
95
96
|
</div>
|
package/tx/sct/expressions.js
CHANGED
|
@@ -537,18 +537,40 @@ class SnomedExpressionParser {
|
|
|
537
537
|
|
|
538
538
|
/**
|
|
539
539
|
* Parse refinements for an expression
|
|
540
|
+
*
|
|
541
|
+
* The compositional grammar ABNF is
|
|
542
|
+
* refinement = (attributeSet / attributeGroup) *( ws ["," ws] attributeGroup )
|
|
543
|
+
* attributeSet = attribute *(ws "," ws attribute)
|
|
544
|
+
* so a comma is required between the ungrouped attributes of the attribute set, but is
|
|
545
|
+
* OPTIONAL before an attribute group. Requiring it everywhere rejected the minimal
|
|
546
|
+
* rendering this parser's own renderer produces (e.g. 249943000:260868000=6934004{...}).
|
|
547
|
+
*
|
|
548
|
+
* We stay liberal in what we accept: a group may also be followed by further ungrouped
|
|
549
|
+
* attributes, which the ABNF does not allow but which this parser has always taken.
|
|
540
550
|
*/
|
|
541
551
|
refinements(expr) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
552
|
+
this.ws();
|
|
553
|
+
|
|
554
|
+
let first = true;
|
|
555
|
+
for (;;) {
|
|
556
|
+
if (!first) {
|
|
557
|
+
const mark = this.cursor;
|
|
558
|
+
const comma = this.gchar(',');
|
|
559
|
+
this.ws();
|
|
560
|
+
if (this.peek() !== '{' && !comma) {
|
|
561
|
+
// neither a separating comma nor a group: the refinements are finished
|
|
562
|
+
this.cursor = mark;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (this.peek() === '{') {
|
|
547
568
|
expr.refinementGroups.push(this.attributeGroup());
|
|
569
|
+
} else {
|
|
570
|
+
expr.refinements.push(this.attribute());
|
|
548
571
|
}
|
|
549
572
|
this.ws();
|
|
550
|
-
|
|
551
|
-
this.ws();
|
|
573
|
+
first = false;
|
|
552
574
|
}
|
|
553
575
|
}
|
|
554
576
|
|
|
@@ -2444,10 +2466,13 @@ class SnomedExpressionServices {
|
|
|
2444
2466
|
}
|
|
2445
2467
|
}
|
|
2446
2468
|
|
|
2447
|
-
// Grouped refinements
|
|
2469
|
+
// Grouped refinements. The comma between the attribute set and the first group is
|
|
2470
|
+
// optional in the grammar, but we emit it: it is what makes the boundary between the
|
|
2471
|
+
// ungrouped attributes and the first group legible, and it is the safer of the two
|
|
2472
|
+
// spellings to hand to a third-party parser.
|
|
2448
2473
|
if (expr.hasRefinementGroups()) {
|
|
2449
2474
|
for (let j = 0; j < expr.refinementGroups.length; j++) {
|
|
2450
|
-
if (j > 0) parts.push(',');
|
|
2475
|
+
if (j > 0 || expr.hasRefinements()) parts.push(',');
|
|
2451
2476
|
parts.push('{');
|
|
2452
2477
|
|
|
2453
2478
|
for (let i = 0; i < expr.refinementGroups[j].refinements.length; i++) {
|
package/tx/tx-html.js
CHANGED
|
@@ -86,6 +86,99 @@ function acceptsHtml(req) {
|
|
|
86
86
|
return _fmt.includes('text/html');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Whether this server publishes its library source YAML. Off unless the operator turns
|
|
90
|
+
// modules.tx.publishLibrarySource on, and it gates both the route and the nav item, so a
|
|
91
|
+
// server that does not publish it has no link to a 404.
|
|
92
|
+
let publishLibrarySource = false;
|
|
93
|
+
|
|
94
|
+
function setPublishLibrarySource(enabled) {
|
|
95
|
+
publishLibrarySource = enabled === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function publishesLibrarySource() {
|
|
99
|
+
return publishLibrarySource;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Highlight a YAML document for display.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately line-oriented and modest: this is the operator's own source file, not a
|
|
106
|
+
* general YAML parser, and it has to survive anything a hand-edited file contains without
|
|
107
|
+
* ever emitting the file's bytes unescaped. Each line is split into its parts FIRST and
|
|
108
|
+
* every part is escaped before any markup goes near it, so no amount of angle brackets or
|
|
109
|
+
* ampersands in a value can break out.
|
|
110
|
+
*/
|
|
111
|
+
function highlightYaml(source) {
|
|
112
|
+
const lines = String(source).replace(/\r\n/g, '\n').split('\n');
|
|
113
|
+
const out = lines.map(line => {
|
|
114
|
+
if (line.trim() === '') {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
// a whole-line comment
|
|
118
|
+
const wholeComment = /^(\s*)(#.*)$/.exec(line);
|
|
119
|
+
if (wholeComment) {
|
|
120
|
+
return escape(wholeComment[1]) + '<span class="y-c">' + escape(wholeComment[2]) + '</span>';
|
|
121
|
+
}
|
|
122
|
+
// split off a trailing comment, but only one introduced by whitespace-then-#, so a
|
|
123
|
+
// '#' inside a value (a FHIR fragment URL, say) stays part of the value
|
|
124
|
+
let body = line;
|
|
125
|
+
let comment = '';
|
|
126
|
+
const trailing = /^(.*?)(\s+#.*)$/.exec(line);
|
|
127
|
+
if (trailing) {
|
|
128
|
+
body = trailing[1];
|
|
129
|
+
comment = '<span class="y-c">' + escape(trailing[2]) + '</span>';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// a mapping key needs whitespace (or end of line) after the colon - without it the
|
|
133
|
+
// colon is just part of a scalar, which is exactly what a source line like
|
|
134
|
+
// '- internal:lang' or '- snomed!:sct_intl_20250201.cache' is
|
|
135
|
+
const m = /^(\s*)(-\s+)?([A-Za-z0-9_.$-]+)(:)(\s+|$)(.*)$/.exec(body);
|
|
136
|
+
if (m) {
|
|
137
|
+
return escape(m[1])
|
|
138
|
+
+ (m[2] ? '<span class="y-d">' + escape(m[2]) + '</span>' : '')
|
|
139
|
+
+ '<span class="y-k">' + escape(m[3]) + '</span>'
|
|
140
|
+
+ '<span class="y-p">' + escape(m[4]) + '</span>'
|
|
141
|
+
+ escape(m[5])
|
|
142
|
+
+ (m[6] ? '<span class="y-v">' + escape(m[6]) + '</span>' : '')
|
|
143
|
+
+ comment;
|
|
144
|
+
}
|
|
145
|
+
const item = /^(\s*)(-\s+)(.*)$/.exec(body);
|
|
146
|
+
if (item) {
|
|
147
|
+
return escape(item[1])
|
|
148
|
+
+ '<span class="y-d">' + escape(item[2]) + '</span>'
|
|
149
|
+
+ '<span class="y-v">' + escape(item[3]) + '</span>'
|
|
150
|
+
+ comment;
|
|
151
|
+
}
|
|
152
|
+
return escape(body) + comment;
|
|
153
|
+
});
|
|
154
|
+
return out.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const YAML_STYLE = '<style>' +
|
|
158
|
+
'.yaml-source { background: #f7f7f7; border: 1px solid #ddd; padding: 12px; overflow-x: auto; ' +
|
|
159
|
+
'font-family: Menlo, Consolas, monospace; font-size: 90%; line-height: 1.45; }' +
|
|
160
|
+
'.yaml-source .y-k { color: #0b5394; font-weight: bold; }' +
|
|
161
|
+
'.yaml-source .y-v { color: #444; }' +
|
|
162
|
+
'.yaml-source .y-p, .yaml-source .y-d { color: #999; }' +
|
|
163
|
+
'.yaml-source .y-c { color: #777; font-style: italic; }' +
|
|
164
|
+
'</style>';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The page body for the library source: what the file is, where to get the raw bytes, and
|
|
168
|
+
* the file itself.
|
|
169
|
+
*/
|
|
170
|
+
function buildLibrarySourcePage(source, filename, endpointPath) {
|
|
171
|
+
return YAML_STYLE
|
|
172
|
+
+ '<p>This is the library source this server loads its content from - the code system files, '
|
|
173
|
+
+ 'the packages, and the terminology sources it makes available. It is shown here as it is on '
|
|
174
|
+
+ 'disk.</p>'
|
|
175
|
+
+ '<p>The same URL returns the file itself to anything that does not ask for HTML: '
|
|
176
|
+
+ '<code>curl -H "Accept: application/yaml" ' + escape(endpointPath) + '/library</code>, or '
|
|
177
|
+
+ '<a href="library?_format=yaml">' + escape(endpointPath) + '/library?_format=yaml</a>.</p>'
|
|
178
|
+
+ '<p>Source file: <code>' + escape(filename) + '</code></p>'
|
|
179
|
+
+ '<pre class="yaml-source">' + highlightYaml(source) + '</pre>';
|
|
180
|
+
}
|
|
181
|
+
|
|
89
182
|
|
|
90
183
|
class TxHtmlRenderer {
|
|
91
184
|
renderer;
|
|
@@ -110,7 +203,10 @@ class TxHtmlRenderer {
|
|
|
110
203
|
version: packageJson.version,
|
|
111
204
|
endpointpath: endpoint.path,
|
|
112
205
|
fhirversion: endpoint.fhirVersion,
|
|
113
|
-
ms: Date.now() - startTime
|
|
206
|
+
ms: Date.now() - startTime,
|
|
207
|
+
libraryLink: publishLibrarySource
|
|
208
|
+
? '<a href="' + escape(endpoint.path) + '/library" style="color: gold">Library</a> | '
|
|
209
|
+
: ''
|
|
114
210
|
};
|
|
115
211
|
|
|
116
212
|
return htmlServer.renderPage('tx', title, content, options);
|
|
@@ -244,7 +340,14 @@ class TxHtmlRenderer {
|
|
|
244
340
|
html += await this.buildSearchForm(req);
|
|
245
341
|
|
|
246
342
|
// ===== Packages and Factories Section =====
|
|
247
|
-
|
|
343
|
+
// What follows is the loaded content; the library YAML is where it came from, so when
|
|
344
|
+
// the operator publishes it, link it from the heading over the list it produced.
|
|
345
|
+
html += '<hr/><h3>Source Content';
|
|
346
|
+
if (publishLibrarySource) {
|
|
347
|
+
html += ` <a href="${escape(this.path)}/library" style="font-size: 60%; font-weight: normal;"` +
|
|
348
|
+
' title="The library source this server loaded this content from">source</a>';
|
|
349
|
+
}
|
|
350
|
+
html += '</h3>';
|
|
248
351
|
|
|
249
352
|
// List Packages
|
|
250
353
|
html += '<h6>FHIR Packages</h6>';
|
|
@@ -1371,5 +1474,6 @@ class TxHtmlRenderer {
|
|
|
1371
1474
|
}
|
|
1372
1475
|
|
|
1373
1476
|
module.exports = {
|
|
1374
|
-
TxHtmlRenderer, loadTemplate, acceptsHtml
|
|
1477
|
+
TxHtmlRenderer, loadTemplate, acceptsHtml,
|
|
1478
|
+
setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
|
|
1375
1479
|
};
|
package/tx/tx.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const express = require('express');
|
|
9
9
|
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
10
11
|
const Logger = require('../library/logger');
|
|
11
12
|
const { Library } = require('./library');
|
|
12
13
|
const { OperationContext, ResourceCache, ExpansionCache, debugLog} = require('./operation-context');
|
|
@@ -149,6 +150,10 @@ class TXModule {
|
|
|
149
150
|
// Load HTML template
|
|
150
151
|
txHtml.loadTemplate();
|
|
151
152
|
|
|
153
|
+
// Off unless the operator opts in. This gates the nav item as well as the route, so a
|
|
154
|
+
// server that does not publish its library source shows no link to it.
|
|
155
|
+
txHtml.setPublishLibrarySource(config.publishLibrarySource === true);
|
|
156
|
+
|
|
152
157
|
// Validate config
|
|
153
158
|
if (!config.librarySource) {
|
|
154
159
|
throw new Error('TX module requires librarySource configuration');
|
|
@@ -956,6 +961,45 @@ class TXModule {
|
|
|
956
961
|
}
|
|
957
962
|
});
|
|
958
963
|
|
|
964
|
+
// The library source YAML - the file this server loaded its content from.
|
|
965
|
+
//
|
|
966
|
+
// Only registered when the operator turns modules.tx.publishLibrarySource on: the file
|
|
967
|
+
// names every database, cache and package the server runs, which some deployments are
|
|
968
|
+
// happy to publish and others are not, so it is opt-in rather than opt-out.
|
|
969
|
+
//
|
|
970
|
+
// One URL, negotiated: a browser (Accept: text/html) gets the highlighted page, anything
|
|
971
|
+
// else gets the file itself. ?_format=html and ?_format=yaml force either way, through the
|
|
972
|
+
// same acceptsHtml() the rest of the module uses.
|
|
973
|
+
if (this.config.publishLibrarySource === true) {
|
|
974
|
+
router.get('/library', async (req, res) => {
|
|
975
|
+
const start = Date.now();
|
|
976
|
+
try {
|
|
977
|
+
const filename = this.config.librarySource;
|
|
978
|
+
let source;
|
|
979
|
+
try {
|
|
980
|
+
source = await fs.promises.readFile(filename, 'utf8');
|
|
981
|
+
} catch (error) {
|
|
982
|
+
this.log.error(`Error reading library source ${filename}: ${error.message}`);
|
|
983
|
+
res.status(500).json(this.operationOutcome('error', 'exception',
|
|
984
|
+
'The library source is published by this server, but could not be read'));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (txHtml.acceptsHtml(req)) {
|
|
988
|
+
let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
|
|
989
|
+
const content = txHtml.buildLibrarySourcePage(source, path.basename(filename), req.txEndpoint.path);
|
|
990
|
+
const html = await txhtml.renderPage('Library Source', content, req.txEndpoint, req.txStartTime);
|
|
991
|
+
res.setHeader('Content-Type', 'text/html');
|
|
992
|
+
res.send(html);
|
|
993
|
+
} else {
|
|
994
|
+
res.setHeader('Content-Type', 'application/yaml; charset=utf-8');
|
|
995
|
+
res.send(source);
|
|
996
|
+
}
|
|
997
|
+
} finally {
|
|
998
|
+
this.countRequest(endpointPath, 'library', Date.now() - start);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
959
1003
|
// Metadata / CapabilityStatement
|
|
960
1004
|
router.get('/metadata', async (req, res) => {
|
|
961
1005
|
const start = Date.now();
|
package/tx/workers/validate.js
CHANGED
|
@@ -558,10 +558,10 @@ class ValueSetChecker {
|
|
|
558
558
|
} else {
|
|
559
559
|
this.worker.opContext.addNote(this.valueSet, 'found OK', this.indentCount);
|
|
560
560
|
result = true;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
messages.push(
|
|
564
|
-
|
|
561
|
+
const normalised = await cs.code(ctxt.context);
|
|
562
|
+
if (normalised && normalised !== code) {
|
|
563
|
+
messages.push(this.addNormalisationIssue(op, path, code, normalised, cs));
|
|
564
|
+
normalForm.value = normalised;
|
|
565
565
|
}
|
|
566
566
|
let msg = await cs.incompleteValidationMessage(ctxt.context, this.params.HTTPLanguages);
|
|
567
567
|
if (msg) {
|
|
@@ -1611,6 +1611,34 @@ class ValueSetChecker {
|
|
|
1611
1611
|
return result;
|
|
1612
1612
|
}
|
|
1613
1613
|
|
|
1614
|
+
/**
|
|
1615
|
+
* The code as submitted differs from the code as the code system renders it. Report why.
|
|
1616
|
+
*
|
|
1617
|
+
* Every such difference used to be reported as CODE_CASE_DIFFERENCE, which is where that
|
|
1618
|
+
* assumption came from - cs-lang, where the canonical form really does differ only in case.
|
|
1619
|
+
* It is not true in general. cs-snomed re-renders any postcoordinated expression through
|
|
1620
|
+
* renderExpression(Minimal), so every legal alternative serialisation (the optional comma
|
|
1621
|
+
* before an attribute group, whitespace, |terms|, attribute order) came back to the caller
|
|
1622
|
+
* as a complaint about their casing; ICD-11 normalises a URI to the code it denotes and did
|
|
1623
|
+
* the same. Neither has anything to do with case, and unlike case there is nothing the
|
|
1624
|
+
* caller did wrong - the submitted form is valid, it simply is not the normal form.
|
|
1625
|
+
*
|
|
1626
|
+
* So: case-only differences stay CODE_CASE_DIFFERENCE, everything else is
|
|
1627
|
+
* CODE_NOT_IN_NORMAL_FORM. Both are information severity (they were warning in one of the
|
|
1628
|
+
* two call sites and information in the other; every tx-ecosystem fixture expects
|
|
1629
|
+
* information).
|
|
1630
|
+
*
|
|
1631
|
+
* @returns {string} the message, for callers that also collect it into `messages`
|
|
1632
|
+
*/
|
|
1633
|
+
addNormalisationIssue(op, path, code, normalised, cs) {
|
|
1634
|
+
const caseOnly = normalised.toLowerCase() === code.toLowerCase();
|
|
1635
|
+
const id = caseOnly ? 'CODE_CASE_DIFFERENCE' : 'CODE_NOT_IN_NORMAL_FORM';
|
|
1636
|
+
const csDesc = cs.version() ? cs.system() + '|' + cs.version() : cs.system();
|
|
1637
|
+
const msg = this.worker.i18n.translate(id, this.params.HTTPLanguages, [code, normalised, csDesc]);
|
|
1638
|
+
op.addIssue(new Issue('information', 'business-rule', addToPath(path, 'code'), id, msg, 'code-rule'));
|
|
1639
|
+
return msg;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1614
1642
|
async checkConceptSet(path, role, cs, cset, code, displays, vs, message, inactive, normalForm, vstatus, op, vcc, messages) {
|
|
1615
1643
|
this.worker.opContext.addNote(vs, 'check code ' + role + ' ' + this.worker.renderer.displayValueSetInclude(cset) + ' at ' + path, this.indentCount);
|
|
1616
1644
|
let result = false;
|
|
@@ -1633,15 +1661,10 @@ class ValueSetChecker {
|
|
|
1633
1661
|
}
|
|
1634
1662
|
} else {
|
|
1635
1663
|
this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs), this.indentCount);
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
} else {
|
|
1641
|
-
msg = this.worker.i18n.translate('CODE_CASE_DIFFERENCE', this.params.HTTPLanguages, [code, await cs.code(loc.context), cs.system()]);
|
|
1642
|
-
}
|
|
1643
|
-
op.addIssue(new Issue('information', 'business-rule', addToPath(path, 'code'), 'CODE_CASE_DIFFERENCE', msg, 'code-rule'));
|
|
1644
|
-
normalForm.value = await cs.code(loc.context);
|
|
1664
|
+
const normalised = await cs.code(loc.context);
|
|
1665
|
+
if (normalised && normalised != code) {
|
|
1666
|
+
this.addNormalisationIssue(op, path, code, normalised, cs);
|
|
1667
|
+
normalForm.value = normalised;
|
|
1645
1668
|
}
|
|
1646
1669
|
let msg = await cs.incompleteValidationMessage(loc.context, this.params.HTTPLanguages);
|
|
1647
1670
|
if (msg) {
|