@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.
Files changed (165) hide show
  1. package/.dockerignore +7 -0
  2. package/.eslintrc.json +23 -0
  3. package/.prettierrc +7 -0
  4. package/DOCKER.md +36 -0
  5. package/Dockerfile +18 -0
  6. package/README.md +69 -0
  7. package/dist/commands/capabilities.d.ts +9 -0
  8. package/dist/commands/capabilities.d.ts.map +1 -0
  9. package/dist/commands/capabilities.js +194 -0
  10. package/dist/commands/capabilities.js.map +1 -0
  11. package/dist/commands/conformance.d.ts +15 -0
  12. package/dist/commands/conformance.d.ts.map +1 -0
  13. package/dist/commands/conformance.js +348 -0
  14. package/dist/commands/conformance.js.map +1 -0
  15. package/dist/commands/convert.d.ts +21 -0
  16. package/dist/commands/convert.d.ts.map +1 -0
  17. package/dist/commands/convert.js +134 -0
  18. package/dist/commands/convert.js.map +1 -0
  19. package/dist/commands/pod/export.d.ts +8 -0
  20. package/dist/commands/pod/export.d.ts.map +1 -0
  21. package/dist/commands/pod/export.js +72 -0
  22. package/dist/commands/pod/export.js.map +1 -0
  23. package/dist/commands/pod/helpers.d.ts +79 -0
  24. package/dist/commands/pod/helpers.d.ts.map +1 -0
  25. package/dist/commands/pod/helpers.js +369 -0
  26. package/dist/commands/pod/helpers.js.map +1 -0
  27. package/dist/commands/pod/index.d.ts +20 -0
  28. package/dist/commands/pod/index.d.ts.map +1 -0
  29. package/dist/commands/pod/index.js +29 -0
  30. package/dist/commands/pod/index.js.map +1 -0
  31. package/dist/commands/pod/info.d.ts +9 -0
  32. package/dist/commands/pod/info.d.ts.map +1 -0
  33. package/dist/commands/pod/info.js +196 -0
  34. package/dist/commands/pod/info.js.map +1 -0
  35. package/dist/commands/pod/init.d.ts +9 -0
  36. package/dist/commands/pod/init.d.ts.map +1 -0
  37. package/dist/commands/pod/init.js +251 -0
  38. package/dist/commands/pod/init.js.map +1 -0
  39. package/dist/commands/pod/query.d.ts +9 -0
  40. package/dist/commands/pod/query.d.ts.map +1 -0
  41. package/dist/commands/pod/query.js +169 -0
  42. package/dist/commands/pod/query.js.map +1 -0
  43. package/dist/commands/pod 2.js +1017 -0
  44. package/dist/commands/pod.d.ts +28 -0
  45. package/dist/commands/pod.d.ts 2.map +1 -0
  46. package/dist/commands/pod.d.ts.map +1 -0
  47. package/dist/commands/pod.js +1031 -0
  48. package/dist/commands/pod.js 2.map +1 -0
  49. package/dist/commands/pod.js.map +1 -0
  50. package/dist/commands/serve.d.ts +33 -0
  51. package/dist/commands/serve.d.ts.map +1 -0
  52. package/dist/commands/serve.js +74 -0
  53. package/dist/commands/serve.js.map +1 -0
  54. package/dist/commands/validate.d.ts +18 -0
  55. package/dist/commands/validate.d.ts.map +1 -0
  56. package/dist/commands/validate.js +275 -0
  57. package/dist/commands/validate.js.map +1 -0
  58. package/dist/index.d.ts +19 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +49 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
  63. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
  64. package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
  65. package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
  66. package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
  67. package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
  68. package/dist/lib/fhir-converter/converters-clinical.js +391 -0
  69. package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
  70. package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
  71. package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
  72. package/dist/lib/fhir-converter/converters-demographics.js +242 -0
  73. package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
  74. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
  75. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
  76. package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
  77. package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
  78. package/dist/lib/fhir-converter/index.d.ts +36 -0
  79. package/dist/lib/fhir-converter/index.d.ts.map +1 -0
  80. package/dist/lib/fhir-converter/index.js +187 -0
  81. package/dist/lib/fhir-converter/index.js.map +1 -0
  82. package/dist/lib/fhir-converter/types.d.ts +77 -0
  83. package/dist/lib/fhir-converter/types.d.ts.map +1 -0
  84. package/dist/lib/fhir-converter/types.js +236 -0
  85. package/dist/lib/fhir-converter/types.js.map +1 -0
  86. package/dist/lib/fhir-converter.d.ts +62 -0
  87. package/dist/lib/fhir-converter.d.ts.map +1 -0
  88. package/dist/lib/fhir-converter.js +1474 -0
  89. package/dist/lib/fhir-converter.js.map +1 -0
  90. package/dist/lib/mcp/audit.d.ts +24 -0
  91. package/dist/lib/mcp/audit.d.ts.map +1 -0
  92. package/dist/lib/mcp/audit.js +85 -0
  93. package/dist/lib/mcp/audit.js.map +1 -0
  94. package/dist/lib/mcp/server.d.ts +38 -0
  95. package/dist/lib/mcp/server.d.ts.map +1 -0
  96. package/dist/lib/mcp/server.js +172 -0
  97. package/dist/lib/mcp/server.js.map +1 -0
  98. package/dist/lib/mcp/tools.d.ts +47 -0
  99. package/dist/lib/mcp/tools.d.ts.map +1 -0
  100. package/dist/lib/mcp/tools.js +547 -0
  101. package/dist/lib/mcp/tools.js.map +1 -0
  102. package/dist/lib/output.d.ts +26 -0
  103. package/dist/lib/output.d.ts.map +1 -0
  104. package/dist/lib/output.js +64 -0
  105. package/dist/lib/output.js.map +1 -0
  106. package/dist/lib/shacl-validator.d.ts +53 -0
  107. package/dist/lib/shacl-validator.d.ts.map +1 -0
  108. package/dist/lib/shacl-validator.js +245 -0
  109. package/dist/lib/shacl-validator.js.map +1 -0
  110. package/dist/lib/turtle-parser.d.ts +64 -0
  111. package/dist/lib/turtle-parser.d.ts.map +1 -0
  112. package/dist/lib/turtle-parser.js +236 -0
  113. package/dist/lib/turtle-parser.js.map +1 -0
  114. package/dist/shapes/checkup.shapes.ttl +1459 -0
  115. package/dist/shapes/clinical.shapes.ttl +1350 -0
  116. package/dist/shapes/clinical.ttl +1369 -0
  117. package/dist/shapes/core.shapes.ttl +450 -0
  118. package/dist/shapes/core.ttl +603 -0
  119. package/dist/shapes/coverage.shapes.ttl +214 -0
  120. package/dist/shapes/coverage.ttl +182 -0
  121. package/dist/shapes/health.shapes.ttl +697 -0
  122. package/dist/shapes/health.ttl +859 -0
  123. package/dist/shapes/pots.shapes.ttl +481 -0
  124. package/package.json +54 -0
  125. package/src/commands/capabilities.ts +235 -0
  126. package/src/commands/conformance.ts +447 -0
  127. package/src/commands/convert.ts +164 -0
  128. package/src/commands/pod/export.ts +85 -0
  129. package/src/commands/pod/helpers.ts +449 -0
  130. package/src/commands/pod/index.ts +32 -0
  131. package/src/commands/pod/info.ts +239 -0
  132. package/src/commands/pod/init.ts +273 -0
  133. package/src/commands/pod/query.ts +224 -0
  134. package/src/commands/serve.ts +92 -0
  135. package/src/commands/validate.ts +303 -0
  136. package/src/index.ts +58 -0
  137. package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
  138. package/src/lib/fhir-converter/converters-clinical.ts +446 -0
  139. package/src/lib/fhir-converter/converters-demographics.ts +270 -0
  140. package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
  141. package/src/lib/fhir-converter/index.ts +215 -0
  142. package/src/lib/fhir-converter/types.ts +318 -0
  143. package/src/lib/mcp/audit.ts +107 -0
  144. package/src/lib/mcp/server.ts +192 -0
  145. package/src/lib/mcp/tools.ts +668 -0
  146. package/src/lib/output.ts +76 -0
  147. package/src/lib/shacl-validator.ts +314 -0
  148. package/src/lib/turtle-parser.ts +277 -0
  149. package/src/shapes/checkup.shapes.ttl +1459 -0
  150. package/src/shapes/clinical.shapes.ttl +1350 -0
  151. package/src/shapes/clinical.ttl +1369 -0
  152. package/src/shapes/core.shapes.ttl +450 -0
  153. package/src/shapes/core.ttl +603 -0
  154. package/src/shapes/coverage.shapes.ttl +214 -0
  155. package/src/shapes/coverage.ttl +182 -0
  156. package/src/shapes/health.shapes.ttl +697 -0
  157. package/src/shapes/health.ttl +859 -0
  158. package/src/shapes/pots.shapes.ttl +481 -0
  159. package/test-fixtures/fhir-bundle-example.json +216 -0
  160. package/test-fixtures/fhir-medication-example.json +18 -0
  161. package/tests/cli.test.ts +126 -0
  162. package/tests/fhir-converter.test.ts +874 -0
  163. package/tests/mcp-server.test.ts +396 -0
  164. package/tests/pod.test.ts +400 -0
  165. 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