Compare commits

...

3 Commits

Author SHA1 Message Date
86c18927e8 DICOM viewer 2023-08-29 21:29:15 -04:00
a9d138e749 TIFF viewer 2023-08-29 21:29:15 -04:00
3aa6a64f36 Access to VistA Imaging System 2023-08-10 18:30:12 -04:00
9 changed files with 32766 additions and 0 deletions

View File

@ -49,6 +49,7 @@
import RoutePatientReports from './RoutePatientReports.vue'; import RoutePatientReports from './RoutePatientReports.vue';
import RoutePatientDocuments from './RoutePatientDocuments.vue'; import RoutePatientDocuments from './RoutePatientDocuments.vue';
import RoutePatientConsults from './RoutePatientConsults.vue'; import RoutePatientConsults from './RoutePatientConsults.vue';
import RoutePatientImaging from './RoutePatientImaging.vue';
import RoutePlanner from './RoutePlanner.vue'; import RoutePlanner from './RoutePlanner.vue';
import RouteRecall from './RouteRecall.vue'; import RouteRecall from './RouteRecall.vue';
import RouteInbox from './RouteInbox.vue'; import RouteInbox from './RouteInbox.vue';
@ -100,6 +101,8 @@
{ path: 'document/:tiu_da', component: RoutePatientDocuments }, { path: 'document/:tiu_da', component: RoutePatientDocuments },
{ path: 'consult', component: RoutePatientConsults }, { path: 'consult', component: RoutePatientConsults },
{ path: 'consult/:ien', component: RoutePatientConsults }, { path: 'consult/:ien', component: RoutePatientConsults },
{ path: 'imaging', component: RoutePatientImaging },
{ path: 'imaging/:ien', component: RoutePatientImaging },
] }, ] },
{ path: '/planner', component: RoutePlanner }, { path: '/planner', component: RoutePlanner },
{ path: '/recall', component: RouteRecall }, { path: '/recall', component: RouteRecall },

View File

@ -61,6 +61,7 @@
{ name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' }, { name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' },
{ name: 'Documents', href: '/patient/' + this.patient_dfn + '/document' }, { name: 'Documents', href: '/patient/' + this.patient_dfn + '/document' },
{ name: 'Consults', href: '/patient/' + this.patient_dfn + '/consult' }, { name: 'Consults', href: '/patient/' + this.patient_dfn + '/consult' },
{ name: 'Imaging', href: '/patient/' + this.patient_dfn + '/imaging' },
] ]
} : null; } : null;
} }

View File

