@the-cascade-protocol/cli 0.2.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/.dockerignore +7 -0
- package/.eslintrc.json +23 -0
- package/.prettierrc +7 -0
- package/DOCKER.md +36 -0
- package/Dockerfile +18 -0
- package/README.md +69 -0
- package/dist/commands/capabilities.d.ts +9 -0
- package/dist/commands/capabilities.d.ts.map +1 -0
- package/dist/commands/capabilities.js +194 -0
- package/dist/commands/capabilities.js.map +1 -0
- package/dist/commands/conformance.d.ts +15 -0
- package/dist/commands/conformance.d.ts.map +1 -0
- package/dist/commands/conformance.js +348 -0
- package/dist/commands/conformance.js.map +1 -0
- package/dist/commands/convert.d.ts +21 -0
- package/dist/commands/convert.d.ts.map +1 -0
- package/dist/commands/convert.js +134 -0
- package/dist/commands/convert.js.map +1 -0
- package/dist/commands/pod/export.d.ts +8 -0
- package/dist/commands/pod/export.d.ts.map +1 -0
- package/dist/commands/pod/export.js +72 -0
- package/dist/commands/pod/export.js.map +1 -0
- package/dist/commands/pod/helpers.d.ts +79 -0
- package/dist/commands/pod/helpers.d.ts.map +1 -0
- package/dist/commands/pod/helpers.js +369 -0
- package/dist/commands/pod/helpers.js.map +1 -0
- package/dist/commands/pod/index.d.ts +20 -0
- package/dist/commands/pod/index.d.ts.map +1 -0
- package/dist/commands/pod/index.js +29 -0
- package/dist/commands/pod/index.js.map +1 -0
- package/dist/commands/pod/info.d.ts +9 -0
- package/dist/commands/pod/info.d.ts.map +1 -0
- package/dist/commands/pod/info.js +196 -0
- package/dist/commands/pod/info.js.map +1 -0
- package/dist/commands/pod/init.d.ts +9 -0
- package/dist/commands/pod/init.d.ts.map +1 -0
- package/dist/commands/pod/init.js +251 -0
- package/dist/commands/pod/init.js.map +1 -0
- package/dist/commands/pod/query.d.ts +9 -0
- package/dist/commands/pod/query.d.ts.map +1 -0
- package/dist/commands/pod/query.js +169 -0
- package/dist/commands/pod/query.js.map +1 -0
- package/dist/commands/pod 2.js +1017 -0
- package/dist/commands/pod.d.ts +28 -0
- package/dist/commands/pod.d.ts 2.map +1 -0
- package/dist/commands/pod.d.ts.map +1 -0
- package/dist/commands/pod.js +1031 -0
- package/dist/commands/pod.js 2.map +1 -0
- package/dist/commands/pod.js.map +1 -0
- package/dist/commands/serve.d.ts +33 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +74 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +275 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
- package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
- package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
- package/dist/lib/fhir-converter/converters-clinical.js +391 -0
- package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
- package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
- package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
- package/dist/lib/fhir-converter/converters-demographics.js +242 -0
- package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
- package/dist/lib/fhir-converter/index.d.ts +36 -0
- package/dist/lib/fhir-converter/index.d.ts.map +1 -0
- package/dist/lib/fhir-converter/index.js +187 -0
- package/dist/lib/fhir-converter/index.js.map +1 -0
- package/dist/lib/fhir-converter/types.d.ts +77 -0
- package/dist/lib/fhir-converter/types.d.ts.map +1 -0
- package/dist/lib/fhir-converter/types.js +236 -0
- package/dist/lib/fhir-converter/types.js.map +1 -0
- package/dist/lib/fhir-converter.d.ts +62 -0
- package/dist/lib/fhir-converter.d.ts.map +1 -0
- package/dist/lib/fhir-converter.js +1474 -0
- package/dist/lib/fhir-converter.js.map +1 -0
- package/dist/lib/mcp/audit.d.ts +24 -0
- package/dist/lib/mcp/audit.d.ts.map +1 -0
- package/dist/lib/mcp/audit.js +85 -0
- package/dist/lib/mcp/audit.js.map +1 -0
- package/dist/lib/mcp/server.d.ts +38 -0
- package/dist/lib/mcp/server.d.ts.map +1 -0
- package/dist/lib/mcp/server.js +172 -0
- package/dist/lib/mcp/server.js.map +1 -0
- package/dist/lib/mcp/tools.d.ts +47 -0
- package/dist/lib/mcp/tools.d.ts.map +1 -0
- package/dist/lib/mcp/tools.js +547 -0
- package/dist/lib/mcp/tools.js.map +1 -0
- package/dist/lib/output.d.ts +26 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +64 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/shacl-validator.d.ts +53 -0
- package/dist/lib/shacl-validator.d.ts.map +1 -0
- package/dist/lib/shacl-validator.js +245 -0
- package/dist/lib/shacl-validator.js.map +1 -0
- package/dist/lib/turtle-parser.d.ts +64 -0
- package/dist/lib/turtle-parser.d.ts.map +1 -0
- package/dist/lib/turtle-parser.js +236 -0
- package/dist/lib/turtle-parser.js.map +1 -0
- package/dist/shapes/checkup.shapes.ttl +1459 -0
- package/dist/shapes/clinical.shapes.ttl +1350 -0
- package/dist/shapes/clinical.ttl +1369 -0
- package/dist/shapes/core.shapes.ttl +450 -0
- package/dist/shapes/core.ttl +603 -0
- package/dist/shapes/coverage.shapes.ttl +214 -0
- package/dist/shapes/coverage.ttl +182 -0
- package/dist/shapes/health.shapes.ttl +697 -0
- package/dist/shapes/health.ttl +859 -0
- package/dist/shapes/pots.shapes.ttl +481 -0
- package/package.json +54 -0
- package/src/commands/capabilities.ts +235 -0
- package/src/commands/conformance.ts +447 -0
- package/src/commands/convert.ts +164 -0
- package/src/commands/pod/export.ts +85 -0
- package/src/commands/pod/helpers.ts +449 -0
- package/src/commands/pod/index.ts +32 -0
- package/src/commands/pod/info.ts +239 -0
- package/src/commands/pod/init.ts +273 -0
- package/src/commands/pod/query.ts +224 -0
- package/src/commands/serve.ts +92 -0
- package/src/commands/validate.ts +303 -0
- package/src/index.ts +58 -0
- package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
- package/src/lib/fhir-converter/converters-clinical.ts +446 -0
- package/src/lib/fhir-converter/converters-demographics.ts +270 -0
- package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
- package/src/lib/fhir-converter/index.ts +215 -0
- package/src/lib/fhir-converter/types.ts +318 -0
- package/src/lib/mcp/audit.ts +107 -0
- package/src/lib/mcp/server.ts +192 -0
- package/src/lib/mcp/tools.ts +668 -0
- package/src/lib/output.ts +76 -0
- package/src/lib/shacl-validator.ts +314 -0
- package/src/lib/turtle-parser.ts +277 -0
- package/src/shapes/checkup.shapes.ttl +1459 -0
- package/src/shapes/clinical.shapes.ttl +1350 -0
- package/src/shapes/clinical.ttl +1369 -0
- package/src/shapes/core.shapes.ttl +450 -0
- package/src/shapes/core.ttl +603 -0
- package/src/shapes/coverage.shapes.ttl +214 -0
- package/src/shapes/coverage.ttl +182 -0
- package/src/shapes/health.shapes.ttl +697 -0
- package/src/shapes/health.ttl +859 -0
- package/src/shapes/pots.shapes.ttl +481 -0
- package/test-fixtures/fhir-bundle-example.json +216 -0
- package/test-fixtures/fhir-medication-example.json +18 -0
- package/tests/cli.test.ts +126 -0
- package/tests/fhir-converter.test.ts +874 -0
- package/tests/mcp-server.test.ts +396 -0
- package/tests/pod.test.ts +400 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FHIR conversion utilities.
|
|
3
|
+
*
|
|
4
|
+
* Converts between FHIR R4 JSON and Cascade Protocol RDF (Turtle/JSON-LD).
|
|
5
|
+
*
|
|
6
|
+
* Supported FHIR R4 resource types:
|
|
7
|
+
* - MedicationStatement / MedicationRequest -> health:MedicationRecord
|
|
8
|
+
* - Condition -> health:ConditionRecord
|
|
9
|
+
* - AllergyIntolerance -> health:AllergyRecord
|
|
10
|
+
* - Observation (lab) -> health:LabResultRecord
|
|
11
|
+
* - Observation (vital) -> clinical:VitalSign
|
|
12
|
+
* - Patient -> cascade:PatientProfile
|
|
13
|
+
* - Immunization -> health:ImmunizationRecord
|
|
14
|
+
* - Coverage -> coverage:InsurancePlan
|
|
15
|
+
*
|
|
16
|
+
* Zero network calls. All conversion is local.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { Parser, Writer, DataFactory } from 'n3';
|
|
20
|
+
const { namedNode, literal, quad: makeQuad } = DataFactory;
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Namespace constants
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const NS = {
|
|
25
|
+
cascade: 'https://ns.cascadeprotocol.org/core/v1#',
|
|
26
|
+
health: 'https://ns.cascadeprotocol.org/health/v1#',
|
|
27
|
+
clinical: 'https://ns.cascadeprotocol.org/clinical/v1#',
|
|
28
|
+
coverage: 'https://ns.cascadeprotocol.org/coverage/v1#',
|
|
29
|
+
fhir: 'http://hl7.org/fhir/',
|
|
30
|
+
sct: 'http://snomed.info/sct/',
|
|
31
|
+
loinc: 'http://loinc.org/rdf#',
|
|
32
|
+
rxnorm: 'http://www.nlm.nih.gov/research/umls/rxnorm/',
|
|
33
|
+
icd10: 'http://hl7.org/fhir/sid/icd-10-cm/',
|
|
34
|
+
xsd: 'http://www.w3.org/2001/XMLSchema#',
|
|
35
|
+
prov: 'http://www.w3.org/ns/prov#',
|
|
36
|
+
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
37
|
+
};
|
|
38
|
+
/** Standard Turtle prefix block for all generated output. */
|
|
39
|
+
const TURTLE_PREFIXES = {
|
|
40
|
+
cascade: NS.cascade,
|
|
41
|
+
health: NS.health,
|
|
42
|
+
clinical: NS.clinical,
|
|
43
|
+
coverage: NS.coverage,
|
|
44
|
+
fhir: NS.fhir,
|
|
45
|
+
sct: NS.sct,
|
|
46
|
+
loinc: NS.loinc,
|
|
47
|
+
rxnorm: NS.rxnorm,
|
|
48
|
+
xsd: NS.xsd,
|
|
49
|
+
prov: NS.prov,
|
|
50
|
+
};
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// FHIR coding-system to Cascade namespace mapping
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
const CODING_SYSTEM_MAP = {
|
|
55
|
+
'http://www.nlm.nih.gov/research/umls/rxnorm': NS.rxnorm,
|
|
56
|
+
'urn:oid:2.16.840.1.113883.6.88': NS.rxnorm,
|
|
57
|
+
'http://snomed.info/sct': NS.sct,
|
|
58
|
+
'http://loinc.org': NS.loinc,
|
|
59
|
+
'http://hl7.org/fhir/sid/icd-10-cm': NS.icd10,
|
|
60
|
+
'http://hl7.org/fhir/sid/icd-10': NS.icd10,
|
|
61
|
+
};
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// FHIR vital-sign LOINC code mapping
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const VITAL_LOINC_CODES = {
|
|
66
|
+
'8480-6': { type: 'bloodPressureSystolic', name: 'Systolic Blood Pressure', unit: 'mmHg', snomedCode: '271649006' },
|
|
67
|
+
'8462-4': { type: 'bloodPressureDiastolic', name: 'Diastolic Blood Pressure', unit: 'mmHg', snomedCode: '271650006' },
|
|
68
|
+
'8867-4': { type: 'heartRate', name: 'Heart Rate', unit: 'bpm', snomedCode: '364075005' },
|
|
69
|
+
'9279-1': { type: 'respiratoryRate', name: 'Respiratory Rate', unit: 'breaths/min', snomedCode: '86290005' },
|
|
70
|
+
'8310-5': { type: 'bodyTemperature', name: 'Body Temperature', unit: 'degC', snomedCode: '386725007' },
|
|
71
|
+
'2708-6': { type: 'oxygenSaturation', name: 'Oxygen Saturation', unit: '%', snomedCode: '431314004' },
|
|
72
|
+
'29463-7': { type: 'bodyWeight', name: 'Body Weight', unit: 'kg', snomedCode: '27113001' },
|
|
73
|
+
'8302-2': { type: 'bodyHeight', name: 'Body Height', unit: 'cm', snomedCode: '50373000' },
|
|
74
|
+
'39156-5': { type: 'bmi', name: 'Body Mass Index', unit: 'kg/m2', snomedCode: '60621009' },
|
|
75
|
+
};
|
|
76
|
+
/** FHIR observation categories that indicate vital signs */
|
|
77
|
+
const VITAL_CATEGORIES = ['vital-signs', 'vital-sign'];
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Helper: date formatting
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
/**
|
|
82
|
+
* Ensure an ISO 8601 dateTime string with timezone.
|
|
83
|
+
* Bare dates (YYYY-MM-DD) get T00:00:00Z appended.
|
|
84
|
+
*/
|
|
85
|
+
function ensureDateTimeWithTz(dateStr) {
|
|
86
|
+
if (!dateStr)
|
|
87
|
+
return '';
|
|
88
|
+
// Already has time component with timezone
|
|
89
|
+
if (/T.+Z$/.test(dateStr) || /T.+[+-]\d{2}:\d{2}$/.test(dateStr)) {
|
|
90
|
+
return dateStr;
|
|
91
|
+
}
|
|
92
|
+
// Has time component but no timezone — append Z
|
|
93
|
+
if (/T/.test(dateStr)) {
|
|
94
|
+
return dateStr + 'Z';
|
|
95
|
+
}
|
|
96
|
+
// Date only — append midnight UTC
|
|
97
|
+
return dateStr + 'T00:00:00Z';
|
|
98
|
+
}
|
|
99
|
+
function extractCodings(codeableConcept) {
|
|
100
|
+
if (!codeableConcept)
|
|
101
|
+
return [];
|
|
102
|
+
const codings = [];
|
|
103
|
+
if (Array.isArray(codeableConcept.coding)) {
|
|
104
|
+
for (const c of codeableConcept.coding) {
|
|
105
|
+
if (c.system && c.code) {
|
|
106
|
+
codings.push({ system: c.system, code: c.code, display: c.display });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return codings;
|
|
111
|
+
}
|
|
112
|
+
function codeableConceptText(cc) {
|
|
113
|
+
if (!cc)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (cc.text)
|
|
116
|
+
return cc.text;
|
|
117
|
+
if (Array.isArray(cc.coding) && cc.coding.length > 0 && cc.coding[0].display) {
|
|
118
|
+
return cc.coding[0].display;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Quad-building helpers
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
function tripleStr(subject, predicate, value) {
|
|
126
|
+
return makeQuad(namedNode(subject), namedNode(predicate), literal(value));
|
|
127
|
+
}
|
|
128
|
+
function tripleTyped(subject, predicate, value, datatype) {
|
|
129
|
+
return makeQuad(namedNode(subject), namedNode(predicate), literal(value, namedNode(datatype)));
|
|
130
|
+
}
|
|
131
|
+
function tripleBool(subject, predicate, value) {
|
|
132
|
+
return makeQuad(namedNode(subject), namedNode(predicate), literal(String(value), namedNode(NS.xsd + 'boolean')));
|
|
133
|
+
}
|
|
134
|
+
function tripleInt(subject, predicate, value) {
|
|
135
|
+
return makeQuad(namedNode(subject), namedNode(predicate), literal(String(value), namedNode(NS.xsd + 'integer')));
|
|
136
|
+
}
|
|
137
|
+
function tripleDouble(subject, predicate, value) {
|
|
138
|
+
return makeQuad(namedNode(subject), namedNode(predicate), literal(String(value), namedNode(NS.xsd + 'double')));
|
|
139
|
+
}
|
|
140
|
+
function tripleRef(subject, predicate, object) {
|
|
141
|
+
return makeQuad(namedNode(subject), namedNode(predicate), namedNode(object));
|
|
142
|
+
}
|
|
143
|
+
function tripleType(subject, rdfType) {
|
|
144
|
+
return tripleRef(subject, NS.rdf + 'type', rdfType);
|
|
145
|
+
}
|
|
146
|
+
function tripleDateTime(subject, predicate, dateStr) {
|
|
147
|
+
return tripleTyped(subject, predicate, ensureDateTimeWithTz(dateStr), NS.xsd + 'dateTime');
|
|
148
|
+
}
|
|
149
|
+
function tripleDate(subject, predicate, dateStr) {
|
|
150
|
+
return tripleTyped(subject, predicate, dateStr, NS.xsd + 'date');
|
|
151
|
+
}
|
|
152
|
+
// Common triples every Cascade resource gets
|
|
153
|
+
function commonTriples(subject) {
|
|
154
|
+
return [
|
|
155
|
+
tripleRef(subject, NS.cascade + 'dataProvenance', NS.cascade + 'ClinicalGenerated'),
|
|
156
|
+
tripleStr(subject, NS.cascade + 'schemaVersion', '1.3'),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Quads -> Turtle serialization
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
function quadsToTurtle(quads) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const writer = new Writer({ prefixes: TURTLE_PREFIXES });
|
|
165
|
+
for (const q of quads) {
|
|
166
|
+
writer.addQuad(q);
|
|
167
|
+
}
|
|
168
|
+
writer.end((error, result) => {
|
|
169
|
+
if (error)
|
|
170
|
+
reject(error);
|
|
171
|
+
else
|
|
172
|
+
resolve(result);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Quads -> JSON-LD object (lightweight, no @context resolution)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
function quadsToJsonLd(quads, _cascadeType) {
|
|
180
|
+
// Build a simple JSON-LD representation grouped by subject
|
|
181
|
+
const subjects = new Map();
|
|
182
|
+
for (const q of quads) {
|
|
183
|
+
const subj = q.subject.value;
|
|
184
|
+
if (!subjects.has(subj)) {
|
|
185
|
+
subjects.set(subj, {
|
|
186
|
+
'@context': 'https://ns.cascadeprotocol.org/context/v1/cascade.jsonld',
|
|
187
|
+
'@id': subj,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const obj = subjects.get(subj);
|
|
191
|
+
const pred = q.predicate.value;
|
|
192
|
+
if (pred === NS.rdf + 'type') {
|
|
193
|
+
obj['@type'] = q.object.value;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Compact the predicate using known prefixes
|
|
197
|
+
let key = pred;
|
|
198
|
+
for (const [prefix, uri] of Object.entries(TURTLE_PREFIXES)) {
|
|
199
|
+
if (pred.startsWith(uri)) {
|
|
200
|
+
key = `${prefix}:${pred.slice(uri.length)}`;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Handle object vs literal
|
|
205
|
+
if (q.object.termType === 'NamedNode') {
|
|
206
|
+
// Check if this is a provenance reference
|
|
207
|
+
let idVal = q.object.value;
|
|
208
|
+
for (const [prefix, uri] of Object.entries(TURTLE_PREFIXES)) {
|
|
209
|
+
if (idVal.startsWith(uri)) {
|
|
210
|
+
idVal = `${prefix}:${idVal.slice(uri.length)}`;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
obj[key] = { '@id': idVal };
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// Literal
|
|
218
|
+
const dt = q.object.datatype?.value;
|
|
219
|
+
if (dt === NS.xsd + 'dateTime' || dt === NS.xsd + 'date') {
|
|
220
|
+
obj[key] = { '@value': q.object.value, '@type': dt === NS.xsd + 'dateTime' ? 'xsd:dateTime' : 'xsd:date' };
|
|
221
|
+
}
|
|
222
|
+
else if (dt === NS.xsd + 'boolean') {
|
|
223
|
+
obj[key] = q.object.value === 'true';
|
|
224
|
+
}
|
|
225
|
+
else if (dt === NS.xsd + 'integer') {
|
|
226
|
+
obj[key] = parseInt(q.object.value, 10);
|
|
227
|
+
}
|
|
228
|
+
else if (dt === NS.xsd + 'double' || dt === NS.xsd + 'decimal') {
|
|
229
|
+
obj[key] = parseFloat(q.object.value);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
obj[key] = q.object.value;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const entries = Array.from(subjects.values());
|
|
237
|
+
return entries.length === 1 ? entries[0] : entries;
|
|
238
|
+
}
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Per-resource FHIR -> Cascade converters
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
function convertMedicationStatement(resource) {
|
|
243
|
+
const warnings = [];
|
|
244
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
245
|
+
const quads = [];
|
|
246
|
+
quads.push(tripleType(subjectUri, NS.health + 'MedicationRecord'));
|
|
247
|
+
quads.push(...commonTriples(subjectUri));
|
|
248
|
+
// Medication name
|
|
249
|
+
const medName = codeableConceptText(resource.medicationCodeableConcept)
|
|
250
|
+
?? resource.medicationReference?.display
|
|
251
|
+
?? 'Unknown Medication';
|
|
252
|
+
quads.push(tripleStr(subjectUri, NS.health + 'medicationName', medName));
|
|
253
|
+
// isActive from FHIR status
|
|
254
|
+
const status = resource.status;
|
|
255
|
+
const isActive = status === 'active' || status === 'intended' || status === 'on-hold';
|
|
256
|
+
quads.push(tripleBool(subjectUri, NS.health + 'isActive', isActive));
|
|
257
|
+
// Drug codes
|
|
258
|
+
const codings = extractCodings(resource.medicationCodeableConcept);
|
|
259
|
+
for (const coding of codings) {
|
|
260
|
+
const nsUri = CODING_SYSTEM_MAP[coding.system];
|
|
261
|
+
if (nsUri) {
|
|
262
|
+
quads.push(tripleRef(subjectUri, NS.clinical + 'drugCode', nsUri + coding.code));
|
|
263
|
+
// If RxNorm, also emit health:rxNormCode
|
|
264
|
+
if (nsUri === NS.rxnorm) {
|
|
265
|
+
quads.push(tripleRef(subjectUri, NS.health + 'rxNormCode', nsUri + coding.code));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
warnings.push(`Unknown coding system: ${coding.system} (code ${coding.code})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Dosage
|
|
273
|
+
const dosage = Array.isArray(resource.dosage) ? resource.dosage[0] : undefined;
|
|
274
|
+
if (dosage) {
|
|
275
|
+
if (dosage.text) {
|
|
276
|
+
quads.push(tripleStr(subjectUri, NS.health + 'dose', dosage.text));
|
|
277
|
+
}
|
|
278
|
+
if (dosage.route?.text) {
|
|
279
|
+
quads.push(tripleStr(subjectUri, NS.health + 'route', dosage.route.text));
|
|
280
|
+
}
|
|
281
|
+
else if (dosage.route?.coding?.[0]?.display) {
|
|
282
|
+
quads.push(tripleStr(subjectUri, NS.health + 'route', dosage.route.coding[0].display));
|
|
283
|
+
}
|
|
284
|
+
if (dosage.timing?.repeat?.frequency) {
|
|
285
|
+
const freq = dosage.timing.repeat.frequency;
|
|
286
|
+
const periodUnit = dosage.timing.repeat.periodUnit ?? 'd';
|
|
287
|
+
const unitLabel = periodUnit === 'd' ? 'daily' : periodUnit === 'wk' ? 'weekly' : periodUnit;
|
|
288
|
+
const freqText = freq === 1 ? `once ${unitLabel}` : `${freq} times ${unitLabel}`;
|
|
289
|
+
quads.push(tripleStr(subjectUri, NS.health + 'frequency', freqText));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Effective period
|
|
293
|
+
if (resource.effectivePeriod?.start) {
|
|
294
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'startDate', resource.effectivePeriod.start));
|
|
295
|
+
}
|
|
296
|
+
else if (resource.effectiveDateTime) {
|
|
297
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'startDate', resource.effectiveDateTime));
|
|
298
|
+
}
|
|
299
|
+
if (resource.effectivePeriod?.end) {
|
|
300
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'endDate', resource.effectivePeriod.end));
|
|
301
|
+
}
|
|
302
|
+
// Provenance class — based on resource type
|
|
303
|
+
const fhirResourceType = resource.resourceType;
|
|
304
|
+
if (fhirResourceType === 'MedicationStatement') {
|
|
305
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'sourceFhirResourceType', 'MedicationStatement'));
|
|
306
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'clinicalIntent', 'reportedUse'));
|
|
307
|
+
}
|
|
308
|
+
else if (fhirResourceType === 'MedicationRequest') {
|
|
309
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'sourceFhirResourceType', 'MedicationRequest'));
|
|
310
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'clinicalIntent', 'prescribed'));
|
|
311
|
+
}
|
|
312
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'provenanceClass', 'imported'));
|
|
313
|
+
// Source record ID
|
|
314
|
+
if (resource.id) {
|
|
315
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
316
|
+
}
|
|
317
|
+
// Notes
|
|
318
|
+
if (resource.note && Array.isArray(resource.note)) {
|
|
319
|
+
const noteText = resource.note.map((n) => n.text).filter(Boolean).join('; ');
|
|
320
|
+
if (noteText)
|
|
321
|
+
quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
turtle: '', // filled by caller
|
|
325
|
+
warnings,
|
|
326
|
+
resourceType: fhirResourceType,
|
|
327
|
+
cascadeType: 'health:MedicationRecord',
|
|
328
|
+
jsonld: quadsToJsonLd(quads, 'health:MedicationRecord'),
|
|
329
|
+
_quads: quads,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function convertCondition(resource) {
|
|
333
|
+
const warnings = [];
|
|
334
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
335
|
+
const quads = [];
|
|
336
|
+
quads.push(tripleType(subjectUri, NS.health + 'ConditionRecord'));
|
|
337
|
+
quads.push(...commonTriples(subjectUri));
|
|
338
|
+
// Condition name
|
|
339
|
+
const condName = codeableConceptText(resource.code) ?? 'Unknown Condition';
|
|
340
|
+
quads.push(tripleStr(subjectUri, NS.health + 'conditionName', condName));
|
|
341
|
+
// Status — map FHIR clinicalStatus to Cascade health:status
|
|
342
|
+
const clinicalStatus = resource.clinicalStatus?.coding?.[0]?.code ?? 'active';
|
|
343
|
+
quads.push(tripleStr(subjectUri, NS.health + 'status', clinicalStatus));
|
|
344
|
+
// Onset date
|
|
345
|
+
if (resource.onsetDateTime) {
|
|
346
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'onsetDate', resource.onsetDateTime));
|
|
347
|
+
}
|
|
348
|
+
else if (resource.onsetPeriod?.start) {
|
|
349
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'onsetDate', resource.onsetPeriod.start));
|
|
350
|
+
}
|
|
351
|
+
// Abatement date
|
|
352
|
+
if (resource.abatementDateTime) {
|
|
353
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'abatementDate', resource.abatementDateTime));
|
|
354
|
+
}
|
|
355
|
+
// Coding: ICD-10 and SNOMED
|
|
356
|
+
const codings = extractCodings(resource.code);
|
|
357
|
+
for (const coding of codings) {
|
|
358
|
+
const nsUri = CODING_SYSTEM_MAP[coding.system];
|
|
359
|
+
if (nsUri === NS.icd10) {
|
|
360
|
+
quads.push(tripleRef(subjectUri, NS.health + 'icd10Code', nsUri + coding.code));
|
|
361
|
+
}
|
|
362
|
+
else if (nsUri === NS.sct) {
|
|
363
|
+
quads.push(tripleRef(subjectUri, NS.health + 'snomedCode', nsUri + coding.code));
|
|
364
|
+
}
|
|
365
|
+
else if (nsUri) {
|
|
366
|
+
// Other code system
|
|
367
|
+
warnings.push(`Condition code from non-standard system: ${coding.system}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Source record ID
|
|
371
|
+
if (resource.id) {
|
|
372
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
373
|
+
}
|
|
374
|
+
// Notes
|
|
375
|
+
if (resource.note && Array.isArray(resource.note)) {
|
|
376
|
+
const noteText = resource.note.map((n) => n.text).filter(Boolean).join('; ');
|
|
377
|
+
if (noteText)
|
|
378
|
+
quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
turtle: '',
|
|
382
|
+
jsonld: quadsToJsonLd(quads, 'health:ConditionRecord'),
|
|
383
|
+
warnings,
|
|
384
|
+
resourceType: 'Condition',
|
|
385
|
+
cascadeType: 'health:ConditionRecord',
|
|
386
|
+
_quads: quads,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function convertAllergyIntolerance(resource) {
|
|
390
|
+
const warnings = [];
|
|
391
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
392
|
+
const quads = [];
|
|
393
|
+
quads.push(tripleType(subjectUri, NS.health + 'AllergyRecord'));
|
|
394
|
+
quads.push(...commonTriples(subjectUri));
|
|
395
|
+
// Allergen
|
|
396
|
+
const allergen = codeableConceptText(resource.code) ?? 'Unknown Allergen';
|
|
397
|
+
quads.push(tripleStr(subjectUri, NS.health + 'allergen', allergen));
|
|
398
|
+
// Category
|
|
399
|
+
if (Array.isArray(resource.category) && resource.category.length > 0) {
|
|
400
|
+
quads.push(tripleStr(subjectUri, NS.health + 'allergyCategory', resource.category[0]));
|
|
401
|
+
}
|
|
402
|
+
// Reaction
|
|
403
|
+
if (Array.isArray(resource.reaction) && resource.reaction.length > 0) {
|
|
404
|
+
const manifestations = resource.reaction
|
|
405
|
+
.flatMap((r) => r.manifestation ?? [])
|
|
406
|
+
.map((m) => codeableConceptText(m))
|
|
407
|
+
.filter(Boolean);
|
|
408
|
+
if (manifestations.length > 0) {
|
|
409
|
+
quads.push(tripleStr(subjectUri, NS.health + 'reaction', manifestations.join(', ')));
|
|
410
|
+
}
|
|
411
|
+
// Severity from the first reaction
|
|
412
|
+
const severity = resource.reaction[0]?.severity;
|
|
413
|
+
if (severity) {
|
|
414
|
+
const severityMap = {
|
|
415
|
+
mild: 'mild',
|
|
416
|
+
moderate: 'moderate',
|
|
417
|
+
severe: 'severe',
|
|
418
|
+
};
|
|
419
|
+
quads.push(tripleStr(subjectUri, NS.health + 'allergySeverity', severityMap[severity] ?? severity));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Criticality -> severity mapping if no reaction severity
|
|
423
|
+
if (resource.criticality && !(Array.isArray(resource.reaction) && resource.reaction[0]?.severity)) {
|
|
424
|
+
const critMap = {
|
|
425
|
+
low: 'mild',
|
|
426
|
+
high: 'severe',
|
|
427
|
+
'unable-to-assess': 'moderate',
|
|
428
|
+
};
|
|
429
|
+
quads.push(tripleStr(subjectUri, NS.health + 'allergySeverity', critMap[resource.criticality] ?? resource.criticality));
|
|
430
|
+
}
|
|
431
|
+
// Onset date
|
|
432
|
+
if (resource.onsetDateTime) {
|
|
433
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'onsetDate', resource.onsetDateTime));
|
|
434
|
+
}
|
|
435
|
+
// Source record ID
|
|
436
|
+
if (resource.id) {
|
|
437
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
438
|
+
}
|
|
439
|
+
// Notes
|
|
440
|
+
if (resource.note && Array.isArray(resource.note)) {
|
|
441
|
+
const noteText = resource.note.map((n) => n.text).filter(Boolean).join('; ');
|
|
442
|
+
if (noteText)
|
|
443
|
+
quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
turtle: '',
|
|
447
|
+
jsonld: quadsToJsonLd(quads, 'health:AllergyRecord'),
|
|
448
|
+
warnings,
|
|
449
|
+
resourceType: 'AllergyIntolerance',
|
|
450
|
+
cascadeType: 'health:AllergyRecord',
|
|
451
|
+
_quads: quads,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function isVitalSignObservation(resource) {
|
|
455
|
+
// Check category for vital-signs
|
|
456
|
+
if (Array.isArray(resource.category)) {
|
|
457
|
+
for (const cat of resource.category) {
|
|
458
|
+
if (Array.isArray(cat.coding)) {
|
|
459
|
+
for (const c of cat.coding) {
|
|
460
|
+
if (VITAL_CATEGORIES.includes(c.code))
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Check if code has a known vital-sign LOINC
|
|
467
|
+
const codings = extractCodings(resource.code);
|
|
468
|
+
for (const c of codings) {
|
|
469
|
+
if (c.system === 'http://loinc.org' && VITAL_LOINC_CODES[c.code])
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
function convertObservationLab(resource) {
|
|
475
|
+
const warnings = [];
|
|
476
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
477
|
+
const quads = [];
|
|
478
|
+
quads.push(tripleType(subjectUri, NS.health + 'LabResultRecord'));
|
|
479
|
+
quads.push(...commonTriples(subjectUri));
|
|
480
|
+
// Test name
|
|
481
|
+
const testName = codeableConceptText(resource.code) ?? 'Unknown Lab Test';
|
|
482
|
+
quads.push(tripleStr(subjectUri, NS.health + 'testName', testName));
|
|
483
|
+
// Result value
|
|
484
|
+
if (resource.valueQuantity) {
|
|
485
|
+
quads.push(tripleStr(subjectUri, NS.health + 'resultValue', String(resource.valueQuantity.value)));
|
|
486
|
+
if (resource.valueQuantity.unit) {
|
|
487
|
+
quads.push(tripleStr(subjectUri, NS.health + 'resultUnit', resource.valueQuantity.unit));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else if (resource.valueString) {
|
|
491
|
+
quads.push(tripleStr(subjectUri, NS.health + 'resultValue', resource.valueString));
|
|
492
|
+
}
|
|
493
|
+
else if (resource.valueCodeableConcept) {
|
|
494
|
+
const valText = codeableConceptText(resource.valueCodeableConcept) ?? '';
|
|
495
|
+
quads.push(tripleStr(subjectUri, NS.health + 'resultValue', valText));
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
quads.push(tripleStr(subjectUri, NS.health + 'resultValue', ''));
|
|
499
|
+
warnings.push('No result value found in Observation resource');
|
|
500
|
+
}
|
|
501
|
+
// Interpretation
|
|
502
|
+
if (resource.interpretation && Array.isArray(resource.interpretation) && resource.interpretation.length > 0) {
|
|
503
|
+
const interpCode = resource.interpretation[0]?.coding?.[0]?.code ?? 'unknown';
|
|
504
|
+
const interpMap = {
|
|
505
|
+
N: 'normal', H: 'abnormal', L: 'abnormal', A: 'abnormal',
|
|
506
|
+
HH: 'critical', LL: 'critical', AA: 'critical',
|
|
507
|
+
HU: 'critical', LU: 'critical',
|
|
508
|
+
};
|
|
509
|
+
quads.push(tripleStr(subjectUri, NS.health + 'interpretation', interpMap[interpCode] ?? 'unknown'));
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
quads.push(tripleStr(subjectUri, NS.health + 'interpretation', 'unknown'));
|
|
513
|
+
}
|
|
514
|
+
// Performed date
|
|
515
|
+
const effectiveDate = resource.effectiveDateTime ?? resource.effectivePeriod?.start ?? resource.issued;
|
|
516
|
+
if (effectiveDate) {
|
|
517
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'performedDate', effectiveDate));
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
warnings.push('No effective date found in Observation resource');
|
|
521
|
+
}
|
|
522
|
+
// LOINC test code
|
|
523
|
+
const codings = extractCodings(resource.code);
|
|
524
|
+
for (const c of codings) {
|
|
525
|
+
if (c.system === 'http://loinc.org') {
|
|
526
|
+
quads.push(tripleRef(subjectUri, NS.health + 'testCode', NS.loinc + c.code));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Category
|
|
530
|
+
if (Array.isArray(resource.category)) {
|
|
531
|
+
for (const cat of resource.category) {
|
|
532
|
+
if (Array.isArray(cat.coding)) {
|
|
533
|
+
for (const c of cat.coding) {
|
|
534
|
+
if (c.code && c.code !== 'laboratory') {
|
|
535
|
+
quads.push(tripleStr(subjectUri, NS.health + 'labCategory', c.code));
|
|
536
|
+
}
|
|
537
|
+
else if (c.code === 'laboratory') {
|
|
538
|
+
// Standard lab category — may want to use text or further coding
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (cat.text) {
|
|
543
|
+
quads.push(tripleStr(subjectUri, NS.health + 'labCategory', cat.text));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Reference range
|
|
548
|
+
if (Array.isArray(resource.referenceRange) && resource.referenceRange.length > 0) {
|
|
549
|
+
const rr = resource.referenceRange[0];
|
|
550
|
+
const parts = [];
|
|
551
|
+
if (rr.low?.value !== undefined)
|
|
552
|
+
parts.push(String(rr.low.value));
|
|
553
|
+
if (rr.high?.value !== undefined)
|
|
554
|
+
parts.push(String(rr.high.value));
|
|
555
|
+
const unit = rr.low?.unit ?? rr.high?.unit ?? '';
|
|
556
|
+
if (parts.length === 2) {
|
|
557
|
+
quads.push(tripleStr(subjectUri, NS.health + 'referenceRange', `${parts[0]}-${parts[1]} ${unit}`.trim()));
|
|
558
|
+
}
|
|
559
|
+
else if (rr.text) {
|
|
560
|
+
quads.push(tripleStr(subjectUri, NS.health + 'referenceRange', rr.text));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Source record ID
|
|
564
|
+
if (resource.id) {
|
|
565
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
turtle: '',
|
|
569
|
+
jsonld: quadsToJsonLd(quads, 'health:LabResultRecord'),
|
|
570
|
+
warnings,
|
|
571
|
+
resourceType: 'Observation',
|
|
572
|
+
cascadeType: 'health:LabResultRecord',
|
|
573
|
+
_quads: quads,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function convertObservationVital(resource) {
|
|
577
|
+
const warnings = [];
|
|
578
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
579
|
+
const quads = [];
|
|
580
|
+
quads.push(tripleType(subjectUri, NS.clinical + 'VitalSign'));
|
|
581
|
+
quads.push(...commonTriples(subjectUri));
|
|
582
|
+
// Identify vital type from LOINC code
|
|
583
|
+
const codings = extractCodings(resource.code);
|
|
584
|
+
let vitalInfo;
|
|
585
|
+
for (const c of codings) {
|
|
586
|
+
if (c.system === 'http://loinc.org' && VITAL_LOINC_CODES[c.code]) {
|
|
587
|
+
vitalInfo = VITAL_LOINC_CODES[c.code];
|
|
588
|
+
quads.push(tripleRef(subjectUri, NS.clinical + 'loincCode', NS.loinc + c.code));
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (vitalInfo) {
|
|
593
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'vitalType', vitalInfo.type));
|
|
594
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'vitalTypeName', vitalInfo.name));
|
|
595
|
+
quads.push(tripleRef(subjectUri, NS.clinical + 'snomedCode', NS.sct + vitalInfo.snomedCode));
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
const name = codeableConceptText(resource.code) ?? 'Unknown Vital';
|
|
599
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'vitalType', name.toLowerCase().replace(/\s+/g, '_')));
|
|
600
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'vitalTypeName', name));
|
|
601
|
+
warnings.push(`Unknown vital sign LOINC code — using display name: ${name}`);
|
|
602
|
+
}
|
|
603
|
+
// Value
|
|
604
|
+
if (resource.valueQuantity) {
|
|
605
|
+
quads.push(tripleDouble(subjectUri, NS.clinical + 'value', resource.valueQuantity.value));
|
|
606
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'unit', resource.valueQuantity.unit ?? vitalInfo?.unit ?? ''));
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
warnings.push('No valueQuantity found in vital sign Observation');
|
|
610
|
+
}
|
|
611
|
+
// Effective date
|
|
612
|
+
const effectiveDate = resource.effectiveDateTime ?? resource.effectivePeriod?.start;
|
|
613
|
+
if (effectiveDate) {
|
|
614
|
+
quads.push(tripleDateTime(subjectUri, NS.clinical + 'effectiveDate', effectiveDate));
|
|
615
|
+
}
|
|
616
|
+
// Reference range
|
|
617
|
+
if (Array.isArray(resource.referenceRange) && resource.referenceRange.length > 0) {
|
|
618
|
+
const rr = resource.referenceRange[0];
|
|
619
|
+
if (rr.low?.value !== undefined) {
|
|
620
|
+
quads.push(tripleDouble(subjectUri, NS.clinical + 'referenceRangeLow', rr.low.value));
|
|
621
|
+
}
|
|
622
|
+
if (rr.high?.value !== undefined) {
|
|
623
|
+
quads.push(tripleDouble(subjectUri, NS.clinical + 'referenceRangeHigh', rr.high.value));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Interpretation
|
|
627
|
+
if (resource.interpretation && Array.isArray(resource.interpretation) && resource.interpretation.length > 0) {
|
|
628
|
+
const interpCode = resource.interpretation[0]?.coding?.[0]?.code ?? 'unknown';
|
|
629
|
+
const interpMap = {
|
|
630
|
+
N: 'normal', H: 'high', L: 'low', A: 'abnormal',
|
|
631
|
+
HH: 'critical', LL: 'critical',
|
|
632
|
+
};
|
|
633
|
+
quads.push(tripleStr(subjectUri, NS.clinical + 'interpretation', interpMap[interpCode] ?? interpCode));
|
|
634
|
+
}
|
|
635
|
+
// Source record ID
|
|
636
|
+
if (resource.id) {
|
|
637
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
turtle: '',
|
|
641
|
+
jsonld: quadsToJsonLd(quads, 'clinical:VitalSign'),
|
|
642
|
+
warnings,
|
|
643
|
+
resourceType: 'Observation',
|
|
644
|
+
cascadeType: 'clinical:VitalSign',
|
|
645
|
+
_quads: quads,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function convertPatient(resource) {
|
|
649
|
+
const warnings = [];
|
|
650
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
651
|
+
const quads = [];
|
|
652
|
+
quads.push(tripleType(subjectUri, NS.cascade + 'PatientProfile'));
|
|
653
|
+
quads.push(...commonTriples(subjectUri));
|
|
654
|
+
// Date of birth
|
|
655
|
+
if (resource.birthDate) {
|
|
656
|
+
quads.push(tripleDate(subjectUri, NS.cascade + 'dateOfBirth', resource.birthDate));
|
|
657
|
+
// Compute age
|
|
658
|
+
const dob = new Date(resource.birthDate);
|
|
659
|
+
const now = new Date();
|
|
660
|
+
let age = now.getFullYear() - dob.getFullYear();
|
|
661
|
+
const monthDiff = now.getMonth() - dob.getMonth();
|
|
662
|
+
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < dob.getDate())) {
|
|
663
|
+
age--;
|
|
664
|
+
}
|
|
665
|
+
quads.push(tripleInt(subjectUri, NS.cascade + 'computedAge', age));
|
|
666
|
+
// Age group
|
|
667
|
+
let ageGroup;
|
|
668
|
+
if (age < 18)
|
|
669
|
+
ageGroup = 'pediatric';
|
|
670
|
+
else if (age < 40)
|
|
671
|
+
ageGroup = 'young_adult';
|
|
672
|
+
else if (age < 65)
|
|
673
|
+
ageGroup = 'adult';
|
|
674
|
+
else
|
|
675
|
+
ageGroup = 'senior';
|
|
676
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'ageGroup', ageGroup));
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
warnings.push('No birthDate found in Patient resource');
|
|
680
|
+
}
|
|
681
|
+
// Biological sex
|
|
682
|
+
if (resource.gender) {
|
|
683
|
+
const genderMap = {
|
|
684
|
+
male: 'male',
|
|
685
|
+
female: 'female',
|
|
686
|
+
other: 'intersex',
|
|
687
|
+
unknown: 'intersex',
|
|
688
|
+
};
|
|
689
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'biologicalSex', genderMap[resource.gender] ?? resource.gender));
|
|
690
|
+
}
|
|
691
|
+
// Address
|
|
692
|
+
if (Array.isArray(resource.address) && resource.address.length > 0) {
|
|
693
|
+
const addr = resource.address[0];
|
|
694
|
+
// Emit address fields directly on the subject since we cannot easily do blank nodes with n3 quads
|
|
695
|
+
// We will note this simplification
|
|
696
|
+
if (addr.city)
|
|
697
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'addressCity', addr.city));
|
|
698
|
+
if (addr.state)
|
|
699
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'addressState', addr.state));
|
|
700
|
+
if (addr.postalCode)
|
|
701
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'addressPostalCode', addr.postalCode));
|
|
702
|
+
if (addr.country)
|
|
703
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'addressCountry', addr.country));
|
|
704
|
+
if (Array.isArray(addr.line)) {
|
|
705
|
+
for (const line of addr.line) {
|
|
706
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'addressLine', line));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
warnings.push('Patient address flattened onto profile (blank node structure simplified)');
|
|
710
|
+
}
|
|
711
|
+
// Marital status
|
|
712
|
+
if (resource.maritalStatus) {
|
|
713
|
+
const maritalText = codeableConceptText(resource.maritalStatus);
|
|
714
|
+
if (maritalText) {
|
|
715
|
+
const maritalMap = {
|
|
716
|
+
S: 'single', M: 'married', D: 'divorced', W: 'widowed',
|
|
717
|
+
A: 'separated', T: 'domestic_partnership', UNK: 'prefer_not_to_say',
|
|
718
|
+
// Display text mappings
|
|
719
|
+
'Never Married': 'single', 'Married': 'married', 'Divorced': 'divorced',
|
|
720
|
+
'Widowed': 'widowed', 'Separated': 'separated',
|
|
721
|
+
};
|
|
722
|
+
const code = resource.maritalStatus.coding?.[0]?.code;
|
|
723
|
+
const mapped = maritalMap[code] ?? maritalMap[maritalText] ?? maritalText.toLowerCase();
|
|
724
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'maritalStatus', mapped));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Profile ID
|
|
728
|
+
if (resource.id) {
|
|
729
|
+
quads.push(tripleStr(subjectUri, NS.cascade + 'profileId', resource.id));
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
turtle: '',
|
|
733
|
+
jsonld: quadsToJsonLd(quads, 'cascade:PatientProfile'),
|
|
734
|
+
warnings,
|
|
735
|
+
resourceType: 'Patient',
|
|
736
|
+
cascadeType: 'cascade:PatientProfile',
|
|
737
|
+
_quads: quads,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function convertImmunization(resource) {
|
|
741
|
+
const warnings = [];
|
|
742
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
743
|
+
const quads = [];
|
|
744
|
+
quads.push(tripleType(subjectUri, NS.health + 'ImmunizationRecord'));
|
|
745
|
+
quads.push(...commonTriples(subjectUri));
|
|
746
|
+
// Vaccine name
|
|
747
|
+
const vaccineName = codeableConceptText(resource.vaccineCode) ?? 'Unknown Vaccine';
|
|
748
|
+
quads.push(tripleStr(subjectUri, NS.health + 'vaccineName', vaccineName));
|
|
749
|
+
// Administration date
|
|
750
|
+
if (resource.occurrenceDateTime) {
|
|
751
|
+
quads.push(tripleDateTime(subjectUri, NS.health + 'administrationDate', resource.occurrenceDateTime));
|
|
752
|
+
}
|
|
753
|
+
else if (resource.occurrenceString) {
|
|
754
|
+
warnings.push(`Immunization date is a string: ${resource.occurrenceString}`);
|
|
755
|
+
}
|
|
756
|
+
// Status
|
|
757
|
+
quads.push(tripleStr(subjectUri, NS.health + 'status', resource.status ?? 'completed'));
|
|
758
|
+
// Vaccine code (CVX)
|
|
759
|
+
const codings = extractCodings(resource.vaccineCode);
|
|
760
|
+
for (const c of codings) {
|
|
761
|
+
if (c.system === 'http://hl7.org/fhir/sid/cvx' || c.system === 'urn:oid:2.16.840.1.113883.12.292') {
|
|
762
|
+
quads.push(tripleStr(subjectUri, NS.health + 'vaccineCode', `CVX-${c.code}`));
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// Manufacturer
|
|
767
|
+
if (resource.manufacturer?.display) {
|
|
768
|
+
quads.push(tripleStr(subjectUri, NS.health + 'manufacturer', resource.manufacturer.display));
|
|
769
|
+
}
|
|
770
|
+
// Lot number
|
|
771
|
+
if (resource.lotNumber) {
|
|
772
|
+
quads.push(tripleStr(subjectUri, NS.health + 'lotNumber', resource.lotNumber));
|
|
773
|
+
}
|
|
774
|
+
// Dose quantity
|
|
775
|
+
if (resource.doseQuantity) {
|
|
776
|
+
const qty = `${resource.doseQuantity.value} ${resource.doseQuantity.unit ?? ''}`.trim();
|
|
777
|
+
quads.push(tripleStr(subjectUri, NS.health + 'doseQuantity', qty));
|
|
778
|
+
}
|
|
779
|
+
// Route
|
|
780
|
+
if (resource.route) {
|
|
781
|
+
const routeText = codeableConceptText(resource.route);
|
|
782
|
+
if (routeText)
|
|
783
|
+
quads.push(tripleStr(subjectUri, NS.health + 'route', routeText));
|
|
784
|
+
}
|
|
785
|
+
// Site
|
|
786
|
+
if (resource.site) {
|
|
787
|
+
const siteText = codeableConceptText(resource.site);
|
|
788
|
+
if (siteText)
|
|
789
|
+
quads.push(tripleStr(subjectUri, NS.health + 'site', siteText));
|
|
790
|
+
}
|
|
791
|
+
// Performer
|
|
792
|
+
if (Array.isArray(resource.performer) && resource.performer.length > 0) {
|
|
793
|
+
const performer = resource.performer[0]?.actor?.display;
|
|
794
|
+
if (performer)
|
|
795
|
+
quads.push(tripleStr(subjectUri, NS.health + 'administeringProvider', performer));
|
|
796
|
+
}
|
|
797
|
+
// Location
|
|
798
|
+
if (resource.location?.display) {
|
|
799
|
+
quads.push(tripleStr(subjectUri, NS.health + 'administeringLocation', resource.location.display));
|
|
800
|
+
}
|
|
801
|
+
// Notes
|
|
802
|
+
if (resource.note && Array.isArray(resource.note)) {
|
|
803
|
+
const noteText = resource.note.map((n) => n.text).filter(Boolean).join('; ');
|
|
804
|
+
if (noteText)
|
|
805
|
+
quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
|
|
806
|
+
}
|
|
807
|
+
// Source record ID
|
|
808
|
+
if (resource.id) {
|
|
809
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
turtle: '',
|
|
813
|
+
jsonld: quadsToJsonLd(quads, 'health:ImmunizationRecord'),
|
|
814
|
+
warnings,
|
|
815
|
+
resourceType: 'Immunization',
|
|
816
|
+
cascadeType: 'health:ImmunizationRecord',
|
|
817
|
+
_quads: quads,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function convertCoverage(resource) {
|
|
821
|
+
const warnings = [];
|
|
822
|
+
const subjectUri = `urn:uuid:${randomUUID()}`;
|
|
823
|
+
const quads = [];
|
|
824
|
+
quads.push(tripleType(subjectUri, NS.coverage + 'InsurancePlan'));
|
|
825
|
+
quads.push(...commonTriples(subjectUri));
|
|
826
|
+
// Provider name (from payor)
|
|
827
|
+
if (Array.isArray(resource.payor) && resource.payor.length > 0) {
|
|
828
|
+
const payorName = resource.payor[0]?.display ?? 'Unknown Insurance';
|
|
829
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'providerName', payorName));
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'providerName', 'Unknown Insurance'));
|
|
833
|
+
warnings.push('No payor information found in Coverage resource');
|
|
834
|
+
}
|
|
835
|
+
// Member ID
|
|
836
|
+
if (resource.subscriberId) {
|
|
837
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'memberId', resource.subscriberId));
|
|
838
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'subscriberId', resource.subscriberId));
|
|
839
|
+
}
|
|
840
|
+
else if (resource.identifier && Array.isArray(resource.identifier) && resource.identifier.length > 0) {
|
|
841
|
+
const memberId = resource.identifier[0]?.value ?? '';
|
|
842
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'memberId', memberId));
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
warnings.push('No member/subscriber ID found in Coverage resource');
|
|
846
|
+
}
|
|
847
|
+
// Coverage type (from FHIR type)
|
|
848
|
+
if (resource.type) {
|
|
849
|
+
const typeText = resource.type.coding?.[0]?.code ?? codeableConceptText(resource.type) ?? 'primary';
|
|
850
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'coverageType', typeText));
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'coverageType', 'primary'));
|
|
854
|
+
}
|
|
855
|
+
// Class — group number, plan name, etc
|
|
856
|
+
if (Array.isArray(resource.class)) {
|
|
857
|
+
for (const cls of resource.class) {
|
|
858
|
+
const clsType = cls.type?.coding?.[0]?.code ?? '';
|
|
859
|
+
if (clsType === 'group' && cls.value) {
|
|
860
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'groupNumber', cls.value));
|
|
861
|
+
if (cls.name)
|
|
862
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'planName', cls.name));
|
|
863
|
+
}
|
|
864
|
+
else if (clsType === 'plan' && cls.value) {
|
|
865
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'planName', cls.name ?? cls.value));
|
|
866
|
+
}
|
|
867
|
+
else if (clsType === 'rxbin' && cls.value) {
|
|
868
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'rxBin', cls.value));
|
|
869
|
+
}
|
|
870
|
+
else if (clsType === 'rxpcn' && cls.value) {
|
|
871
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'rxPcn', cls.value));
|
|
872
|
+
}
|
|
873
|
+
else if (clsType === 'rxgroup' && cls.value) {
|
|
874
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'rxGroup', cls.value));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Relationship
|
|
879
|
+
if (resource.relationship) {
|
|
880
|
+
const relCode = resource.relationship.coding?.[0]?.code ?? 'self';
|
|
881
|
+
quads.push(tripleStr(subjectUri, NS.coverage + 'subscriberRelationship', relCode));
|
|
882
|
+
}
|
|
883
|
+
// Period
|
|
884
|
+
if (resource.period?.start) {
|
|
885
|
+
// Coverage uses xsd:date, not dateTime
|
|
886
|
+
quads.push(tripleDate(subjectUri, NS.coverage + 'effectiveStart', resource.period.start.substring(0, 10)));
|
|
887
|
+
}
|
|
888
|
+
if (resource.period?.end) {
|
|
889
|
+
quads.push(tripleDate(subjectUri, NS.coverage + 'effectiveEnd', resource.period.end.substring(0, 10)));
|
|
890
|
+
}
|
|
891
|
+
// Source record ID
|
|
892
|
+
if (resource.id) {
|
|
893
|
+
quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
turtle: '',
|
|
897
|
+
jsonld: quadsToJsonLd(quads, 'coverage:InsurancePlan'),
|
|
898
|
+
warnings,
|
|
899
|
+
resourceType: 'Coverage',
|
|
900
|
+
cascadeType: 'coverage:InsurancePlan',
|
|
901
|
+
_quads: quads,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// Main dispatcher: single FHIR resource -> Cascade
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
const SUPPORTED_TYPES = new Set([
|
|
908
|
+
'MedicationStatement', 'MedicationRequest',
|
|
909
|
+
'Condition',
|
|
910
|
+
'AllergyIntolerance',
|
|
911
|
+
'Observation',
|
|
912
|
+
'Patient',
|
|
913
|
+
'Immunization',
|
|
914
|
+
'Coverage',
|
|
915
|
+
]);
|
|
916
|
+
export function convertFhirResourceToQuads(fhirResource) {
|
|
917
|
+
const resourceType = fhirResource?.resourceType;
|
|
918
|
+
if (!resourceType)
|
|
919
|
+
return null;
|
|
920
|
+
switch (resourceType) {
|
|
921
|
+
case 'MedicationStatement':
|
|
922
|
+
case 'MedicationRequest':
|
|
923
|
+
return convertMedicationStatement(fhirResource);
|
|
924
|
+
case 'Condition':
|
|
925
|
+
return convertCondition(fhirResource);
|
|
926
|
+
case 'AllergyIntolerance':
|
|
927
|
+
return convertAllergyIntolerance(fhirResource);
|
|
928
|
+
case 'Observation':
|
|
929
|
+
if (isVitalSignObservation(fhirResource)) {
|
|
930
|
+
return convertObservationVital(fhirResource);
|
|
931
|
+
}
|
|
932
|
+
return convertObservationLab(fhirResource);
|
|
933
|
+
case 'Patient':
|
|
934
|
+
return convertPatient(fhirResource);
|
|
935
|
+
case 'Immunization':
|
|
936
|
+
return convertImmunization(fhirResource);
|
|
937
|
+
case 'Coverage':
|
|
938
|
+
return convertCoverage(fhirResource);
|
|
939
|
+
default:
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
export async function convertFhirToCascade(fhirResource) {
|
|
944
|
+
const result = convertFhirResourceToQuads(fhirResource);
|
|
945
|
+
if (!result) {
|
|
946
|
+
return {
|
|
947
|
+
turtle: '',
|
|
948
|
+
warnings: [`Unsupported FHIR resource type: ${fhirResource?.resourceType ?? 'unknown'}`],
|
|
949
|
+
resourceType: fhirResource?.resourceType ?? 'unknown',
|
|
950
|
+
cascadeType: 'unknown',
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const turtle = await quadsToTurtle(result._quads);
|
|
954
|
+
return {
|
|
955
|
+
turtle,
|
|
956
|
+
jsonld: result.jsonld,
|
|
957
|
+
warnings: result.warnings,
|
|
958
|
+
resourceType: result.resourceType,
|
|
959
|
+
cascadeType: result.cascadeType,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
// Cascade -> FHIR (reverse conversion)
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
/**
|
|
966
|
+
* Convert Cascade Turtle to FHIR R4 JSON.
|
|
967
|
+
*
|
|
968
|
+
* Parses the Turtle using n3, identifies the resource type from rdf:type,
|
|
969
|
+
* and maps Cascade predicates back to FHIR fields.
|
|
970
|
+
*
|
|
971
|
+
* Not all Cascade fields have FHIR equivalents -- lost fields are reported
|
|
972
|
+
* as warnings.
|
|
973
|
+
*/
|
|
974
|
+
export async function convertCascadeToFhir(turtle) {
|
|
975
|
+
const warnings = [];
|
|
976
|
+
const resources = [];
|
|
977
|
+
// Parse Turtle
|
|
978
|
+
const parser = new Parser();
|
|
979
|
+
const quads = [];
|
|
980
|
+
try {
|
|
981
|
+
const parsed = parser.parse(turtle);
|
|
982
|
+
quads.push(...parsed);
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
return { resources: [], warnings: [`Turtle parse error: ${err.message}`] };
|
|
986
|
+
}
|
|
987
|
+
// Group quads by subject
|
|
988
|
+
const subjects = new Map();
|
|
989
|
+
for (const q of quads) {
|
|
990
|
+
const subj = q.subject.value;
|
|
991
|
+
if (!subjects.has(subj))
|
|
992
|
+
subjects.set(subj, []);
|
|
993
|
+
subjects.get(subj).push(q);
|
|
994
|
+
}
|
|
995
|
+
for (const [_subjectUri, subjectQuads] of subjects) {
|
|
996
|
+
// Find rdf:type
|
|
997
|
+
const typeQuad = subjectQuads.find(q => q.predicate.value === NS.rdf + 'type');
|
|
998
|
+
if (!typeQuad)
|
|
999
|
+
continue;
|
|
1000
|
+
const rdfType = typeQuad.object.value;
|
|
1001
|
+
// Build a predicate->value map for quick access
|
|
1002
|
+
const pv = new Map();
|
|
1003
|
+
for (const q of subjectQuads) {
|
|
1004
|
+
const pred = q.predicate.value;
|
|
1005
|
+
if (!pv.has(pred))
|
|
1006
|
+
pv.set(pred, []);
|
|
1007
|
+
pv.get(pred).push(q.object.value);
|
|
1008
|
+
}
|
|
1009
|
+
const getFirst = (pred) => pv.get(pred)?.[0];
|
|
1010
|
+
if (rdfType === NS.health + 'MedicationRecord') {
|
|
1011
|
+
const fhirResource = {
|
|
1012
|
+
resourceType: 'MedicationStatement',
|
|
1013
|
+
status: 'active',
|
|
1014
|
+
medicationCodeableConcept: { text: getFirst(NS.health + 'medicationName') ?? '' },
|
|
1015
|
+
};
|
|
1016
|
+
// isActive -> status
|
|
1017
|
+
const isActive = getFirst(NS.health + 'isActive');
|
|
1018
|
+
if (isActive === 'false')
|
|
1019
|
+
fhirResource.status = 'stopped';
|
|
1020
|
+
// Drug codes
|
|
1021
|
+
const drugCodes = pv.get(NS.clinical + 'drugCode') ?? [];
|
|
1022
|
+
const codingArr = [];
|
|
1023
|
+
for (const uri of drugCodes) {
|
|
1024
|
+
if (uri.startsWith(NS.rxnorm)) {
|
|
1025
|
+
codingArr.push({ system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: uri.slice(NS.rxnorm.length) });
|
|
1026
|
+
}
|
|
1027
|
+
else if (uri.startsWith(NS.sct)) {
|
|
1028
|
+
codingArr.push({ system: 'http://snomed.info/sct', code: uri.slice(NS.sct.length) });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (codingArr.length > 0) {
|
|
1032
|
+
fhirResource.medicationCodeableConcept.coding = codingArr;
|
|
1033
|
+
}
|
|
1034
|
+
// Dosage
|
|
1035
|
+
const doseText = getFirst(NS.health + 'dose');
|
|
1036
|
+
if (doseText) {
|
|
1037
|
+
fhirResource.dosage = [{ text: doseText }];
|
|
1038
|
+
}
|
|
1039
|
+
// Dates
|
|
1040
|
+
const startDate = getFirst(NS.health + 'startDate');
|
|
1041
|
+
const endDate = getFirst(NS.health + 'endDate');
|
|
1042
|
+
if (startDate || endDate) {
|
|
1043
|
+
fhirResource.effectivePeriod = {};
|
|
1044
|
+
if (startDate)
|
|
1045
|
+
fhirResource.effectivePeriod.start = startDate;
|
|
1046
|
+
if (endDate)
|
|
1047
|
+
fhirResource.effectivePeriod.end = endDate;
|
|
1048
|
+
}
|
|
1049
|
+
// Source record ID
|
|
1050
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1051
|
+
if (srcId)
|
|
1052
|
+
fhirResource.id = srcId;
|
|
1053
|
+
// Cascade-only fields that have no FHIR equivalent
|
|
1054
|
+
const cascadeOnlyFields = [
|
|
1055
|
+
NS.clinical + 'provenanceClass',
|
|
1056
|
+
NS.clinical + 'clinicalIntent',
|
|
1057
|
+
NS.cascade + 'schemaVersion',
|
|
1058
|
+
NS.health + 'medicationClass',
|
|
1059
|
+
];
|
|
1060
|
+
for (const field of cascadeOnlyFields) {
|
|
1061
|
+
if (getFirst(field)) {
|
|
1062
|
+
const shortName = field.split('#')[1] ?? field;
|
|
1063
|
+
warnings.push(`Cascade field '${shortName}' has no FHIR equivalent and was not included in output`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
resources.push(fhirResource);
|
|
1067
|
+
}
|
|
1068
|
+
else if (rdfType === NS.health + 'ConditionRecord') {
|
|
1069
|
+
const fhirResource = {
|
|
1070
|
+
resourceType: 'Condition',
|
|
1071
|
+
code: {
|
|
1072
|
+
text: getFirst(NS.health + 'conditionName') ?? '',
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
// Status
|
|
1076
|
+
const status = getFirst(NS.health + 'status');
|
|
1077
|
+
if (status) {
|
|
1078
|
+
fhirResource.clinicalStatus = {
|
|
1079
|
+
coding: [{ system: 'http://terminology.hl7.org/CodeSystem/condition-clinical', code: status }],
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
// Onset
|
|
1083
|
+
const onset = getFirst(NS.health + 'onsetDate');
|
|
1084
|
+
if (onset)
|
|
1085
|
+
fhirResource.onsetDateTime = onset;
|
|
1086
|
+
// Codes
|
|
1087
|
+
const codingArr = [];
|
|
1088
|
+
const icd10 = pv.get(NS.health + 'icd10Code') ?? [];
|
|
1089
|
+
for (const uri of icd10) {
|
|
1090
|
+
codingArr.push({ system: 'http://hl7.org/fhir/sid/icd-10-cm', code: uri.startsWith(NS.icd10) ? uri.slice(NS.icd10.length) : uri });
|
|
1091
|
+
}
|
|
1092
|
+
const snomed = pv.get(NS.health + 'snomedCode') ?? [];
|
|
1093
|
+
for (const uri of snomed) {
|
|
1094
|
+
codingArr.push({ system: 'http://snomed.info/sct', code: uri.startsWith(NS.sct) ? uri.slice(NS.sct.length) : uri });
|
|
1095
|
+
}
|
|
1096
|
+
if (codingArr.length > 0)
|
|
1097
|
+
fhirResource.code.coding = codingArr;
|
|
1098
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1099
|
+
if (srcId)
|
|
1100
|
+
fhirResource.id = srcId;
|
|
1101
|
+
resources.push(fhirResource);
|
|
1102
|
+
}
|
|
1103
|
+
else if (rdfType === NS.health + 'AllergyRecord') {
|
|
1104
|
+
const fhirResource = {
|
|
1105
|
+
resourceType: 'AllergyIntolerance',
|
|
1106
|
+
code: { text: getFirst(NS.health + 'allergen') ?? '' },
|
|
1107
|
+
};
|
|
1108
|
+
const cat = getFirst(NS.health + 'allergyCategory');
|
|
1109
|
+
if (cat)
|
|
1110
|
+
fhirResource.category = [cat];
|
|
1111
|
+
const severity = getFirst(NS.health + 'allergySeverity');
|
|
1112
|
+
const reaction = getFirst(NS.health + 'reaction');
|
|
1113
|
+
if (reaction || severity) {
|
|
1114
|
+
const rxn = {};
|
|
1115
|
+
if (reaction)
|
|
1116
|
+
rxn.manifestation = [{ text: reaction }];
|
|
1117
|
+
if (severity)
|
|
1118
|
+
rxn.severity = severity;
|
|
1119
|
+
fhirResource.reaction = [rxn];
|
|
1120
|
+
}
|
|
1121
|
+
const onset = getFirst(NS.health + 'onsetDate');
|
|
1122
|
+
if (onset)
|
|
1123
|
+
fhirResource.onsetDateTime = onset;
|
|
1124
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1125
|
+
if (srcId)
|
|
1126
|
+
fhirResource.id = srcId;
|
|
1127
|
+
resources.push(fhirResource);
|
|
1128
|
+
}
|
|
1129
|
+
else if (rdfType === NS.health + 'LabResultRecord') {
|
|
1130
|
+
const fhirResource = {
|
|
1131
|
+
resourceType: 'Observation',
|
|
1132
|
+
code: { text: getFirst(NS.health + 'testName') ?? '' },
|
|
1133
|
+
category: [{ coding: [{ code: 'laboratory' }] }],
|
|
1134
|
+
};
|
|
1135
|
+
// LOINC code
|
|
1136
|
+
const testCode = pv.get(NS.health + 'testCode') ?? [];
|
|
1137
|
+
const codingArr = [];
|
|
1138
|
+
for (const uri of testCode) {
|
|
1139
|
+
const code = uri.startsWith(NS.loinc) ? uri.slice(NS.loinc.length) : uri;
|
|
1140
|
+
codingArr.push({ system: 'http://loinc.org', code });
|
|
1141
|
+
}
|
|
1142
|
+
if (codingArr.length > 0)
|
|
1143
|
+
fhirResource.code.coding = codingArr;
|
|
1144
|
+
// Value
|
|
1145
|
+
const resultVal = getFirst(NS.health + 'resultValue');
|
|
1146
|
+
const resultUnit = getFirst(NS.health + 'resultUnit');
|
|
1147
|
+
if (resultVal) {
|
|
1148
|
+
const numVal = parseFloat(resultVal);
|
|
1149
|
+
if (!isNaN(numVal)) {
|
|
1150
|
+
fhirResource.valueQuantity = { value: numVal };
|
|
1151
|
+
if (resultUnit)
|
|
1152
|
+
fhirResource.valueQuantity.unit = resultUnit;
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
fhirResource.valueString = resultVal;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// Date
|
|
1159
|
+
const perfDate = getFirst(NS.health + 'performedDate');
|
|
1160
|
+
if (perfDate)
|
|
1161
|
+
fhirResource.effectiveDateTime = perfDate;
|
|
1162
|
+
// Interpretation
|
|
1163
|
+
const interp = getFirst(NS.health + 'interpretation');
|
|
1164
|
+
if (interp) {
|
|
1165
|
+
const revInterpMap = {
|
|
1166
|
+
normal: 'N', abnormal: 'A', critical: 'HH', unknown: 'UNK',
|
|
1167
|
+
};
|
|
1168
|
+
fhirResource.interpretation = [{
|
|
1169
|
+
coding: [{ code: revInterpMap[interp] ?? interp }],
|
|
1170
|
+
}];
|
|
1171
|
+
}
|
|
1172
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1173
|
+
if (srcId)
|
|
1174
|
+
fhirResource.id = srcId;
|
|
1175
|
+
resources.push(fhirResource);
|
|
1176
|
+
}
|
|
1177
|
+
else if (rdfType === NS.clinical + 'VitalSign') {
|
|
1178
|
+
const fhirResource = {
|
|
1179
|
+
resourceType: 'Observation',
|
|
1180
|
+
code: {},
|
|
1181
|
+
category: [{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/observation-category', code: 'vital-signs' }] }],
|
|
1182
|
+
};
|
|
1183
|
+
// LOINC code
|
|
1184
|
+
const loincUri = getFirst(NS.clinical + 'loincCode');
|
|
1185
|
+
if (loincUri) {
|
|
1186
|
+
const code = loincUri.startsWith(NS.loinc) ? loincUri.slice(NS.loinc.length) : loincUri;
|
|
1187
|
+
fhirResource.code.coding = [{ system: 'http://loinc.org', code }];
|
|
1188
|
+
}
|
|
1189
|
+
const vitalName = getFirst(NS.clinical + 'vitalTypeName');
|
|
1190
|
+
if (vitalName)
|
|
1191
|
+
fhirResource.code.text = vitalName;
|
|
1192
|
+
// Value
|
|
1193
|
+
const value = getFirst(NS.clinical + 'value');
|
|
1194
|
+
const unit = getFirst(NS.clinical + 'unit');
|
|
1195
|
+
if (value) {
|
|
1196
|
+
fhirResource.valueQuantity = { value: parseFloat(value) };
|
|
1197
|
+
if (unit)
|
|
1198
|
+
fhirResource.valueQuantity.unit = unit;
|
|
1199
|
+
}
|
|
1200
|
+
// Date
|
|
1201
|
+
const effDate = getFirst(NS.clinical + 'effectiveDate');
|
|
1202
|
+
if (effDate)
|
|
1203
|
+
fhirResource.effectiveDateTime = effDate;
|
|
1204
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1205
|
+
if (srcId)
|
|
1206
|
+
fhirResource.id = srcId;
|
|
1207
|
+
// Warn about Cascade-specific fields
|
|
1208
|
+
if (getFirst(NS.clinical + 'snomedCode')) {
|
|
1209
|
+
warnings.push("Cascade field 'snomedCode' has no standard FHIR Observation field and was not included");
|
|
1210
|
+
}
|
|
1211
|
+
resources.push(fhirResource);
|
|
1212
|
+
}
|
|
1213
|
+
else if (rdfType === NS.cascade + 'PatientProfile') {
|
|
1214
|
+
const fhirResource = {
|
|
1215
|
+
resourceType: 'Patient',
|
|
1216
|
+
};
|
|
1217
|
+
const dob = getFirst(NS.cascade + 'dateOfBirth');
|
|
1218
|
+
if (dob)
|
|
1219
|
+
fhirResource.birthDate = dob;
|
|
1220
|
+
const sex = getFirst(NS.cascade + 'biologicalSex');
|
|
1221
|
+
if (sex) {
|
|
1222
|
+
const sexMap = { male: 'male', female: 'female', intersex: 'other' };
|
|
1223
|
+
fhirResource.gender = sexMap[sex] ?? 'unknown';
|
|
1224
|
+
}
|
|
1225
|
+
const marital = getFirst(NS.cascade + 'maritalStatus');
|
|
1226
|
+
if (marital) {
|
|
1227
|
+
const maritalMap = {
|
|
1228
|
+
single: 'S', married: 'M', divorced: 'D', widowed: 'W',
|
|
1229
|
+
separated: 'A', domestic_partnership: 'T',
|
|
1230
|
+
};
|
|
1231
|
+
fhirResource.maritalStatus = {
|
|
1232
|
+
coding: [{ code: maritalMap[marital] ?? 'UNK' }],
|
|
1233
|
+
text: marital,
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
const profileId = getFirst(NS.cascade + 'profileId');
|
|
1237
|
+
if (profileId)
|
|
1238
|
+
fhirResource.id = profileId;
|
|
1239
|
+
// Warn about Cascade-only fields
|
|
1240
|
+
const cascadeOnly = ['computedAge', 'ageGroup', 'genderIdentity'];
|
|
1241
|
+
for (const field of cascadeOnly) {
|
|
1242
|
+
if (getFirst(NS.cascade + field)) {
|
|
1243
|
+
warnings.push(`Cascade field '${field}' has no FHIR Patient equivalent and was not included`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
resources.push(fhirResource);
|
|
1247
|
+
}
|
|
1248
|
+
else if (rdfType === NS.health + 'ImmunizationRecord') {
|
|
1249
|
+
const fhirResource = {
|
|
1250
|
+
resourceType: 'Immunization',
|
|
1251
|
+
status: getFirst(NS.health + 'status') ?? 'completed',
|
|
1252
|
+
vaccineCode: { text: getFirst(NS.health + 'vaccineName') ?? '' },
|
|
1253
|
+
};
|
|
1254
|
+
const adminDate = getFirst(NS.health + 'administrationDate');
|
|
1255
|
+
if (adminDate)
|
|
1256
|
+
fhirResource.occurrenceDateTime = adminDate;
|
|
1257
|
+
const vaccineCode = getFirst(NS.health + 'vaccineCode');
|
|
1258
|
+
if (vaccineCode) {
|
|
1259
|
+
// Strip "CVX-" prefix
|
|
1260
|
+
const code = vaccineCode.startsWith('CVX-') ? vaccineCode.slice(4) : vaccineCode;
|
|
1261
|
+
fhirResource.vaccineCode.coding = [{ system: 'http://hl7.org/fhir/sid/cvx', code }];
|
|
1262
|
+
}
|
|
1263
|
+
const manufacturer = getFirst(NS.health + 'manufacturer');
|
|
1264
|
+
if (manufacturer)
|
|
1265
|
+
fhirResource.manufacturer = { display: manufacturer };
|
|
1266
|
+
const lotNumber = getFirst(NS.health + 'lotNumber');
|
|
1267
|
+
if (lotNumber)
|
|
1268
|
+
fhirResource.lotNumber = lotNumber;
|
|
1269
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1270
|
+
if (srcId)
|
|
1271
|
+
fhirResource.id = srcId;
|
|
1272
|
+
resources.push(fhirResource);
|
|
1273
|
+
}
|
|
1274
|
+
else if (rdfType === NS.coverage + 'InsurancePlan') {
|
|
1275
|
+
const fhirResource = {
|
|
1276
|
+
resourceType: 'Coverage',
|
|
1277
|
+
status: 'active',
|
|
1278
|
+
};
|
|
1279
|
+
const providerName = getFirst(NS.coverage + 'providerName');
|
|
1280
|
+
if (providerName)
|
|
1281
|
+
fhirResource.payor = [{ display: providerName }];
|
|
1282
|
+
const memberId = getFirst(NS.coverage + 'memberId');
|
|
1283
|
+
if (memberId)
|
|
1284
|
+
fhirResource.subscriberId = memberId;
|
|
1285
|
+
const groupNum = getFirst(NS.coverage + 'groupNumber');
|
|
1286
|
+
const planName = getFirst(NS.coverage + 'planName');
|
|
1287
|
+
const classArr = [];
|
|
1288
|
+
if (groupNum)
|
|
1289
|
+
classArr.push({ type: { coding: [{ code: 'group' }] }, value: groupNum, name: planName });
|
|
1290
|
+
else if (planName)
|
|
1291
|
+
classArr.push({ type: { coding: [{ code: 'plan' }] }, value: planName });
|
|
1292
|
+
if (classArr.length > 0)
|
|
1293
|
+
fhirResource.class = classArr;
|
|
1294
|
+
const start = getFirst(NS.coverage + 'effectiveStart');
|
|
1295
|
+
const end = getFirst(NS.coverage + 'effectiveEnd');
|
|
1296
|
+
if (start || end) {
|
|
1297
|
+
fhirResource.period = {};
|
|
1298
|
+
if (start)
|
|
1299
|
+
fhirResource.period.start = start;
|
|
1300
|
+
if (end)
|
|
1301
|
+
fhirResource.period.end = end;
|
|
1302
|
+
}
|
|
1303
|
+
const rel = getFirst(NS.coverage + 'subscriberRelationship');
|
|
1304
|
+
if (rel)
|
|
1305
|
+
fhirResource.relationship = { coding: [{ code: rel }] };
|
|
1306
|
+
const srcId = getFirst(NS.health + 'sourceRecordId');
|
|
1307
|
+
if (srcId)
|
|
1308
|
+
fhirResource.id = srcId;
|
|
1309
|
+
resources.push(fhirResource);
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
warnings.push(`Unknown Cascade RDF type: ${rdfType}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return { resources, warnings };
|
|
1316
|
+
}
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
// Batch conversion (FHIR Bundle support)
|
|
1319
|
+
// ---------------------------------------------------------------------------
|
|
1320
|
+
/**
|
|
1321
|
+
* Convert an entire FHIR input (single resource or Bundle) to Cascade format.
|
|
1322
|
+
*/
|
|
1323
|
+
export async function convert(input, from, to, outputSerialization = 'turtle') {
|
|
1324
|
+
const warnings = [];
|
|
1325
|
+
const errors = [];
|
|
1326
|
+
const results = [];
|
|
1327
|
+
if (from === 'fhir' && (to === 'cascade' || to === 'turtle' || to === 'jsonld')) {
|
|
1328
|
+
// FHIR -> Cascade
|
|
1329
|
+
let parsed;
|
|
1330
|
+
try {
|
|
1331
|
+
parsed = JSON.parse(input);
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
return {
|
|
1335
|
+
success: false, output: '', format: to, resourceCount: 0,
|
|
1336
|
+
warnings: [], errors: ['Invalid JSON input'], results: [],
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
// Collect resources from Bundle or single resource
|
|
1340
|
+
const fhirResources = [];
|
|
1341
|
+
if (parsed.resourceType === 'Bundle' && Array.isArray(parsed.entry)) {
|
|
1342
|
+
for (const entry of parsed.entry) {
|
|
1343
|
+
if (entry.resource)
|
|
1344
|
+
fhirResources.push(entry.resource);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
else if (parsed.resourceType) {
|
|
1348
|
+
fhirResources.push(parsed);
|
|
1349
|
+
}
|
|
1350
|
+
else {
|
|
1351
|
+
return {
|
|
1352
|
+
success: false, output: '', format: to, resourceCount: 0,
|
|
1353
|
+
warnings: [], errors: ['Input does not appear to be a FHIR resource or Bundle'], results: [],
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const allQuads = [];
|
|
1357
|
+
for (const res of fhirResources) {
|
|
1358
|
+
if (!SUPPORTED_TYPES.has(res.resourceType)) {
|
|
1359
|
+
warnings.push(`Skipping unsupported FHIR resource type: ${res.resourceType}`);
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
const result = convertFhirResourceToQuads(res);
|
|
1363
|
+
if (result) {
|
|
1364
|
+
allQuads.push(...result._quads);
|
|
1365
|
+
results.push({
|
|
1366
|
+
turtle: '', // will be filled with combined output
|
|
1367
|
+
jsonld: result.jsonld,
|
|
1368
|
+
warnings: result.warnings,
|
|
1369
|
+
resourceType: result.resourceType,
|
|
1370
|
+
cascadeType: result.cascadeType,
|
|
1371
|
+
});
|
|
1372
|
+
warnings.push(...result.warnings);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (allQuads.length === 0) {
|
|
1376
|
+
return {
|
|
1377
|
+
success: false, output: '', format: to, resourceCount: 0,
|
|
1378
|
+
warnings, errors: ['No convertible FHIR resources found'], results: [],
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
// Determine output format
|
|
1382
|
+
let output;
|
|
1383
|
+
if (outputSerialization === 'jsonld' || to === 'jsonld') {
|
|
1384
|
+
const jsonLd = quadsToJsonLd(allQuads, results[0]?.cascadeType ?? '');
|
|
1385
|
+
output = JSON.stringify(jsonLd, null, 2);
|
|
1386
|
+
}
|
|
1387
|
+
else {
|
|
1388
|
+
output = await quadsToTurtle(allQuads);
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
success: true,
|
|
1392
|
+
output,
|
|
1393
|
+
format: to === 'cascade' ? (outputSerialization === 'jsonld' ? 'jsonld' : 'turtle') : to,
|
|
1394
|
+
resourceCount: results.length,
|
|
1395
|
+
warnings,
|
|
1396
|
+
errors,
|
|
1397
|
+
results,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
else if (from === 'cascade' && to === 'fhir') {
|
|
1401
|
+
// Cascade -> FHIR
|
|
1402
|
+
const { resources, warnings: convWarnings } = await convertCascadeToFhir(input);
|
|
1403
|
+
warnings.push(...convWarnings);
|
|
1404
|
+
if (resources.length === 0) {
|
|
1405
|
+
return {
|
|
1406
|
+
success: false, output: '', format: 'fhir', resourceCount: 0,
|
|
1407
|
+
warnings, errors: ['No resources converted from Cascade Turtle'], results: [],
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
const output = resources.length === 1
|
|
1411
|
+
? JSON.stringify(resources[0], null, 2)
|
|
1412
|
+
: JSON.stringify({ resourceType: 'Bundle', type: 'collection', entry: resources.map(r => ({ resource: r })) }, null, 2);
|
|
1413
|
+
return {
|
|
1414
|
+
success: true,
|
|
1415
|
+
output,
|
|
1416
|
+
format: 'fhir',
|
|
1417
|
+
resourceCount: resources.length,
|
|
1418
|
+
warnings,
|
|
1419
|
+
errors,
|
|
1420
|
+
results: resources.map(r => ({
|
|
1421
|
+
turtle: '',
|
|
1422
|
+
warnings: [],
|
|
1423
|
+
resourceType: r.resourceType,
|
|
1424
|
+
cascadeType: 'fhir',
|
|
1425
|
+
})),
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
else if (from === 'c-cda') {
|
|
1429
|
+
return {
|
|
1430
|
+
success: false, output: '', format: to, resourceCount: 0,
|
|
1431
|
+
warnings: [], errors: ['C-CDA conversion is not yet supported'], results: [],
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
return {
|
|
1436
|
+
success: false, output: '', format: to, resourceCount: 0,
|
|
1437
|
+
warnings: [], errors: [`Unsupported conversion: ${from} -> ${to}`], results: [],
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
// ---------------------------------------------------------------------------
|
|
1442
|
+
// Format detection
|
|
1443
|
+
// ---------------------------------------------------------------------------
|
|
1444
|
+
/**
|
|
1445
|
+
* Detect the format of input data by inspecting its content.
|
|
1446
|
+
*/
|
|
1447
|
+
export function detectFormat(input) {
|
|
1448
|
+
const trimmed = input.trim();
|
|
1449
|
+
// Check for FHIR JSON (has "resourceType")
|
|
1450
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
1451
|
+
try {
|
|
1452
|
+
const parsed = JSON.parse(trimmed);
|
|
1453
|
+
if (parsed.resourceType)
|
|
1454
|
+
return 'fhir';
|
|
1455
|
+
if (parsed['@context'] || parsed['@type'])
|
|
1456
|
+
return 'cascade'; // JSON-LD
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
// Not valid JSON
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
// Check for Turtle (has @prefix declarations or common Cascade namespace URIs)
|
|
1463
|
+
if (trimmed.includes('@prefix') ||
|
|
1464
|
+
trimmed.includes('ns.cascadeprotocol.org') ||
|
|
1465
|
+
/^<[^>]+>\s+a\s+/.test(trimmed)) {
|
|
1466
|
+
return 'cascade';
|
|
1467
|
+
}
|
|
1468
|
+
// Check for C-CDA (XML with ClinicalDocument root)
|
|
1469
|
+
if (trimmed.startsWith('<?xml') || trimmed.includes('<ClinicalDocument')) {
|
|
1470
|
+
return 'c-cda';
|
|
1471
|
+
}
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
//# sourceMappingURL=fhir-converter.js.map
|