@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,1031 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade pod <subcommand>
|
|
3
|
+
*
|
|
4
|
+
* Manage Cascade Pod structures.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* init <directory> Initialize a new Cascade Pod
|
|
8
|
+
* query <pod-dir> Query data within a pod
|
|
9
|
+
* export <pod-dir> Export pod data
|
|
10
|
+
* info <pod-dir> Show pod metadata and statistics
|
|
11
|
+
*
|
|
12
|
+
* Query options:
|
|
13
|
+
* --medications Query medications
|
|
14
|
+
* --conditions Query conditions
|
|
15
|
+
* --allergies Query allergies
|
|
16
|
+
* --lab-results Query lab results
|
|
17
|
+
* --immunizations Query immunizations
|
|
18
|
+
* --vital-signs Query vital signs
|
|
19
|
+
* --all Query all data
|
|
20
|
+
* --json Output as JSON
|
|
21
|
+
*
|
|
22
|
+
* Export options:
|
|
23
|
+
* --format <fmt> Export format (zip|directory) [default: zip]
|
|
24
|
+
* --output <path> Output path for export
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'fs/promises';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
import { printResult, printError, printVerbose } from '../lib/output.js';
|
|
29
|
+
import { parseTurtleFile, getSubjectsByType, getProperties, shortenIRI, extractLabel, CASCADE_NAMESPACES, } from '../lib/turtle-parser.js';
|
|
30
|
+
// ─── Pod Init Templates ──────────────────────────────────────────────────────
|
|
31
|
+
function wellKnownSolid(absPath) {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
'@context': 'https://www.w3.org/ns/solid/terms',
|
|
34
|
+
pod_root: '/',
|
|
35
|
+
profile: '/profile/card.ttl#me',
|
|
36
|
+
storage: '/',
|
|
37
|
+
publicTypeIndex: '/settings/publicTypeIndex.ttl',
|
|
38
|
+
privateTypeIndex: '/settings/privateTypeIndex.ttl',
|
|
39
|
+
podUri: `file://${absPath}/`,
|
|
40
|
+
version: '1.0',
|
|
41
|
+
}, null, 2);
|
|
42
|
+
}
|
|
43
|
+
const PROFILE_CARD_TTL = `@prefix cascade: <https://ns.cascadeprotocol.org/core/v1#> .
|
|
44
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
|
|
45
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#> .
|
|
46
|
+
@prefix pim: <http://www.w3.org/ns/pim/space#> .
|
|
47
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
48
|
+
|
|
49
|
+
# =============================================================================
|
|
50
|
+
# WebID Profile Card
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# This is a Solid-compatible WebID profile for the Pod owner.
|
|
53
|
+
# Edit this file to add patient demographics and identity information.
|
|
54
|
+
#
|
|
55
|
+
# The <#me> fragment serves as the WebID for this Pod.
|
|
56
|
+
# =============================================================================
|
|
57
|
+
|
|
58
|
+
<#me> a foaf:Person ;
|
|
59
|
+
# ── Edit the fields below to personalize your Pod ──
|
|
60
|
+
# foaf:name "First Last" ;
|
|
61
|
+
# foaf:givenName "First" ;
|
|
62
|
+
# foaf:familyName "Last" ;
|
|
63
|
+
# cascade:dateOfBirth "1990-01-01"^^xsd:date ;
|
|
64
|
+
|
|
65
|
+
# ── Discovery links (do not remove) ──
|
|
66
|
+
solid:publicTypeIndex </settings/publicTypeIndex.ttl> ;
|
|
67
|
+
solid:privateTypeIndex </settings/privateTypeIndex.ttl> ;
|
|
68
|
+
pim:storage </> ;
|
|
69
|
+
|
|
70
|
+
cascade:schemaVersion "1.3" .
|
|
71
|
+
`;
|
|
72
|
+
const PUBLIC_TYPE_INDEX_TTL = `@prefix solid: <http://www.w3.org/ns/solid/terms#> .
|
|
73
|
+
@prefix cascade: <https://ns.cascadeprotocol.org/core/v1#> .
|
|
74
|
+
@prefix health: <https://ns.cascadeprotocol.org/health/v1#> .
|
|
75
|
+
@prefix clinical: <https://ns.cascadeprotocol.org/clinical/v1#> .
|
|
76
|
+
|
|
77
|
+
# =============================================================================
|
|
78
|
+
# Public Type Index
|
|
79
|
+
# =============================================================================
|
|
80
|
+
# Maps data types to their storage locations in the Pod.
|
|
81
|
+
# Public registrations are visible to authorized applications.
|
|
82
|
+
#
|
|
83
|
+
# As you populate your Pod with data, add type registrations here so that
|
|
84
|
+
# agents and applications can discover where to find each data type.
|
|
85
|
+
# =============================================================================
|
|
86
|
+
|
|
87
|
+
<> a solid:TypeIndex, solid:ListedDocument .
|
|
88
|
+
|
|
89
|
+
# Type registrations will be added as data is populated.
|
|
90
|
+
# Example:
|
|
91
|
+
# <#medications> a solid:TypeRegistration ;
|
|
92
|
+
# solid:forClass health:MedicationRecord ;
|
|
93
|
+
# solid:instance </clinical/medications.ttl> .
|
|
94
|
+
#
|
|
95
|
+
# <#conditions> a solid:TypeRegistration ;
|
|
96
|
+
# solid:forClass health:ConditionRecord ;
|
|
97
|
+
# solid:instance </clinical/conditions.ttl> .
|
|
98
|
+
`;
|
|
99
|
+
const PRIVATE_TYPE_INDEX_TTL = `@prefix solid: <http://www.w3.org/ns/solid/terms#> .
|
|
100
|
+
@prefix health: <https://ns.cascadeprotocol.org/health/v1#> .
|
|
101
|
+
@prefix clinical: <https://ns.cascadeprotocol.org/clinical/v1#> .
|
|
102
|
+
|
|
103
|
+
# =============================================================================
|
|
104
|
+
# Private Type Index
|
|
105
|
+
# =============================================================================
|
|
106
|
+
# Maps wellness and device data types to their storage locations.
|
|
107
|
+
# Private registrations require explicit authorization to access.
|
|
108
|
+
#
|
|
109
|
+
# Wellness data (heart rate, activity, sleep, etc.) is typically registered
|
|
110
|
+
# here rather than in the public type index.
|
|
111
|
+
# =============================================================================
|
|
112
|
+
|
|
113
|
+
<> a solid:TypeIndex, solid:UnlistedDocument .
|
|
114
|
+
|
|
115
|
+
# Type registrations will be added as wellness data is populated.
|
|
116
|
+
# Example:
|
|
117
|
+
# <#heartRate> a solid:TypeRegistration ;
|
|
118
|
+
# solid:forClass health:HeartRateData ;
|
|
119
|
+
# solid:instance </wellness/heart-rate.ttl> .
|
|
120
|
+
`;
|
|
121
|
+
function indexTtl(dirName) {
|
|
122
|
+
const now = new Date().toISOString();
|
|
123
|
+
return `@prefix ldp: <http://www.w3.org/ns/ldp#> .
|
|
124
|
+
@prefix dcterms: <http://purl.org/dc/terms/> .
|
|
125
|
+
@prefix cascade: <https://ns.cascadeprotocol.org/core/v1#> .
|
|
126
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
127
|
+
|
|
128
|
+
# =============================================================================
|
|
129
|
+
# Root LDP Container
|
|
130
|
+
# =============================================================================
|
|
131
|
+
# This is the root index of this Cascade Pod, structured as an LDP Basic
|
|
132
|
+
# Container per Solid Protocol conventions. It enumerates every resource
|
|
133
|
+
# in the Pod for discoverability by agents and applications.
|
|
134
|
+
#
|
|
135
|
+
# Update this file as you add or remove resources.
|
|
136
|
+
# =============================================================================
|
|
137
|
+
|
|
138
|
+
<> a ldp:BasicContainer ;
|
|
139
|
+
dcterms:title "${dirName} -- Cascade Pod" ;
|
|
140
|
+
dcterms:description "A Cascade Protocol Pod initialized with the cascade CLI." ;
|
|
141
|
+
dcterms:created "${now}"^^xsd:dateTime ;
|
|
142
|
+
cascade:schemaVersion "1.3" ;
|
|
143
|
+
|
|
144
|
+
# ── Profile & Settings ──
|
|
145
|
+
ldp:contains
|
|
146
|
+
<profile/card.ttl> ,
|
|
147
|
+
<settings/publicTypeIndex.ttl> ,
|
|
148
|
+
<settings/privateTypeIndex.ttl> .
|
|
149
|
+
|
|
150
|
+
# ── Add clinical and wellness resources below as they are created ──
|
|
151
|
+
# ldp:contains <clinical/medications.ttl> .
|
|
152
|
+
# ldp:contains <wellness/heart-rate.ttl> .
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
const README_MD = `# Cascade Protocol Pod
|
|
156
|
+
|
|
157
|
+
This directory is a **Cascade Protocol Pod** -- a portable, self-describing collection of personal health data serialized as RDF/Turtle files.
|
|
158
|
+
|
|
159
|
+
## Structure
|
|
160
|
+
|
|
161
|
+
\`\`\`
|
|
162
|
+
.well-known/
|
|
163
|
+
solid # Pod discovery document (JSON)
|
|
164
|
+
profile/
|
|
165
|
+
card.ttl # WebID profile (identity + discovery links)
|
|
166
|
+
settings/
|
|
167
|
+
publicTypeIndex.ttl # Maps clinical data types to file locations
|
|
168
|
+
privateTypeIndex.ttl # Maps wellness data types to file locations
|
|
169
|
+
clinical/ # Clinical records (EHR-sourced data)
|
|
170
|
+
wellness/ # Wellness records (device and self-reported data)
|
|
171
|
+
index.ttl # Root LDP container listing all resources
|
|
172
|
+
\`\`\`
|
|
173
|
+
|
|
174
|
+
## Getting Started
|
|
175
|
+
|
|
176
|
+
1. Edit \`profile/card.ttl\` to set the Pod owner's name and demographics.
|
|
177
|
+
2. Add clinical data files (e.g., \`clinical/medications.ttl\`) and register them in \`settings/publicTypeIndex.ttl\`.
|
|
178
|
+
3. Add wellness data files (e.g., \`wellness/heart-rate.ttl\`) and register them in \`settings/privateTypeIndex.ttl\`.
|
|
179
|
+
4. Update \`index.ttl\` to list all resources.
|
|
180
|
+
|
|
181
|
+
## Useful Commands
|
|
182
|
+
|
|
183
|
+
\`\`\`bash
|
|
184
|
+
cascade pod info . # Show Pod summary
|
|
185
|
+
cascade pod query . --all # Query all data in the Pod
|
|
186
|
+
cascade pod export . --format zip # Export as ZIP archive
|
|
187
|
+
cascade validate . # Validate against SHACL shapes
|
|
188
|
+
\`\`\`
|
|
189
|
+
|
|
190
|
+
## Learn More
|
|
191
|
+
|
|
192
|
+
- Cascade Protocol: https://cascadeprotocol.org
|
|
193
|
+
- Pod Structure Spec: https://cascadeprotocol.org/docs/spec/pod-structure
|
|
194
|
+
- Cascade SDK: https://github.com/nickthorpe71/cascade-sdk-swift
|
|
195
|
+
`;
|
|
196
|
+
const DATA_TYPES = {
|
|
197
|
+
medications: {
|
|
198
|
+
label: 'Medications',
|
|
199
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'MedicationRecord'],
|
|
200
|
+
directory: 'clinical',
|
|
201
|
+
filename: 'medications.ttl',
|
|
202
|
+
},
|
|
203
|
+
conditions: {
|
|
204
|
+
label: 'Conditions',
|
|
205
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'ConditionRecord'],
|
|
206
|
+
directory: 'clinical',
|
|
207
|
+
filename: 'conditions.ttl',
|
|
208
|
+
},
|
|
209
|
+
allergies: {
|
|
210
|
+
label: 'Allergies',
|
|
211
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'AllergyRecord'],
|
|
212
|
+
directory: 'clinical',
|
|
213
|
+
filename: 'allergies.ttl',
|
|
214
|
+
},
|
|
215
|
+
'lab-results': {
|
|
216
|
+
label: 'Lab Results',
|
|
217
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'LabResultRecord'],
|
|
218
|
+
directory: 'clinical',
|
|
219
|
+
filename: 'lab-results.ttl',
|
|
220
|
+
},
|
|
221
|
+
immunizations: {
|
|
222
|
+
label: 'Immunizations',
|
|
223
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'ImmunizationRecord'],
|
|
224
|
+
directory: 'clinical',
|
|
225
|
+
filename: 'immunizations.ttl',
|
|
226
|
+
},
|
|
227
|
+
'vital-signs': {
|
|
228
|
+
label: 'Vital Signs',
|
|
229
|
+
rdfTypes: [CASCADE_NAMESPACES.clinical + 'VitalSign'],
|
|
230
|
+
directory: 'clinical',
|
|
231
|
+
filename: 'vital-signs.ttl',
|
|
232
|
+
},
|
|
233
|
+
insurance: {
|
|
234
|
+
label: 'Insurance',
|
|
235
|
+
rdfTypes: [CASCADE_NAMESPACES.clinical + 'CoverageRecord'],
|
|
236
|
+
directory: 'clinical',
|
|
237
|
+
filename: 'insurance.ttl',
|
|
238
|
+
},
|
|
239
|
+
'patient-profile': {
|
|
240
|
+
label: 'Patient Profile',
|
|
241
|
+
rdfTypes: [CASCADE_NAMESPACES.cascade + 'PatientProfile'],
|
|
242
|
+
directory: 'clinical',
|
|
243
|
+
filename: 'patient-profile.ttl',
|
|
244
|
+
},
|
|
245
|
+
'heart-rate': {
|
|
246
|
+
label: 'Heart Rate',
|
|
247
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'DailyVitalReading', CASCADE_NAMESPACES.health + 'HeartRateData'],
|
|
248
|
+
directory: 'wellness',
|
|
249
|
+
filename: 'heart-rate.ttl',
|
|
250
|
+
},
|
|
251
|
+
'blood-pressure': {
|
|
252
|
+
label: 'Blood Pressure',
|
|
253
|
+
rdfTypes: [
|
|
254
|
+
'http://hl7.org/fhir/Observation',
|
|
255
|
+
CASCADE_NAMESPACES.health + 'BloodPressureData',
|
|
256
|
+
],
|
|
257
|
+
directory: 'wellness',
|
|
258
|
+
filename: 'blood-pressure.ttl',
|
|
259
|
+
},
|
|
260
|
+
activity: {
|
|
261
|
+
label: 'Activity',
|
|
262
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'DailyActivitySnapshot', CASCADE_NAMESPACES.health + 'ActivityData'],
|
|
263
|
+
directory: 'wellness',
|
|
264
|
+
filename: 'activity.ttl',
|
|
265
|
+
},
|
|
266
|
+
sleep: {
|
|
267
|
+
label: 'Sleep',
|
|
268
|
+
rdfTypes: [CASCADE_NAMESPACES.health + 'DailySleepSnapshot', CASCADE_NAMESPACES.health + 'SleepData'],
|
|
269
|
+
directory: 'wellness',
|
|
270
|
+
filename: 'sleep.ttl',
|
|
271
|
+
},
|
|
272
|
+
supplements: {
|
|
273
|
+
label: 'Supplements',
|
|
274
|
+
rdfTypes: [CASCADE_NAMESPACES.clinical + 'Supplement'],
|
|
275
|
+
directory: 'wellness',
|
|
276
|
+
filename: 'supplements.ttl',
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
280
|
+
/**
|
|
281
|
+
* Resolve a pod directory path to an absolute path.
|
|
282
|
+
*/
|
|
283
|
+
function resolvePodDir(podDir) {
|
|
284
|
+
return path.resolve(process.cwd(), podDir);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Check if a path exists and is a directory.
|
|
288
|
+
*/
|
|
289
|
+
async function isDirectory(dirPath) {
|
|
290
|
+
try {
|
|
291
|
+
const stat = await fs.stat(dirPath);
|
|
292
|
+
return stat.isDirectory();
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Check if a file exists.
|
|
300
|
+
*/
|
|
301
|
+
async function fileExists(filePath) {
|
|
302
|
+
try {
|
|
303
|
+
await fs.access(filePath);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Discover all TTL files in a pod directory recursively.
|
|
312
|
+
*/
|
|
313
|
+
async function discoverTtlFiles(podDir) {
|
|
314
|
+
const files = [];
|
|
315
|
+
async function walk(dir) {
|
|
316
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
const fullPath = path.join(dir, entry.name);
|
|
319
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
320
|
+
await walk(fullPath);
|
|
321
|
+
}
|
|
322
|
+
else if (entry.isFile() && entry.name.endsWith('.ttl')) {
|
|
323
|
+
files.push(fullPath);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
await walk(podDir);
|
|
328
|
+
return files.sort();
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Parse a single TTL file and extract typed records.
|
|
332
|
+
*/
|
|
333
|
+
async function parseDataFile(filePath) {
|
|
334
|
+
const result = await parseTurtleFile(filePath);
|
|
335
|
+
if (!result.success) {
|
|
336
|
+
return { records: [], totalQuads: 0, error: result.errors.join('; ') };
|
|
337
|
+
}
|
|
338
|
+
const records = [];
|
|
339
|
+
for (const subject of result.subjects) {
|
|
340
|
+
// Skip blank nodes that are just structural (e.g., nested blank nodes for provenance)
|
|
341
|
+
// Keep named subjects (URNs, URIs) and typed blank nodes with meaningful types
|
|
342
|
+
const meaningfulTypes = subject.types.filter((t) => !t.startsWith('http://www.w3.org/ns/prov#') &&
|
|
343
|
+
t !== 'http://www.w3.org/ns/solid/terms#TypeRegistration' &&
|
|
344
|
+
t !== 'http://www.w3.org/ns/solid/terms#TypeIndex' &&
|
|
345
|
+
t !== 'http://www.w3.org/ns/solid/terms#ListedDocument' &&
|
|
346
|
+
t !== 'http://www.w3.org/ns/solid/terms#UnlistedDocument' &&
|
|
347
|
+
t !== 'http://www.w3.org/ns/ldp#BasicContainer');
|
|
348
|
+
if (meaningfulTypes.length === 0)
|
|
349
|
+
continue;
|
|
350
|
+
const props = getProperties(result.store, subject.uri);
|
|
351
|
+
const label = extractLabel(props);
|
|
352
|
+
// Flatten properties for display (take first value of each, shorten IRIs)
|
|
353
|
+
const flatProps = {};
|
|
354
|
+
for (const [pred, values] of Object.entries(props)) {
|
|
355
|
+
const shortPred = shortenIRI(pred);
|
|
356
|
+
// Skip rdf:type since we have it separately
|
|
357
|
+
if (pred === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type')
|
|
358
|
+
continue;
|
|
359
|
+
flatProps[shortPred] = values.length === 1 ? values[0] : values.join(', ');
|
|
360
|
+
}
|
|
361
|
+
records.push({
|
|
362
|
+
id: subject.uri,
|
|
363
|
+
type: shortenIRI(meaningfulTypes[0]),
|
|
364
|
+
label,
|
|
365
|
+
properties: flatProps,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return { records, totalQuads: result.quadCount };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Read the patient profile from a pod to extract name, age, schema version.
|
|
372
|
+
*/
|
|
373
|
+
async function readPatientProfile(podDir) {
|
|
374
|
+
// Try clinical/patient-profile.ttl first, then profile/card.ttl
|
|
375
|
+
const profilePaths = [
|
|
376
|
+
path.join(podDir, 'clinical', 'patient-profile.ttl'),
|
|
377
|
+
path.join(podDir, 'profile', 'card.ttl'),
|
|
378
|
+
];
|
|
379
|
+
let name;
|
|
380
|
+
let age;
|
|
381
|
+
let schemaVersion;
|
|
382
|
+
let dateOfBirth;
|
|
383
|
+
for (const profilePath of profilePaths) {
|
|
384
|
+
if (!(await fileExists(profilePath)))
|
|
385
|
+
continue;
|
|
386
|
+
const result = await parseTurtleFile(profilePath);
|
|
387
|
+
if (!result.success)
|
|
388
|
+
continue;
|
|
389
|
+
for (const subject of result.subjects) {
|
|
390
|
+
const props = getProperties(result.store, subject.uri);
|
|
391
|
+
if (!name) {
|
|
392
|
+
name = props['http://xmlns.com/foaf/0.1/name']?.[0];
|
|
393
|
+
}
|
|
394
|
+
if (!age) {
|
|
395
|
+
age = props[CASCADE_NAMESPACES.cascade + 'computedAge']?.[0];
|
|
396
|
+
}
|
|
397
|
+
if (!schemaVersion) {
|
|
398
|
+
schemaVersion = props[CASCADE_NAMESPACES.cascade + 'schemaVersion']?.[0];
|
|
399
|
+
}
|
|
400
|
+
if (!dateOfBirth) {
|
|
401
|
+
dateOfBirth = props[CASCADE_NAMESPACES.cascade + 'dateOfBirth']?.[0];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { name, age, schemaVersion, dateOfBirth };
|
|
406
|
+
}
|
|
407
|
+
// ─── Command Registration ────────────────────────────────────────────────────
|
|
408
|
+
export function registerPodCommand(program) {
|
|
409
|
+
const pod = program.command('pod').description('Manage Cascade Pod structures');
|
|
410
|
+
// ── cascade pod init <directory> ───────────────────────────────────────────
|
|
411
|
+
pod
|
|
412
|
+
.command('init')
|
|
413
|
+
.description('Initialize a new Cascade Pod')
|
|
414
|
+
.argument('<directory>', 'Directory to initialize as a Cascade Pod')
|
|
415
|
+
.action(async (directory) => {
|
|
416
|
+
const globalOpts = program.opts();
|
|
417
|
+
const absDir = resolvePodDir(directory);
|
|
418
|
+
const dirName = path.basename(absDir);
|
|
419
|
+
printVerbose(`Initializing pod at: ${absDir}`, globalOpts);
|
|
420
|
+
try {
|
|
421
|
+
// Check if directory already has pod structure
|
|
422
|
+
if (await fileExists(path.join(absDir, 'index.ttl'))) {
|
|
423
|
+
printError(`Directory already contains a Cascade Pod: ${absDir}`, globalOpts);
|
|
424
|
+
process.exitCode = 1;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Create directory structure
|
|
428
|
+
const dirs = [
|
|
429
|
+
path.join(absDir, '.well-known'),
|
|
430
|
+
path.join(absDir, 'profile'),
|
|
431
|
+
path.join(absDir, 'settings'),
|
|
432
|
+
path.join(absDir, 'clinical'),
|
|
433
|
+
path.join(absDir, 'wellness'),
|
|
434
|
+
];
|
|
435
|
+
for (const dir of dirs) {
|
|
436
|
+
await fs.mkdir(dir, { recursive: true });
|
|
437
|
+
}
|
|
438
|
+
// Write template files
|
|
439
|
+
await fs.writeFile(path.join(absDir, '.well-known', 'solid'), wellKnownSolid(absDir));
|
|
440
|
+
await fs.writeFile(path.join(absDir, 'profile', 'card.ttl'), PROFILE_CARD_TTL);
|
|
441
|
+
await fs.writeFile(path.join(absDir, 'settings', 'publicTypeIndex.ttl'), PUBLIC_TYPE_INDEX_TTL);
|
|
442
|
+
await fs.writeFile(path.join(absDir, 'settings', 'privateTypeIndex.ttl'), PRIVATE_TYPE_INDEX_TTL);
|
|
443
|
+
await fs.writeFile(path.join(absDir, 'index.ttl'), indexTtl(dirName));
|
|
444
|
+
await fs.writeFile(path.join(absDir, 'README.md'), README_MD);
|
|
445
|
+
const filesCreated = [
|
|
446
|
+
'.well-known/solid',
|
|
447
|
+
'profile/card.ttl',
|
|
448
|
+
'settings/publicTypeIndex.ttl',
|
|
449
|
+
'settings/privateTypeIndex.ttl',
|
|
450
|
+
'clinical/',
|
|
451
|
+
'wellness/',
|
|
452
|
+
'index.ttl',
|
|
453
|
+
'README.md',
|
|
454
|
+
];
|
|
455
|
+
if (globalOpts.json) {
|
|
456
|
+
printResult({
|
|
457
|
+
status: 'created',
|
|
458
|
+
directory: absDir,
|
|
459
|
+
files: filesCreated,
|
|
460
|
+
message: 'Cascade Pod initialized successfully.',
|
|
461
|
+
}, globalOpts);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
console.log(`Cascade Pod initialized at: ${absDir}\n`);
|
|
465
|
+
console.log('Created:');
|
|
466
|
+
for (const f of filesCreated) {
|
|
467
|
+
console.log(` ${f}`);
|
|
468
|
+
}
|
|
469
|
+
console.log('\nNext steps:');
|
|
470
|
+
console.log(' 1. Edit profile/card.ttl to set patient name and demographics');
|
|
471
|
+
console.log(' 2. Add data files to clinical/ and wellness/ directories');
|
|
472
|
+
console.log(' 3. Register data types in settings/publicTypeIndex.ttl');
|
|
473
|
+
console.log(` 4. Run: cascade pod info ${directory}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
478
|
+
printError(`Failed to initialize pod: ${message}`, globalOpts);
|
|
479
|
+
process.exitCode = 1;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
// ── cascade pod query <pod-dir> ────────────────────────────────────────────
|
|
483
|
+
pod
|
|
484
|
+
.command('query')
|
|
485
|
+
.description('Query data within a pod')
|
|
486
|
+
.argument('<pod-dir>', 'Path to the Cascade Pod')
|
|
487
|
+
.option('--medications', 'Query medications')
|
|
488
|
+
.option('--conditions', 'Query conditions')
|
|
489
|
+
.option('--allergies', 'Query allergies')
|
|
490
|
+
.option('--lab-results', 'Query lab results')
|
|
491
|
+
.option('--immunizations', 'Query immunizations')
|
|
492
|
+
.option('--vital-signs', 'Query vital signs')
|
|
493
|
+
.option('--supplements', 'Query supplements')
|
|
494
|
+
.option('--all', 'Query all data')
|
|
495
|
+
.action(async (podDir, options) => {
|
|
496
|
+
const globalOpts = program.opts();
|
|
497
|
+
const absDir = resolvePodDir(podDir);
|
|
498
|
+
printVerbose(`Querying pod: ${absDir}`, globalOpts);
|
|
499
|
+
printVerbose(`Filters: ${JSON.stringify(options)}`, globalOpts);
|
|
500
|
+
// Validate pod exists
|
|
501
|
+
if (!(await isDirectory(absDir))) {
|
|
502
|
+
printError(`Pod directory not found: ${absDir}`, globalOpts);
|
|
503
|
+
process.exitCode = 1;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
// Determine which data types to query
|
|
508
|
+
let requestedTypes;
|
|
509
|
+
if (options.all) {
|
|
510
|
+
// Discover all TTL files in the pod
|
|
511
|
+
requestedTypes = Object.keys(DATA_TYPES);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
requestedTypes = [];
|
|
515
|
+
if (options.medications)
|
|
516
|
+
requestedTypes.push('medications');
|
|
517
|
+
if (options.conditions)
|
|
518
|
+
requestedTypes.push('conditions');
|
|
519
|
+
if (options.allergies)
|
|
520
|
+
requestedTypes.push('allergies');
|
|
521
|
+
if (options.labResults)
|
|
522
|
+
requestedTypes.push('lab-results');
|
|
523
|
+
if (options.immunizations)
|
|
524
|
+
requestedTypes.push('immunizations');
|
|
525
|
+
if (options.vitalSigns)
|
|
526
|
+
requestedTypes.push('vital-signs');
|
|
527
|
+
if (options.supplements)
|
|
528
|
+
requestedTypes.push('supplements');
|
|
529
|
+
}
|
|
530
|
+
if (requestedTypes.length === 0) {
|
|
531
|
+
printError('No query filter specified. Use --medications, --conditions, --all, etc.', globalOpts);
|
|
532
|
+
process.exitCode = 1;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// Process each requested data type
|
|
536
|
+
const queryResults = {};
|
|
537
|
+
// If --all, also discover any TTL files not in the registry
|
|
538
|
+
const extraFiles = [];
|
|
539
|
+
if (options.all) {
|
|
540
|
+
const allTtlFiles = await discoverTtlFiles(absDir);
|
|
541
|
+
const knownPaths = new Set(Object.values(DATA_TYPES).map((dt) => path.join(absDir, dt.directory, dt.filename)));
|
|
542
|
+
// Also exclude index.ttl, manifest.ttl, profile/card.ttl, type indexes
|
|
543
|
+
const excludePaths = new Set([
|
|
544
|
+
path.join(absDir, 'index.ttl'),
|
|
545
|
+
path.join(absDir, 'manifest.ttl'),
|
|
546
|
+
path.join(absDir, 'profile', 'card.ttl'),
|
|
547
|
+
path.join(absDir, 'settings', 'publicTypeIndex.ttl'),
|
|
548
|
+
path.join(absDir, 'settings', 'privateTypeIndex.ttl'),
|
|
549
|
+
]);
|
|
550
|
+
for (const f of allTtlFiles) {
|
|
551
|
+
if (!knownPaths.has(f) && !excludePaths.has(f)) {
|
|
552
|
+
extraFiles.push(f);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const typeName of requestedTypes) {
|
|
557
|
+
const typeInfo = DATA_TYPES[typeName];
|
|
558
|
+
if (!typeInfo)
|
|
559
|
+
continue;
|
|
560
|
+
const filePath = path.join(absDir, typeInfo.directory, typeInfo.filename);
|
|
561
|
+
if (!(await fileExists(filePath))) {
|
|
562
|
+
printVerbose(`Skipping ${typeName}: file not found at ${filePath}`, globalOpts);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const { records, error } = await parseDataFile(filePath);
|
|
566
|
+
queryResults[typeName] = {
|
|
567
|
+
count: records.length,
|
|
568
|
+
file: `${typeInfo.directory}/${typeInfo.filename}`,
|
|
569
|
+
records: records.map((r) => ({
|
|
570
|
+
id: r.id,
|
|
571
|
+
type: r.type,
|
|
572
|
+
properties: r.properties,
|
|
573
|
+
})),
|
|
574
|
+
error,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
// Process extra files found in --all mode
|
|
578
|
+
for (const extraFile of extraFiles) {
|
|
579
|
+
const relPath = path.relative(absDir, extraFile);
|
|
580
|
+
const baseName = path.basename(extraFile, '.ttl');
|
|
581
|
+
const { records, error } = await parseDataFile(extraFile);
|
|
582
|
+
if (records.length > 0) {
|
|
583
|
+
queryResults[baseName] = {
|
|
584
|
+
count: records.length,
|
|
585
|
+
file: relPath,
|
|
586
|
+
records: records.map((r) => ({
|
|
587
|
+
id: r.id,
|
|
588
|
+
type: r.type,
|
|
589
|
+
properties: r.properties,
|
|
590
|
+
})),
|
|
591
|
+
error,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Output results
|
|
596
|
+
if (globalOpts.json) {
|
|
597
|
+
printResult({
|
|
598
|
+
pod: podDir,
|
|
599
|
+
dataTypes: queryResults,
|
|
600
|
+
}, globalOpts);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
// Human-readable output
|
|
604
|
+
const typeKeys = Object.keys(queryResults);
|
|
605
|
+
if (typeKeys.length === 0) {
|
|
606
|
+
console.log('No data found for the specified query filters.');
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
for (const typeName of typeKeys) {
|
|
610
|
+
const data = queryResults[typeName];
|
|
611
|
+
const typeInfo = DATA_TYPES[typeName];
|
|
612
|
+
const displayLabel = typeInfo?.label ?? typeName;
|
|
613
|
+
console.log(`\n=== ${displayLabel} (${data.count} records) ===`);
|
|
614
|
+
if (data.error) {
|
|
615
|
+
console.log(` Error: ${data.error}`);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
console.log(` File: ${data.file}\n`);
|
|
619
|
+
for (let i = 0; i < data.records.length; i++) {
|
|
620
|
+
const rec = data.records[i];
|
|
621
|
+
const label = extractLabelFromProps(rec.properties);
|
|
622
|
+
const idShort = rec.id.length > 40 ? rec.id.substring(0, 40) + '...' : rec.id;
|
|
623
|
+
console.log(` ${i + 1}. ${label ?? rec.type} (${idShort})`);
|
|
624
|
+
// Show key properties
|
|
625
|
+
const keyProps = selectKeyProperties(typeName, rec.properties);
|
|
626
|
+
for (const [key, value] of Object.entries(keyProps)) {
|
|
627
|
+
console.log(` ${key}: ${value}`);
|
|
628
|
+
}
|
|
629
|
+
console.log('');
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
636
|
+
printError(`Failed to query pod: ${message}`, globalOpts);
|
|
637
|
+
process.exitCode = 1;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
// ── cascade pod export <pod-dir> ───────────────────────────────────────────
|
|
641
|
+
pod
|
|
642
|
+
.command('export')
|
|
643
|
+
.description('Export pod data')
|
|
644
|
+
.argument('<pod-dir>', 'Path to the Cascade Pod')
|
|
645
|
+
.option('--format <fmt>', 'Export format (zip|directory)', 'zip')
|
|
646
|
+
.option('--output <path>', 'Output path for export')
|
|
647
|
+
.action(async (podDir, options) => {
|
|
648
|
+
const globalOpts = program.opts();
|
|
649
|
+
const absDir = resolvePodDir(podDir);
|
|
650
|
+
printVerbose(`Exporting pod: ${absDir} as ${options.format}`, globalOpts);
|
|
651
|
+
// Validate pod exists
|
|
652
|
+
if (!(await isDirectory(absDir))) {
|
|
653
|
+
printError(`Pod directory not found: ${absDir}`, globalOpts);
|
|
654
|
+
process.exitCode = 1;
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
if (options.format === 'directory') {
|
|
659
|
+
// Copy to new directory
|
|
660
|
+
const outputDir = options.output ?? `${absDir}-export`;
|
|
661
|
+
await copyDirectory(absDir, outputDir);
|
|
662
|
+
if (globalOpts.json) {
|
|
663
|
+
printResult({
|
|
664
|
+
status: 'exported',
|
|
665
|
+
format: 'directory',
|
|
666
|
+
source: absDir,
|
|
667
|
+
output: outputDir,
|
|
668
|
+
}, globalOpts);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
console.log(`Pod exported to directory: ${outputDir}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else if (options.format === 'zip') {
|
|
675
|
+
// Create ZIP archive
|
|
676
|
+
const outputZip = options.output ?? `${path.basename(absDir)}.zip`;
|
|
677
|
+
const absOutputZip = path.resolve(process.cwd(), outputZip);
|
|
678
|
+
await createZipArchive(absDir, absOutputZip);
|
|
679
|
+
if (globalOpts.json) {
|
|
680
|
+
printResult({
|
|
681
|
+
status: 'exported',
|
|
682
|
+
format: 'zip',
|
|
683
|
+
source: absDir,
|
|
684
|
+
output: absOutputZip,
|
|
685
|
+
}, globalOpts);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
console.log(`Pod exported to ZIP: ${absOutputZip}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
printError(`Unknown export format: ${options.format}. Use 'zip' or 'directory'.`, globalOpts);
|
|
693
|
+
process.exitCode = 1;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
698
|
+
printError(`Failed to export pod: ${message}`, globalOpts);
|
|
699
|
+
process.exitCode = 1;
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
// ── cascade pod info <pod-dir> ─────────────────────────────────────────────
|
|
703
|
+
pod
|
|
704
|
+
.command('info')
|
|
705
|
+
.description('Show pod metadata and statistics')
|
|
706
|
+
.argument('<pod-dir>', 'Path to the Cascade Pod')
|
|
707
|
+
.action(async (podDir) => {
|
|
708
|
+
const globalOpts = program.opts();
|
|
709
|
+
const absDir = resolvePodDir(podDir);
|
|
710
|
+
printVerbose(`Getting info for pod: ${absDir}`, globalOpts);
|
|
711
|
+
// Validate pod exists
|
|
712
|
+
if (!(await isDirectory(absDir))) {
|
|
713
|
+
printError(`Pod directory not found: ${absDir}`, globalOpts);
|
|
714
|
+
process.exitCode = 1;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
// Read patient profile info
|
|
719
|
+
const profile = await readPatientProfile(absDir);
|
|
720
|
+
// Scan data files
|
|
721
|
+
const clinicalSummary = [];
|
|
722
|
+
const wellnessSummary = [];
|
|
723
|
+
const provenanceSources = new Set();
|
|
724
|
+
// Get last modified time of the pod
|
|
725
|
+
let lastModified;
|
|
726
|
+
const allTtlFiles = await discoverTtlFiles(absDir);
|
|
727
|
+
for (const filePath of allTtlFiles) {
|
|
728
|
+
const stat = await fs.stat(filePath);
|
|
729
|
+
if (!lastModified || stat.mtime > lastModified) {
|
|
730
|
+
lastModified = stat.mtime;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// Analyze each known data type
|
|
734
|
+
for (const [, typeInfo] of Object.entries(DATA_TYPES)) {
|
|
735
|
+
const filePath = path.join(absDir, typeInfo.directory, typeInfo.filename);
|
|
736
|
+
if (!(await fileExists(filePath)))
|
|
737
|
+
continue;
|
|
738
|
+
const result = await parseTurtleFile(filePath);
|
|
739
|
+
if (!result.success)
|
|
740
|
+
continue;
|
|
741
|
+
// Count records by type
|
|
742
|
+
let recordCount = 0;
|
|
743
|
+
for (const rdfType of typeInfo.rdfTypes) {
|
|
744
|
+
recordCount += getSubjectsByType(result.store, rdfType).length;
|
|
745
|
+
}
|
|
746
|
+
// If no records found by type, count all typed subjects
|
|
747
|
+
if (recordCount === 0 && result.subjects.length > 0) {
|
|
748
|
+
recordCount = result.subjects.length;
|
|
749
|
+
}
|
|
750
|
+
// Detect provenance
|
|
751
|
+
const provenanceValues = new Set();
|
|
752
|
+
for (const subject of result.subjects) {
|
|
753
|
+
const props = getProperties(result.store, subject.uri);
|
|
754
|
+
const prov = props[CASCADE_NAMESPACES.cascade + 'dataProvenance'];
|
|
755
|
+
if (prov) {
|
|
756
|
+
for (const p of prov) {
|
|
757
|
+
const shortProv = normalizeProvenanceLabel(shortenIRI(p));
|
|
758
|
+
provenanceValues.add(shortProv);
|
|
759
|
+
provenanceSources.add(shortProv);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// For wellness files, also check for prov:wasGeneratedBy / cascade:sourceType
|
|
764
|
+
// which indicates DeviceGenerated provenance
|
|
765
|
+
if (provenanceValues.size === 0) {
|
|
766
|
+
const allQuads = result.quads;
|
|
767
|
+
const hasDeviceSource = allQuads.some((q) => (q.predicate.value === CASCADE_NAMESPACES.cascade + 'sourceType' &&
|
|
768
|
+
(q.object.value === 'healthKit' || q.object.value === 'bluetoothDevice')) ||
|
|
769
|
+
// Also detect device provenance from prov:wasGeneratedBy patterns
|
|
770
|
+
(q.predicate.value === 'http://www.w3.org/ns/prov#wasGeneratedBy'));
|
|
771
|
+
// If in wellness directory and has device data patterns, infer DeviceGenerated
|
|
772
|
+
if (hasDeviceSource || typeInfo.directory === 'wellness') {
|
|
773
|
+
const hasDeviceTypes = allQuads.some((q) => q.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' &&
|
|
774
|
+
(q.object.value.includes('HeartRateData') ||
|
|
775
|
+
q.object.value.includes('BloodPressureData') ||
|
|
776
|
+
q.object.value.includes('ActivityData') ||
|
|
777
|
+
q.object.value.includes('SleepData') ||
|
|
778
|
+
q.object.value.includes('DailyVitalReading') ||
|
|
779
|
+
q.object.value.includes('DailyActivitySnapshot') ||
|
|
780
|
+
q.object.value.includes('DailySleepSnapshot') ||
|
|
781
|
+
q.object.value === 'http://hl7.org/fhir/Observation'));
|
|
782
|
+
if (hasDeviceSource || hasDeviceTypes) {
|
|
783
|
+
provenanceValues.add('cascade:DeviceGenerated');
|
|
784
|
+
provenanceSources.add('cascade:DeviceGenerated');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const provenanceStr = provenanceValues.size > 0
|
|
789
|
+
? Array.from(provenanceValues).join(', ')
|
|
790
|
+
: 'Unknown';
|
|
791
|
+
// Determine record description
|
|
792
|
+
let recordDesc;
|
|
793
|
+
// For time-series data (vital signs, heart rate, etc.), show as "X days" if applicable
|
|
794
|
+
const isTimeSeries = ['vital-signs', 'heart-rate', 'blood-pressure', 'activity', 'sleep'].some((ts) => typeInfo.filename.includes(ts.replace('-', '-')));
|
|
795
|
+
if (isTimeSeries && recordCount >= 28) {
|
|
796
|
+
recordDesc = `${recordCount} days`;
|
|
797
|
+
}
|
|
798
|
+
else if (recordCount === 1) {
|
|
799
|
+
recordDesc = '1 record';
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
recordDesc = `${recordCount} records`;
|
|
803
|
+
}
|
|
804
|
+
const entry = {
|
|
805
|
+
file: typeInfo.filename,
|
|
806
|
+
records: recordCount,
|
|
807
|
+
provenance: provenanceStr,
|
|
808
|
+
label: `${typeInfo.filename.padEnd(22)} ${recordDesc.padEnd(16)} (${provenanceStr})`,
|
|
809
|
+
};
|
|
810
|
+
if (typeInfo.directory === 'clinical') {
|
|
811
|
+
clinicalSummary.push(entry);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
wellnessSummary.push(entry);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (globalOpts.json) {
|
|
818
|
+
printResult({
|
|
819
|
+
pod: podDir,
|
|
820
|
+
patient: {
|
|
821
|
+
name: profile.name,
|
|
822
|
+
age: profile.age,
|
|
823
|
+
dateOfBirth: profile.dateOfBirth,
|
|
824
|
+
},
|
|
825
|
+
schemaVersion: profile.schemaVersion,
|
|
826
|
+
lastModified: lastModified?.toISOString(),
|
|
827
|
+
clinical: clinicalSummary.map((s) => ({
|
|
828
|
+
file: s.file,
|
|
829
|
+
records: s.records,
|
|
830
|
+
provenance: s.provenance,
|
|
831
|
+
})),
|
|
832
|
+
wellness: wellnessSummary.map((s) => ({
|
|
833
|
+
file: s.file,
|
|
834
|
+
records: s.records,
|
|
835
|
+
provenance: s.provenance,
|
|
836
|
+
})),
|
|
837
|
+
provenanceSources: Array.from(provenanceSources),
|
|
838
|
+
}, globalOpts);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
// Human-readable output
|
|
842
|
+
console.log(`\nCascade Pod: ${podDir}\n`);
|
|
843
|
+
if (profile.name) {
|
|
844
|
+
const ageStr = profile.age ? ` (age ${profile.age})` : '';
|
|
845
|
+
console.log(`Patient: ${profile.name}${ageStr}`);
|
|
846
|
+
}
|
|
847
|
+
if (profile.schemaVersion) {
|
|
848
|
+
console.log(`Schema Version: ${profile.schemaVersion}`);
|
|
849
|
+
}
|
|
850
|
+
if (lastModified) {
|
|
851
|
+
console.log(`Last Modified: ${lastModified.toISOString().split('T')[0]}`);
|
|
852
|
+
}
|
|
853
|
+
if (clinicalSummary.length > 0) {
|
|
854
|
+
console.log('\nData Summary:');
|
|
855
|
+
console.log(' Clinical:');
|
|
856
|
+
for (const entry of clinicalSummary) {
|
|
857
|
+
console.log(` ${entry.label}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (wellnessSummary.length > 0) {
|
|
861
|
+
if (clinicalSummary.length === 0) {
|
|
862
|
+
console.log('\nData Summary:');
|
|
863
|
+
}
|
|
864
|
+
console.log(' Wellness:');
|
|
865
|
+
for (const entry of wellnessSummary) {
|
|
866
|
+
console.log(` ${entry.label}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (provenanceSources.size > 0) {
|
|
870
|
+
console.log(`\nProvenance Sources: ${Array.from(provenanceSources).join(', ')}`);
|
|
871
|
+
}
|
|
872
|
+
if (clinicalSummary.length === 0 && wellnessSummary.length === 0) {
|
|
873
|
+
console.log('\nThis pod has no data files yet.');
|
|
874
|
+
console.log('Add TTL files to the clinical/ or wellness/ directories to get started.');
|
|
875
|
+
}
|
|
876
|
+
console.log('');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
881
|
+
printError(`Failed to read pod info: ${message}`, globalOpts);
|
|
882
|
+
process.exitCode = 1;
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
// ─── Export Helpers ──────────────────────────────────────────────────────────
|
|
887
|
+
/**
|
|
888
|
+
* Recursively copy a directory.
|
|
889
|
+
*/
|
|
890
|
+
async function copyDirectory(src, dest) {
|
|
891
|
+
await fs.mkdir(dest, { recursive: true });
|
|
892
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
893
|
+
for (const entry of entries) {
|
|
894
|
+
const srcPath = path.join(src, entry.name);
|
|
895
|
+
const destPath = path.join(dest, entry.name);
|
|
896
|
+
if (entry.isDirectory()) {
|
|
897
|
+
await copyDirectory(srcPath, destPath);
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
await fs.copyFile(srcPath, destPath);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Create a ZIP archive of the pod directory using the archiver package.
|
|
906
|
+
*/
|
|
907
|
+
async function createZipArchive(sourceDir, outputPath) {
|
|
908
|
+
// Dynamic import of archiver
|
|
909
|
+
let archiverModule;
|
|
910
|
+
try {
|
|
911
|
+
archiverModule = await import('archiver');
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
throw new Error('The "archiver" package is required for ZIP export. ' +
|
|
915
|
+
'Install it with: npm install archiver');
|
|
916
|
+
}
|
|
917
|
+
const { createWriteStream } = await import('fs');
|
|
918
|
+
return new Promise((resolve, reject) => {
|
|
919
|
+
const output = createWriteStream(outputPath);
|
|
920
|
+
const archive = archiverModule.default('zip', { zlib: { level: 9 } });
|
|
921
|
+
output.on('close', () => resolve());
|
|
922
|
+
archive.on('error', (err) => reject(err));
|
|
923
|
+
archive.pipe(output);
|
|
924
|
+
archive.directory(sourceDir, path.basename(sourceDir));
|
|
925
|
+
void archive.finalize();
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
// ─── Display Helpers ─────────────────────────────────────────────────────────
|
|
929
|
+
/**
|
|
930
|
+
* Normalize provenance label for consistent display.
|
|
931
|
+
* Converts "core:ClinicalGenerated" to "cascade:ClinicalGenerated" since
|
|
932
|
+
* the "core" and "cascade" prefixes map to the same namespace.
|
|
933
|
+
*/
|
|
934
|
+
function normalizeProvenanceLabel(label) {
|
|
935
|
+
if (label.startsWith('core:')) {
|
|
936
|
+
return 'cascade:' + label.slice(5);
|
|
937
|
+
}
|
|
938
|
+
return label;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Extract a display label from already-shortened property keys.
|
|
942
|
+
*/
|
|
943
|
+
function extractLabelFromProps(properties) {
|
|
944
|
+
const labelKeys = [
|
|
945
|
+
'health:medicationName',
|
|
946
|
+
'health:conditionName',
|
|
947
|
+
'health:allergen',
|
|
948
|
+
'clinical:supplementName',
|
|
949
|
+
'clinical:vaccineName',
|
|
950
|
+
'health:vaccineName',
|
|
951
|
+
'health:testName',
|
|
952
|
+
'health:labTestName',
|
|
953
|
+
'foaf:name',
|
|
954
|
+
'dcterms:title',
|
|
955
|
+
];
|
|
956
|
+
for (const key of labelKeys) {
|
|
957
|
+
if (properties[key]) {
|
|
958
|
+
return properties[key];
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Select the most relevant properties for display based on data type.
|
|
965
|
+
*/
|
|
966
|
+
function selectKeyProperties(typeName, properties) {
|
|
967
|
+
const result = {};
|
|
968
|
+
// Common properties to always show if present
|
|
969
|
+
const commonKeys = ['cascade:dataProvenance', 'cascade:schemaVersion'];
|
|
970
|
+
// Type-specific key properties
|
|
971
|
+
const typeKeys = {
|
|
972
|
+
medications: [
|
|
973
|
+
'health:dose',
|
|
974
|
+
'health:frequency',
|
|
975
|
+
'health:route',
|
|
976
|
+
'health:isActive',
|
|
977
|
+
'health:startDate',
|
|
978
|
+
'health:prescriber',
|
|
979
|
+
'health:rxNormCode',
|
|
980
|
+
'health:medicationClass',
|
|
981
|
+
],
|
|
982
|
+
conditions: [
|
|
983
|
+
'health:status',
|
|
984
|
+
'health:onsetDate',
|
|
985
|
+
'health:icd10Code',
|
|
986
|
+
'health:snomedCode',
|
|
987
|
+
'health:conditionClass',
|
|
988
|
+
],
|
|
989
|
+
allergies: [
|
|
990
|
+
'health:allergyCategory',
|
|
991
|
+
'health:reaction',
|
|
992
|
+
'health:allergySeverity',
|
|
993
|
+
'health:onsetDate',
|
|
994
|
+
],
|
|
995
|
+
'lab-results': [
|
|
996
|
+
'health:value',
|
|
997
|
+
'health:unit',
|
|
998
|
+
'health:referenceRange',
|
|
999
|
+
'health:interpretation',
|
|
1000
|
+
'health:effectiveDate',
|
|
1001
|
+
],
|
|
1002
|
+
immunizations: [
|
|
1003
|
+
'health:vaccineDate',
|
|
1004
|
+
'health:lotNumber',
|
|
1005
|
+
'health:site',
|
|
1006
|
+
'health:manufacturer',
|
|
1007
|
+
],
|
|
1008
|
+
supplements: [
|
|
1009
|
+
'clinical:dose',
|
|
1010
|
+
'clinical:frequency',
|
|
1011
|
+
'clinical:form',
|
|
1012
|
+
'clinical:isActive',
|
|
1013
|
+
'clinical:evidenceStrength',
|
|
1014
|
+
],
|
|
1015
|
+
};
|
|
1016
|
+
const keysToShow = [...(typeKeys[typeName] ?? []), ...commonKeys];
|
|
1017
|
+
for (const key of keysToShow) {
|
|
1018
|
+
if (properties[key]) {
|
|
1019
|
+
result[key] = properties[key];
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// If no specific keys matched, show first few properties
|
|
1023
|
+
if (Object.keys(result).length === 0) {
|
|
1024
|
+
const allKeys = Object.keys(properties);
|
|
1025
|
+
for (const key of allKeys.slice(0, 5)) {
|
|
1026
|
+
result[key] = properties[key];
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return result;
|
|
1030
|
+
}
|
|
1031
|
+
//# sourceMappingURL=pod.js.map
|