@ -0,0 +1,228 @@
<template>
<Subtitle value="Imaging" />
<Subtitle :value="patient_info.name" />
<div class="row">
<div class="selector col-12" :class="{ 'col-xl-4': selection_data }">
<div class="card mb-3 shadow">
<div class="card-header">{{resultset.length > 0 ? resultset.length : 'No'}} record{{resultset.length == 1 ? '' : 's'}}</div>
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection_data }" ref="scroller">
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/imaging/' + item.Info.IEN" replace custom v-slot="{ navigate, href }">
<li :key="item" class="record list-group-item" :class="{ 'active': selection == item.Info.IEN }" :title="'Site: ' + item['Site'] + '\nNote Title: ' + item['Note Title~~W0'] + '\nProc DT: ' + item['Proc DT~S1'] + '\nProcedure: ' + item['Procedure'] + '\n# Img: ' + item['# Img~S2'] + '\nShort Desc: ' + item['Short Desc'] + '\nPkg: ' + item['Pkg'] + '\nClass: ' + item['Class'] + '\nType: ' + item['Type'] + '\nSpecialty: ' + item['Specialty'] + '\nOrigin: ' + item['Origin'] + '\nCap Dt: ' + item['Cap Dt~S1~W0'] + '\nCap by: ' + item['Cap by~~W0'] + '\nImage ID: ' + item['Image ID~S2~W0'] + '\nCreation Date: ' + item.Info['Document Date']" @click="navigate">
<div class="row">
<div class="cell col-4"><router-link :to="href" replace>{{item['Proc DT~S1']}}</router-link></div>
<div class="cell col-7">{{doctitledesc(item)}}</div>
<div class="cell col-1">#{{item['# Img~S2']}}</div>
<div class="cell col-3">{{doctypespec(item)}}</div>
<div class="cell col-3">{{docclspkgproc(item)}}</div>
<div class="cell col-3">{{item['Site']}} {{item['Origin']}}</div>
</div>
</li>
</router-link>
</ul>
</div>
</div>
<div v-if="selection_data" class="col-12 col-xl-8">
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{doctitledesc(selection_data) || 'Image'}} #{{selection}}</span>
<router-link class="close" :to="'/patient/' + patient_dfn + '/imaging'" replace></router-link>
</div>
<div class="detail card-body" ref="detail">
<p v-if="selection_info">{{selection_info}}</p>
<ul v-if="(selection_images) && (selection_images.length > 0)" class="list-group list-group-flush">
<li v-for="info in selection_images" class="list-group-item"><router-link :to="'/v1/vista/' + client.cid + '/imaging/' + info['Image Path'].replace(/\\/g, '/').replace(/^\/+/, '') + '?view'" target="_blank">{{info['Short Desc']}}</router-link></li>
</ul>
</div>
</div>
</div>
</div>
</template>
<style scoped>
div.selector {
position: sticky;
top: 1.15rem;
z-index: 1;
}
ul.scroller.list-skinny {
max-height: 25vh;
overflow-y: auto;
}
li.record {
cursor: default;
border-top: 1px solid #dee2e6;
padding: 0.25rem 0.75rem;
scroll-margin-top: 3.6875rem;
}
li.record:nth-child(even) {
background-color: rgba(0, 0, 0, 0.05);
}
ul.scroller.list-skinny li.record {
scroll-margin-top: 0;
}
li.record a {
color: inherit;
}
li.record.active {
color: #fff;
background-color: #0d6efd;
}
li.bottom {
overflow-anchor: none;
}
div.cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.close {
cursor: default;
text-decoration: none;
}
div.detail {
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
font-family: monospace;
white-space: pre-wrap;
}
@media (min-width: 1200px) {
div.selector {
position: static;
}
ul.scroller.list-skinny {
max-height: 75vh;
}
div.detail {
max-height: 75vh;
scroll-margin-top: 0;
overflow-y: auto;
}
}
</style>
<script>
import { debounce, strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
const SZ_WINDOW = 100;
export default {
components: {
Subtitle
},
props: {
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
return {
dfn: null,
has_more: '',
is_loading: false,
resultset: [],
selection: null,
selection_info: null,
selection_images: null
};
},
computed: {
selection_data() {
var selection = this.selection, resultset = this.resultset;
if((selection) && (resultset) && (resultset.length > 0)) return resultset.find(item => item['Image ID~S2~W0'] == selection);
}
},
watch: {
'$route.params.ien': {
async handler(value) {
this.selection = value;
}, immediate: true
}
},
methods: {
strptime_vista,
datestring(date) {
return date.toLocaleDateString('sv-SE');
},
datetimestring(date) {
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
},
docclspkgproc(item) {
var res = [], cls = item['Class'], pkg = item['Pkg'], proc = item['Procedure'];
if(cls) res.push(cls);
if((pkg) && (pkg != 'NONE') && (pkg != cls)) res.push(pkg);
if((proc) && (proc != cls) && (proc != pkg)) res.push(proc);
return res.join(' • ');
},
doctypespec(item) {
var res = [], type = item['Type'], spec = item['Specialty'];
if(type) res.push(type);
if(spec) res.push(spec);
return res.join(' • ');
},
doctitledesc(item) {
var title = item['Note Title~~W0'].replace(/^\s+|\s+$/g, ''), desc = item['Short Desc'].replace(/^\s+|\s+$/g, '');
if(title) {
if((desc == title) || (desc == '+' + title)) return title;
else return title + ' • ' + desc;
} else return desc;
},
doctitle(doc) {
if(doc) {
var m = doc.match(/^Orderable Item:\s*(.*)$/m);
if(m) return m[1];
}
}
},
created() {
this.$watch(
() => (this.client, this.patient_dfn, {}),
debounce(async () => {
if((this.client) && (this.patient_dfn)) this.resultset = await this.client.MAG4_IMAGE_LIST('E', '', '', '', ['IXCLASS^^CLIN^CLIN/ADMIN^ADMIN/CLIN', 'IDFN^^' + this.patient_dfn]);
else this.resultset = [];
}, 500),
{ immediate: true }
);
this.$watch(
() => (this.client, this.selection, {}),
async function() {
try {
this.selection_info = (this.client) && (this.selection) ? await this.client.MAG4_GET_IMAGE_INFO(this.selection) : null;
} catch(ex) {
this.selection_info = null;
console.warn(ex);
}
try {
this.selection_images = (this.client) && (this.selection) && (this.selection_data) ? (this.selection_data['# Img~S2'] == '1' ? [await this.client.MAGG_IMAGE_INFO(this.selection, '1')] : await this.client.MAGG_GROUP_IMAGES(this.selection, '1')) : null;
} catch(ex) {
this.selection_images = null;
console.warn(ex);
}
if(this.$refs.scroller) {
if(this.selection_data) { // scroll to selected item
await this.$nextTick();
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
if(this.$refs.detail) { // scroll to top of detail panel
this.$refs.detail.scrollIntoView();
this.$refs.detail.scrollTop = 0;
}
} else { // scroll to topmost item
var offset = this.$refs.scroller.getBoundingClientRect().top;
for(var children = this.$refs.scroller.children, count = children.length, i = 0; i < count; ++i) if(children[i].getBoundingClientRect().top >= offset) {
await this.$nextTick();
var behavior = document.documentElement.style.scrollBehavior;
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
children[i].scrollIntoView();
document.documentElement.style.scrollBehavior = behavior;
break;
}
}
}
},
{ immediate: true }
);
}
};
</script>

1704
htdocs/adapter/UTIF.js Normal file

File diff suppressed because it is too large Load Diff

30647
htdocs/adapter/dicom.ts.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DICOM</title>
<style type="text/css">
figure {
border: 1px solid #000;
}
figcaption {
border-bottom: 1px solid #000;
padding: 0.25em;
background-color: #ccc;
}
</style>
</head>
<body>
<script type="text/javascript" src="/adapter/dicom.ts.js"></script>
<script type="text/javascript">
function request(method, url, responseType) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if(responseType) xhr.responseType = responseType;
xhr.onload = resolve;
xhr.onerror = reject;
xhr.send();
});
}
function createElementX(tag, attrs) {
var res = document.createElement(tag);
if(attrs) for(var k in attrs) if(attrs.hasOwnProperty(k)) res.setAttribute(k, attrs[k]);
for(var i = 2, child; i < arguments.length; ++i) res.appendChild((typeof (child = arguments[i]) === 'string') || (child instanceof String) ? document.createTextNode(child) : child);
return res;
}
var pathname = window.location.pathname, filename = document.title = pathname.split('/').pop();
var body = document.body;
var header = document.createElement('div');
header.append(createElementX('a', { href: pathname }, 'Download ' + filename))
body.appendChild(header);
request('GET', pathname, 'arraybuffer').then(function(evt) {
var filedata = evt.target.response;
var cnv = document.createElement('canvas'), renderer = new dicom.ts.Renderer(cnv), img;
var dataset = dicom.ts.parseImage(filedata), count = dataset.numberOfFrames;
header.appendChild(createElementX('div', null, dataset.patientName + ' #' + dataset.patientID));
header.appendChild(createElementX('div', null, dataset.imageDescription));
header.appendChild(createElementX('div', null, dataset.studyDate.toLocaleDateString('sv-SE') + ' @ ' + dataset.studyTime));
header.appendChild(createElementX('div', null, dataset.modality + ' series #' + dataset.seriesNumber));
(function renderall(i) {
if((i = i || 0) >= count) return;
renderer.render(dataset, i).then(function() {
body.appendChild(createElementX('figure', null, createElementX('figcaption', null, 'Image ' + (i + 1) + ' of ' + count), img = createElementX('img', { src: cnv.toDataURL() })));
img.style.width = '100%';
renderall(i + 1);
});
})();
})
</script>
</body>
</html>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TIFF</title>
<style type="text/css">
figure {
border: 1px solid #000;
}
figcaption {
border-bottom: 1px solid #000;
padding: 0.25em;
background-color: #ccc;
}
</style>
</head>
<body>
<script type="text/javascript" src="/adapter/UTIF.js"></script>
<script type="text/javascript">
function request(method, url, responseType) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if(responseType) xhr.responseType = responseType;
xhr.onload = resolve;
xhr.onerror = reject;
xhr.send();
});
}
function createElementX(tag, attrs) {
var res = document.createElement(tag);
if(attrs) for(var k in attrs) if(attrs.hasOwnProperty(k)) res.setAttribute(k, attrs[k]);
for(var i = 2, child; i < arguments.length; ++i) res.appendChild((typeof (child = arguments[i]) === 'string') || (child instanceof String) ? document.createTextNode(child) : child);
return res;
}
var pathname = window.location.pathname, filename = document.title = pathname.split('/').pop();
var body = document.body;
var header = document.createElement('div');
header.append(createElementX('a', { href: pathname }, 'Download ' + filename))
body.appendChild(header);
request('GET', pathname, 'arraybuffer').then(function(evt) {
var filedata = evt.target.response;
var cnv = document.createElement('canvas'), ctx = cnv.getContext('2d'), img;
var pages = UTIF.decode(filedata), page;
for(var i = 0; i < pages.length; ++i) {
UTIF.decodeImage(filedata, page = pages[i], pages);
var rgba = UTIF.toRGBA8(page);
ctx.putImageData(new ImageData(new Uint8ClampedArray(rgba.buffer), cnv.width = page.width, cnv.height = page.height), 0, 0);
body.appendChild(createElementX('figure', null, createElementX('figcaption', null, 'Page ' + (i + 1) + ' of ' + pages.length), img = createElementX('img', { src: cnv.toDataURL() })));
img.style.width = '100%';
}
})
</script>
</body>
</html>

View File

@ -50,6 +50,37 @@ export const d_parse_array = data => data !== '' ? data : [];
export const d_parse_authinfo = data => data ? { duz: data[0] != '0' ? data[0] : null, device_lock: data[1] != '0', change_verify: data[2] != '0', message: data[3], reserved: data[4], greeting_lines: data[5], greeting: data.slice(6), success: (data[0] != '0') && (data[2] == '0') } : { success: false } export const d_parse_authinfo = data => data ? { duz: data[0] != '0' ? data[0] : null, device_lock: data[1] != '0', change_verify: data[2] != '0', message: data[3], reserved: data[4], greeting_lines: data[5], greeting: data.slice(6), success: (data[0] != '0') && (data[2] == '0') } : { success: false }
export const d_parse_imagelist = data => {
var descriptor = d_split1(data[0], '^', 'success', 'filter', 'more'), res;
if(descriptor.success == '1') {
var headers = d_split1(data[1], '^');
res = data.slice(2).map(function(row) {
row = row.split('|');
var values = headers.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row[0].split('^'))
values.Info = d_split1(row[1], '^', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
values.Info['Group Ch1'] = values.Info['Group Ch1'] ? d_split1(':', 'IEN', 'Type') : null
return values;
});
res.descriptor = descriptor;
} else {
res = data.slice(1);
res.descriptor = descriptor;
}
return res;
};
export const d_parse_imageinfo = data => {
var res = d_split1(data[0], '^', 'Code', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
res.Patient = d_split1(data[1], '^', 'IEN', 'name');
return res;
};
export const d_parse_imagegroup = data => {
var res = d_split(data.slice(1), '^', 'B2', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
res.Result = d_split1(data[0], '^', 'code', 'count');
return res;
};
export const d_parse_orderdialogs = (data, columns=['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText']) => data.map(function(row) { export const d_parse_orderdialogs = (data, columns=['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText']) => data.map(function(row) {
row = row.split('^'); row = row.split('^');
row = [...row[0].split(';'), row[1]]; row = [...row[0].split(';'), row[1]];
@ -344,6 +375,11 @@ export function Client(cid, secret) {
this.ORWPCE_NOTEVSTR = aflow((...args) => this.call({ method: 'ORWPCE_NOTEVSTR', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap); this.ORWPCE_NOTEVSTR = aflow((...args) => this.call({ method: 'ORWPCE_NOTEVSTR', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap);
this.ORWPCE_DELETE = aflow((...args) => this.call({ method: 'ORWPCE_DELETE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap); this.ORWPCE_DELETE = aflow((...args) => this.call({ method: 'ORWPCE_DELETE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.MAG4_IMAGE_LIST = memoized(aflow((...args) => this.call({ method: 'MAG4_IMAGE_LIST', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imagelist));
this.MAG4_GET_IMAGE_INFO = memoized(aflow((...args) => this.call({ method: 'MAG4_GET_IMAGE_INFO', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text));
this.MAGG_IMAGE_INFO = memoized(aflow((...args) => this.call({ method: 'MAGG_IMAGE_INFO', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imageinfo));
this.MAGG_GROUP_IMAGES = memoized(aflow((...args) => this.call({ method: 'MAGG_GROUP_IMAGES', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imagegroup));
this.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'apptinfo', 'datetime', 'location', 'status'))); this.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'apptinfo', 'datetime', 'location', 'status')));
this.ORWU_NEWPERS = memoized(aflow((...args) => this.call({ method: 'ORWU_NEWPERS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'DUZ', 'name', 'description'))); this.ORWU_NEWPERS = memoized(aflow((...args) => this.call({ method: 'ORWU_NEWPERS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'DUZ', 'name', 'description')));

24
main.py
View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import json import json
import secrets import secrets
import string import string
@ -160,6 +161,29 @@ def application():
logger.exception(request.url) logger.exception(request.url)
return jsonify_error(ex, id=request.json.get('id')) return jsonify_error(ex, id=request.json.get('id'))
@app.get('/v1/vista/<cid>/imaging/<path:path>')
def cb_imaging(cid, path):
if 'view' in request.args:
adapter = 'view.' + path.rsplit('.', 1)[1].lower() + '.html'
if os.path.isfile('./htdocs/adapter/' + adapter):
return send_from_directory('./htdocs/adapter', adapter)
client = clients[cid]
frag = path.replace('\\', '/').strip('/').split('/')
winshare = '\\\\' + '\\'.join(frag[:2]).upper()
for item in client.MAG_GET_NETLOC('ALL', context=['MAG WINDOWS']):
if item.split('^')[1] == winshare:
break
else:
raise PermissionError(path)
try:
open('//' + '/'.join(frag)).close()
except PermissionError as ex:
credentials = client.MAGGUSER2(context=['MAG WINDOWS'])[2].split('^')
import subprocess, XWBHash
subprocess.run(['net', 'use', winshare, '/d'])
subprocess.run(['net', 'use', winshare, '/user:' + credentials[0], XWBHash.decrypt(credentials[1])])
return send_from_directory('//' + '/'.join(frag[:2]), '/'.join(frag[2:]), as_attachment=('dl' in request.args))
@app.get('/<path:path>') @app.get('/<path:path>')
def cb_static(path): def cb_static(path):
return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html') return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html')