Compare commits

...

19 Commits

Author SHA1 Message Date
46ef48b0ae More robust lab report diff count normalization 2025-01-14 20:46:15 -05:00
1d276e7bec Improved lab report edge case parsing 2025-01-14 20:44:45 -05:00
18d6b6f19c Improved measurement parsing 2025-01-05 21:52:42 -05:00
5d2a8f464f Flowsheet calculation for iron saturation 2025-01-03 23:57:17 -05:00
fcd8447658 Fix date range negative month 2025-01-02 22:52:19 -05:00
e648988b53 Fix flowsheet auto-scroll to end 2025-01-02 22:49:25 -05:00
4708e5e0eb Improved lab report edge case parsing 2024-12-30 23:04:09 -05:00
badb26c9fc Flowsheet calculation for corrected calcium 2024-12-09 23:25:22 -05:00
5db3091470 Fix missing out-of-range flag reporting for flowsheet calculations 2024-12-09 23:24:17 -05:00
ace1407715 Discharge summary report 2024-12-09 23:11:03 -05:00
32de0bdd56 Report line-collapsed formatting 2024-12-09 23:08:07 -05:00
fa25bf3c5c Fix lab report handling to correspond with VistA patch OR*3*610 2024-12-09 23:04:03 -05:00
053053825c Improved SSO workflow: use WS-Trust STS endpoint directly instead of relying on XUIAMSSOi.dll 2024-12-09 22:48:05 -05:00
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
83c9f11c73 Fix handling of null ORWORB FASTUSER meta field 2023-08-09 23:37:14 -04:00
d7d716cc27 Consult viewer 2023-08-09 23:16:25 -04:00
5c67773287 Location lookup fixes: RPC null array handling, RPC caching, existing encounters empty resultset handling, default to existing encounters 2023-08-08 22:34:59 -04:00
20 changed files with 33265 additions and 70 deletions

201
XWBSSOi.py Normal file
View File

@ -0,0 +1,201 @@
#!/usr/bin/env python3
import ctypes
import ctypes.wintypes
import winreg
import contextlib
from typing import Any, Optional, Generator
DEFAULT_USER_AGENT = 'Borland SOAP 1.2'
DEFAULT_ISSUER = 'https://ssoi.sts.va.gov/Issuer/smtoken/SAML2'
HCERTSTORE = ctypes.c_void_p
PCERT_INFO = ctypes.c_void_p
HCRYPTPROV_LEGACY = ctypes.c_void_p
CERT_STORE_PROV_MEMORY = b'Memory'
X509_ASN_ENCODING = 0x00000001
PKCS_7_ASN_ENCODING = 0x00010000
CERT_COMPARE_ANY = 0
CERT_COMPARE_SHIFT = 16
CERT_FIND_ANY = CERT_COMPARE_ANY<<CERT_COMPARE_SHIFT
CERT_NAME_FRIENDLY_DISPLAY_TYPE = 5
CERT_DIGITAL_SIGNATURE_KEY_USAGE = 0x80
CERT_STORE_ADD_ALWAYS = 4
CERT_HASH_PROP_ID = CERT_SHA1_HASH_PROP_ID = 3
class CERT_CONTEXT(ctypes.Structure):
_fields_ = [
('dwCertEncodingType', ctypes.wintypes.DWORD),
('pbCertEncoded', ctypes.POINTER(ctypes.wintypes.BYTE)),
('cbCertEncoded', ctypes.wintypes.DWORD),
('pCertInfo', PCERT_INFO),
('hCertStore', HCERTSTORE),
]
PCCERT_CONTEXT = ctypes.POINTER(CERT_CONTEXT)
crypt32 = ctypes.WinDLL('crypt32')
CertOpenStore = crypt32.CertOpenStore
CertOpenStore.restype = HCERTSTORE
CertOpenStore.argtypes = (ctypes.wintypes.LPCSTR, ctypes.wintypes.DWORD, HCRYPTPROV_LEGACY, ctypes.wintypes.DWORD, ctypes.c_void_p)
CertOpenSystemStoreW = crypt32.CertOpenSystemStoreW
CertOpenSystemStoreW.restype = HCERTSTORE
CertOpenSystemStoreW.argtypes = (HCRYPTPROV_LEGACY, ctypes.wintypes.LPCWSTR)
CertCloseStore = crypt32.CertCloseStore
CertCloseStore.restype = ctypes.wintypes.BOOL
CertCloseStore.argtypes = (HCERTSTORE, ctypes.wintypes.DWORD)
CertEnumCertificatesInStore = crypt32.CertEnumCertificatesInStore
CertEnumCertificatesInStore.restype = PCCERT_CONTEXT
CertEnumCertificatesInStore.argtypes = (HCERTSTORE, PCCERT_CONTEXT)
CertFindCertificateInStore = crypt32.CertFindCertificateInStore
CertFindCertificateInStore.restype = PCCERT_CONTEXT
CertFindCertificateInStore.argtypes = (HCERTSTORE, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p, PCCERT_CONTEXT)
CertAddCertificateContextToStore = crypt32.CertAddCertificateContextToStore
CertAddCertificateContextToStore.restype = ctypes.wintypes.BOOL
CertAddCertificateContextToStore.argtypes = (HCERTSTORE, PCCERT_CONTEXT, ctypes.wintypes.DWORD, ctypes.POINTER(PCCERT_CONTEXT))
CertGetNameStringW = crypt32.CertGetNameStringW
CertGetNameStringW.restype = ctypes.wintypes.DWORD
CertGetNameStringW.argtypes = (PCCERT_CONTEXT, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p, ctypes.wintypes.LPWSTR, ctypes.wintypes.DWORD)
CertGetIntendedKeyUsage = crypt32.CertGetIntendedKeyUsage
CertGetIntendedKeyUsage.restype = ctypes.wintypes.BOOL
CertGetIntendedKeyUsage.argtypes = (ctypes.wintypes.DWORD, PCERT_INFO, ctypes.POINTER(ctypes.wintypes.BYTE), ctypes.wintypes.DWORD)
CertGetCertificateContextProperty = crypt32.CertGetCertificateContextProperty
CertGetCertificateContextProperty.restype = ctypes.wintypes.BOOL
CertGetCertificateContextProperty.argtypes = (PCCERT_CONTEXT, ctypes.wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(ctypes.wintypes.DWORD))
CertVerifyTimeValidity = crypt32.CertVerifyTimeValidity
CertVerifyTimeValidity.restype = ctypes.wintypes.LONG
CertVerifyTimeValidity.argtypes = (ctypes.wintypes.LPFILETIME, PCERT_INFO)
cryptui = ctypes.WinDLL('cryptui')
CryptUIDlgSelectCertificateFromStore = cryptui.CryptUIDlgSelectCertificateFromStore
CryptUIDlgSelectCertificateFromStore.restype = PCCERT_CONTEXT
CryptUIDlgSelectCertificateFromStore.argtypes = (HCERTSTORE, ctypes.wintypes.HWND, ctypes.wintypes.LPCWSTR, ctypes.wintypes.LPCWSTR, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p)
GetConsoleWindow = ctypes.windll.kernel32.GetConsoleWindow
GetConsoleWindow.restype = ctypes.wintypes.HWND
@contextlib.contextmanager
def ManagedCertOpenStore(lpszStoreProvider: ctypes.wintypes.LPCSTR, dwEncodingType: ctypes.wintypes.DWORD, hCryptProv: HCRYPTPROV_LEGACY, dwFlags: ctypes.wintypes.DWORD, pvPara: ctypes.c_void_p) -> Generator[HCERTSTORE, None, None]:
res = CertOpenStore(lpszStoreProvider, dwEncodingType, hCryptProv, dwFlags, pvPara)
try:
yield res
finally:
CertCloseStore(res, 0)
@contextlib.contextmanager
def ManagedCertOpenSystemStore(hProv: HCRYPTPROV_LEGACY, szSubsystemProtocol: ctypes.wintypes.LPCWSTR) -> Generator[HCERTSTORE, None, None]:
res = CertOpenSystemStoreW(hProv, szSubsystemProtocol)
try:
yield res
finally:
CertCloseStore(res, 0)
def get_vista_certificate(show_cert_dialog: bool=True, hwnd: Optional[int]=0) -> PCCERT_CONTEXT:
with ManagedCertOpenSystemStore(0, 'MY') as store_system, ManagedCertOpenStore(CERT_STORE_PROV_MEMORY, 0, None, 0, None) as store_memory:
cert_selection = cert_iter = None
while cert_iter := CertEnumCertificatesInStore(store_system, cert_iter):
if cert_valid := CertFindCertificateInStore(store_system, X509_ASN_ENCODING|PKCS_7_ASN_ENCODING, 0, CERT_FIND_ANY, None, None):
name_bufsz = CertGetNameStringW(cert_iter, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, None, 0)
buf = ctypes.create_unicode_buffer(name_bufsz)
CertGetNameStringW(cert_iter, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, buf, name_bufsz)
name_string = buf.value
key_usage_bits = ctypes.wintypes.BYTE()
CertGetIntendedKeyUsage(X509_ASN_ENCODING|PKCS_7_ASN_ENCODING, cert_iter.contents.pCertInfo, ctypes.byref(key_usage_bits), ctypes.sizeof(key_usage_bits))
valid_date = CertVerifyTimeValidity(None, cert_iter.contents.pCertInfo)
if ((key_usage_bits.value&CERT_DIGITAL_SIGNATURE_KEY_USAGE) == CERT_DIGITAL_SIGNATURE_KEY_USAGE) and (valid_date == 0) and ('Card Authentication' not in name_string) and ('0,' not in name_string) and ('Signature' not in name_string):
CertAddCertificateContextToStore(store_memory, cert_iter, CERT_STORE_ADD_ALWAYS, ctypes.byref(cert_valid))
cert_selection = cert_valid
return CryptUIDlgSelectCertificateFromStore(store_memory, hwnd if hwnd is not None else GetConsoleWindow(), 'VistA Logon - Certificate Selection', 'Select a certificate for VistA authentication', 0, 0, None) if show_cert_dialog else cert_selection
def get_certificate_thumbprint(certificate: PCCERT_CONTEXT) -> str:
bufsz = ctypes.wintypes.DWORD()
CertGetCertificateContextProperty(certificate, CERT_HASH_PROP_ID, None, ctypes.byref(bufsz))
buffer = ctypes.create_string_buffer(bufsz.value)
CertGetCertificateContextProperty(certificate, CERT_HASH_PROP_ID, buffer, ctypes.byref(bufsz))
return buffer.value
def get_certificate_friendly_display_name(certificate: PCCERT_CONTEXT) -> str:
name_bufsz = CertGetNameStringW(certificate, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, None, 0)
buffer = ctypes.create_unicode_buffer(name_bufsz)
CertGetNameStringW(certificate, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, buffer, name_bufsz)
return buffer.value
# WS-Trust STS endpoints from VDL documentation
# https://www.va.gov/vdl/documents/Infrastructure/KAAJEE/kaajee_ssowap_8_791_depg_r.pdf
# https://www.va.gov/vdl/documents/Financial_Admin/Bed_Management_Solution_(BMS)/bms_4_0_tm.pdf
# https://www.va.gov/vdl/documents/VistA_GUI_Hybrids/National_Utilization_Management_Integration_Archive/numi_server_setup_guide_v15_9.pdf
get_registry_iam = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\IAM', default='https://services.eauth.va.gov:9301/STS/RequestSecurityToken')
get_registry_iam_ad = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\IAM_AD', default='https://services.eauth.va.gov:9201/STS/RequestSecurityToken')
get_registry_rioserver = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\RIOSERVER', default='SecurityTokenService')
get_registry_rioport = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\RIOPORT', default='RequestSecurityToken')
def get_registry_value(hkey: int, subkey: str, value: Optional[str]=None, default: Any=None) -> Any:
try:
with winreg.OpenKey(hkey, subkey) as key:
return winreg.QueryValueEx(key, value)[0]
except FileNotFoundError:
return default
def get_iam_request(application: str, issuer: str) -> str:
return f'''<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
<soapenv:Header/>
<soapenv:Body>
<ns:RequestSecurityToken>
<ns:Base>
<wss:TLS xmlns:wss="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"/>
</ns:Base>
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
<wsa:EndpointReference xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing">
<wsa:Address>{application}</wsa:Address>
</wsa:EndpointReference>
</wsp:AppliesTo>
<ns:Issuer>
<wsa:Address xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing">{issuer}</wsa:Address>
</ns:Issuer>
<ns:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Validate</ns:RequestType>
</ns:RequestSecurityToken>
</soapenv:Body>
</soapenv:Envelope>'''
def get_local_computer_name() -> str:
import socket
return socket.getfqdn()
def get_app_name() -> str:
import sys, os
return os.path.basename(sys.argv[0])
def get_sso_token(iam: Optional[str]=None, ua: Optional[str]=None, certificate: Optional[str]=None, issuer: Optional[str]=None, hostname: Optional[str]=None, application: Optional[str]=None) -> str:
import sys, subprocess
if certificate is None:
if choice := get_vista_certificate():
certificate = get_certificate_thumbprint(choice).hex()
if certificate is not None:
res = subprocess.run(['curl', '-fsSL', '-X', 'POST', iam or get_registry_iam(), '--ca-native', '--cert', 'CurrentUser\\MY\\' + certificate, '-A', ua or DEFAULT_USER_AGENT, '-H', 'Content-Type: application/xml', '-H', 'Accept: application/xml', '-d', get_iam_request(f"https://{hostname or get_local_computer_name()}/Delphi_RPC_Broker/{application or get_app_name()}", issuer or DEFAULT_ISSUER)], capture_output=True)
print(res.stderr.decode('utf8'), end='', file=sys.stderr)
return res.stdout.decode('utf8')
async def get_sso_token_async(iam: Optional[str]=None, ua: Optional[str]=None, certificate: Optional[str]=None, issuer: Optional[str]=None, hostname: Optional[str]=None, application: Optional[str]=None) -> str:
import sys, asyncio
if certificate is None:
certificate = get_certificate_thumbprint(get_vista_certificate()).hex()
res = await (await asyncio.create_subprocess_exec('curl', '-fsSL', '-X', 'POST', iam or get_registry_iam(), '--ca-native', '--cert', 'CurrentUser\\MY\\' + certificate, '-A', ua or DEFAULT_USER_AGENT, '-H', 'Content-Type: application/xml', '-H', 'Accept: application/xml', '-d', get_iam_request(f"https://{hostname or get_local_computer_name()}/Delphi_RPC_Broker/{application or get_app_name()}", issuer or DEFAULT_ISSUER), stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)).communicate()
print(res[1].decode('utf8'), end='', file=sys.stderr)
return res[0].decode('utf8')
if __name__ == '__main__':
try:
print(get_sso_token())
except OSError:
exit(1)

17
auth.py
View File

@ -1,17 +0,0 @@
#!/usr/bin/env python3
import ctypes
# Load DLL
XUIAMSSOi = ctypes.WinDLL('C:\\Program Files (x86)\\Micro Focus\\Reflection\\XUIAMSSOi.dll')
XUIAMSSOi.MySsoTokenVBA.restype = ctypes.c_long
XUIAMSSOi.MySsoTokenVBA.argtypes = (ctypes.c_wchar_p, ctypes.c_long)
# Authenticate against smartcard
def XUIAMSSOi_MySsoTokenVBA(bufsize=15000):
buf = ctypes.create_unicode_buffer(bufsize)
sz = XUIAMSSOi.MySsoTokenVBA(buf, bufsize)
if sz <= bufsize:
return buf.value.encode('utf-16')[2:].decode('latin-1')
else:
return XUIAMSSOi_MySsoTokenVBA(sz)

View File

@ -48,6 +48,8 @@
import RoutePatientOrders from './RoutePatientOrders.vue';
import RoutePatientReports from './RoutePatientReports.vue';
import RoutePatientDocuments from './RoutePatientDocuments.vue';
import RoutePatientConsults from './RoutePatientConsults.vue';
import RoutePatientImaging from './RoutePatientImaging.vue';
import RoutePlanner from './RoutePlanner.vue';
import RouteRecall from './RouteRecall.vue';
import RouteInbox from './RouteInbox.vue';
@ -97,6 +99,10 @@
{ path: 'reports', component: RoutePatientReports },
{ path: 'document', component: RoutePatientDocuments },
{ path: 'document/:tiu_da', component: RoutePatientDocuments },
{ path: 'consult', component: RoutePatientConsults },
{ path: 'consult/:ien', component: RoutePatientConsults },
{ path: 'imaging', component: RoutePatientImaging },
{ path: 'imaging/:ien', component: RoutePatientImaging },
] },
{ path: '/planner', component: RoutePlanner },
{ path: '/recall', component: RouteRecall },

View File

@ -53,8 +53,8 @@
}
function timeshift_month(date, diff) {
var month = date.getMonth() + diff;
return new Date(date.getFullYear() + Math.floor(month/12), month >= 0 ? (month%12) : (month%12 + 12), date.getDate());
var month = date.getMonth() + diff, month_mod12 = month%12;
return new Date(date.getFullYear() + Math.floor(month/12), month_mod12 >= 0 ? (month_mod12) : (month_mod12 + 12), date.getDate());
}
function datecalc(date, range, direction) {

View File

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

View File

@ -0,0 +1,195 @@
<template>
<Subtitle value="Consults" />
<Subtitle :value="patient_info.name" />
<div class="row">
<div class="selector col-12" :class="{ 'col-xl-4': selection_text }">
<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_text }" ref="scroller">
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/consult/' + item.IEN" replace custom v-slot="{ navigate, href }">
<li :key="item" class="record list-group-item" :class="{ 'active': selection == item.IEN }" :title="datetimestring(strptime_vista(item.time)) + ' #' + item.IEN + '\n(' + item.status + ') ' + item.text" @click="navigate">
<div class="row">
<div class="cell col-4"><router-link :to="href" replace>{{datestring(strptime_vista(item.time))}}</router-link></div>
<div class="cell col-8">
<template v-if="item.status == 'p'"></template>
<template v-else-if="item.status == 'a'">👍</template>
<template v-else-if="item.status == 's'">📆</template>
<template v-else-if="item.status == 'c'"></template>
<template v-else-if="item.status == 'x'"></template>
<template v-else-if="item.status == 'dc'">🗑</template>
<template v-else>({{item.status}})</template>
{{item.text}}
</div>
</div>
</li>
</router-link>
</ul>
</div>
</div>
<div v-if="selection_text" 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>{{doctitle(selection_text) || 'Consult'}} #{{selection}}</span>
<router-link class="close" :to="'/patient/' + patient_dfn + '/consult'" replace></router-link>
</div>
<div class="detail card-body" ref="detail">{{selection_text}}</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_text: null
};
},
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');
},
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.ORQQCN_LIST(this.patient_dfn);
else this.resultset = [];
}, 500),
{ immediate: true }
);
this.$watch(
() => (this.client, this.selection, {}),
async function() {
try {
this.selection_text = (this.client) && (this.selection) ? await this.client.ORQQCN_DETAIL(this.selection) : null;
} catch(ex) {
this.selection_text = null;
console.warn(ex);
}
if(this.$refs.scroller) {
if(this.selection_text) { // 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>

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>

View File

@ -205,12 +205,27 @@
id: 'OR_PN:' + time.getTime() + ':' + x[2],
emblem: 'emblem-notes',
title: [x[4], x[5], '#' + x[2]],
detail: escape_html(x[6])
detail: escape_html(collapse_lines(x[6]))
};
}),
loader: reportloader_chunk,
enabled: true
},
{
name: 'Discharge',
rpt_id: 'OR_DS:DISCHARGE SUMMARY~TIUDCS;ORDV04;57;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[3]);
return {
time,
id: 'OR_DS:' + time.getTime() + ':' + x[2],
emblem: 'emblem-notes',
title: ['DISCHARGE SUMMARY', x[4], x[2], x[3]],
detail: escape_html(collapse_lines(x[7]))
};
}),
loader: reportloader_chunk
},
{
name: 'Labs',
rpt_id: 'OR_OV_R:LAB OVERVIEW (COLLECTED SPECIMENS)~OV;ORDV02C;32;',
@ -224,7 +239,7 @@
detail: escape_html(x[15])
};
}),
loader: reportloader_alpha
loader: reportloader_chunk
},
{
name: 'Microbiology',
@ -412,6 +427,10 @@
return fn;
}
function collapse_lines(s) {
return s.replace(/(\S)[^\S\r\n]+\r?\n([^\s\.,\/#!$%\^&\*;:=\-_`~])/g, '$1 $2').replace(/([\.,!;])\r?\n([^\s\.,\/#!$%\^&\*;:=\-_`~])/g, '$1 $2');
}
const escape_div = document.createElement('div');
function escape_html(s) {
escape_div.textContent = s;

View File

@ -177,7 +177,7 @@
if((satisfied) && (updated)) {
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
results.push(history[calculation.name] = update[calculation.name] = Object.assign({ time: group.key, value: item }, calculation));
results.push(history[calculation.name] = update[calculation.name] = item = Object.assign({ time: group.key, value: item }, calculation));
if((item.hasOwnProperty('rangeL')) && (item.value < item.rangeL)) item.flag = 'L';
else if((item.hasOwnProperty('rangeH')) && (item.value > item.rangeH)) item.flag = 'H';
}
@ -226,7 +226,7 @@
},
watch: {
async resultset(value) {
this.$nextTick(() => this.$refs.headers ? this.$refs.headers.scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
this.$nextTick(() => (this.$refs.headers) && (this.$refs.headers.length > 0) ? this.$refs.headers[this.$refs.headers.length - 1].scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
}
},
methods: {

View File

@ -42,14 +42,14 @@
client: Object,
dfn: String,
label: String,
modelValue: String
modelValue: Object
},
emits: {
'update:modelValue': Object
},
data() {
return {
view_new: true,
view_new: false,
query: '',
visits_date_begin: now,
visits_date_end: now,

View File

@ -1,6 +1,6 @@
<template>
<table v-if="resultset.length > 0" class="table table-striped">
<tbody>
<table class="table table-striped">
<tbody v-if="resultset.length > 0">
<tr v-for="item in resultset" :class="{ 'table-active': (x_modelValue) && (x_modelValue.IEN) && (x_modelValue.datetime) && (item.location_ien == x_modelValue.IEN) && (item.datetime == x_modelValue.datetime) }" @click="x_modelValue = { IEN: item.location_ien, location: item.location, datetime: item.datetime, appointment_ien: item.IEN }">
<td>{{item.location}}</td>
<td>#{{item.location_ien}}</td>
@ -8,6 +8,9 @@
<td style="text-align: right;">{{item.datestr}} {{item.timestr}}</td>
</tr>
</tbody>
<tfoot v-else>
<tr><td style="text-align: center;">No encounters in range</td></tr>
</tfoot>
</table>
</template>

View File

@ -22,7 +22,9 @@
{ name: 'BMI', unit: 'kg/m²', rangeL: 18.5, rangeH: 24.9, range: '18.5 - 24.9', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (10000*Wt/(Ht*Ht)).toPrecision(3) },
{ name: 'BSA', unit: 'm²', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (0.007184*Math.pow(Ht, 0.725)*Math.pow(Wt, 0.425)).toPrecision(3) },
{ name: 'CrCl', unit: 'mL/min', deps: ['Age', 'Sex', 'Wt', 'CREATININE'], calc: (Age, Sex, Wt, CREATININE) => (((140 - Age) * Wt)/(72*CREATININE)*(Sex == 'M' ? 1 : 0.85)).toPrecision(4) },
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) }
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) },
{ name: 'CALCIUM CORRECTED', unit: 'mg/dL', rangeL: 8.9, rangeH: 10.3, range: '8.9 - 10.3', deps: ['CALCIUM', 'ALBUMIN'], calc: (CALCIUM, ALBUMIN) => ALBUMIN < 4 ? (+CALCIUM + 0.8*(4 - ALBUMIN)).toPrecision(3) : undefined },
{ name: 'IRON SATURATION', unit: '%', rangeL: 15, rangeH: 55, range: '15 - 55', comment: 'IRON/TIBC', deps: ['IRON', 'TIBC'], calc: (IRON, TIBC) => (100*IRON/TIBC).toPrecision(3) },
];
const reports = [
@ -30,9 +32,9 @@
{ name: 'CBC', value: ['HGB', 'MCV', 'RETICYLOCYTE#', 'PLT', 'WBC', 'NEUTROPHIL#'], selected: false },
{ name: 'Renal', value: ['CREATININE', 'UREA NITROGEN', 'EGFR CKD-EPI 2021', 'Estimated GFR dc\'d 3/30/2022', 'CrCl'], selected: false },
{ name: 'Hepatic', value: ['SGOT', 'SGPT', 'LDH', 'ALKALINE PHOSPHATASE', 'GAMMA-GTP', 'TOT. BILIRUBIN', 'DIR. BILIRUBIN', 'ALBUMIN'], selected: false },
{ name: 'Electrolytes', value: ['SODIUM', 'CHLORIDE', 'CO2', 'CALCIUM', 'IONIZED CALCIUM (LABCORP)', 'POTASSIUM', 'MAGNESIUM', 'PO4', 'ANION GAP', 'OSMOBLD'], selected: false },
{ name: 'Electrolytes', value: ['SODIUM', 'CHLORIDE', 'CO2', 'CALCIUM', 'CALCIUM CORRECTED', 'IONIZED CALCIUM (LABCORP)', 'POTASSIUM', 'MAGNESIUM', 'PO4', 'ANION GAP', 'OSMOBLD'], selected: false },
{ name: 'Coagulation', value: ['PT', 'INR', 'PTT'], selected: false },
{ name: 'Vitamins', value: ['FERRITIN', 'IRON', 'TIBC', 'B 12', 'FOLATE', 'VITAMIN D TOTAL 25-OH'], selected: false },
{ name: 'Vitamins', value: ['FERRITIN', 'IRON', 'TIBC', 'IRON SATURATION', 'B 12', 'FOLATE', 'VITAMIN D TOTAL 25-OH'], selected: false },
{ name: 'Thyroid', value: ['TSH', 'T4 (THYROXINE)'], selected: false },
{ name: 'Myeloma', value: ['PROTEIN,TOT SER (LC)', 'ALBUMIN [for SPEP](LC)', 'ALPHA-1 GLOBULIN S (LC)', 'ALPHA-2 GLOBULIN S (LC)', 'BETA GLOBULIN S (LC)', 'GAMMA GLOBULIN S (LC)', 'GLOBULIN,TOTAL S (LC)', 'A/G RATIO S (LC)', 'M-SPIKE S (LC)', 'IMMUNOFIXATION SERUM (LC)', 'FREE KAPPA LT CHAIN, S (LC)', 'FREE LAMBDA LT CHAIN, S (LC)', 'KAPPA/LAMBDA RATIO, S (LC)', 'KLRATIO', 'IMMUNOGLOBULIN G,QN (LC)', 'IMMUNOGLOBULIN A,QN (LC)', 'IMMUNOGLOBULIN M,QN (LC)', 'IGG', 'IGA', 'IGM', 'ALBUMIN [for RAND UR](LC):U', 'ALPHA-1 GLOB RAND UR(LC):U', 'ALPHA-2 GLOB RAND UR(LC):U', 'BETA GLOB RAND UR(LC):U', 'GAMMA GLOB RAND UR(LC):U', 'M-SPIKE% RAND UR(LC):U', 'PROTEIN,TOT UR(LC):U', 'FKLCUR:U', 'FLLCUR:U', 'KAPPA/LAMBDA RATIO, UR (LC):U', 'KLRATIO:U', 'PROTEIN,24H CALC(LC):U', 'ALBUMIN [for 24UPEP](LC):U', 'ALPHA-1 GLOBULIN 24H(LC):U', 'ALPHA-2 GLOBULIN 24H(LC):U', 'BETA GLOBULIN 24H(LC):U', 'GAMMA GLOBULIN 24H(LC):U', 'M-SPIKE% 24H(LC):U', 'M-SPIKE mg/24hr(LC):U', 'FR KAPPA LTCH:U', 'FR LAMBDA LTCH:U'], selected: false }
];
@ -47,13 +49,14 @@
function vitals_normalize(rs) {
return rs.map(function(x) {
var comment = x.comment && x.comment.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
var res = {
time: x.datetime,
name: x.name,
unit: x.unit,
value: x.value,
flag: x.flag,
comment: x.user
comment: comment ? x.user + ' • ' + comment : x.user
};
return vitals_mapping[x.name] ? Object.assign(res, vitals_mapping[x.name]) : res;
});

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

@ -53,15 +53,15 @@ function lab_parse1default(data) {
else x.comment = [line.substring(12)];
} else console.log('DANGLING:', line);
} else if(m = line.match(/^\b(?<name>.*?)\s{2,}(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/)) {
if(x = line.match(/^\b(?<name>.*?)(?<value>(?:positive|negative|reactive|nonreactive|not detected|collected - specimen in lab|test not performed))(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/i)) m = x;
if(x = line.match(/^\b(?<name>.*?)(?<value>(?:positive|negative|reactive|nonreactive|detected|not detected|comment|collected - specimen in lab|test not performed))(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/i)) m = x;
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
results.push(x = m.groups);
if((x.value === '') && (m = x.name.match(/^(?<name>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|not detected|collected - specimen in lab|test not performed))\s*$/i))) {
if((x.value === '') && (m = x.name.match(/^(?<name>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|detected|not detected|comment|collected - specimen in lab|test not performed))\s*$/i))) {
x.name = m.groups.name;
x.value = m.groups.value;
}
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
} else if(m = line.match(/^\b(?<name>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|nonreactive|not detected|collected - specimen in lab|test not performed))\s*$/i)) {
} else if(m = line.match(/^\b(?<name>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|nonreactive|detected|not detected|comment|collected - specimen in lab|test not performed))(?: (?<flag>L\*|L|H\*|H))?\s*$/i)) {
results.push(x = m.groups);
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
} else if(line.startsWith(' [')) {
@ -95,7 +95,7 @@ function lab_parse1default(data) {
value: x = (results.hasOwnProperty('SEGS') ? +results.SEGS.value : 0) + (results.hasOwnProperty('BANDS') ? +results.BANDS.value : 0),
flag: (x < 42.2 ? 'L' : x > 75.2 ? 'H' : undefined)
});
results.push(results['NEUTROPHIL#'] = {
if(results.WBC) results.push(results['NEUTROPHIL#'] = {
name: 'NEUTROPHIL#', unit: 'K/cmm', range: '1.4 - 6.5',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 1.4 ? 'L' : x > 6.5 ? 'H' : undefined)
@ -107,7 +107,7 @@ function lab_parse1default(data) {
value: x = +results.EOSINO.value,
flag: (x < 0 ? 'L' : x > 10 ? 'H' : undefined)
});
results.push(results['EOSINOPHIL#'] = {
if(results.WBC) results.push(results['EOSINOPHIL#'] = {
name: 'EOSINOPHIL#', unit: 'K/cmm', range: '0.0 - 0.7',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 0 ? 'L' : x > 0.7 ? 'H' : undefined)
@ -119,7 +119,7 @@ function lab_parse1default(data) {
value: x = +results.BASO.value,
flag: (x < 0 ? 'L' : x > 2 ? 'H' : undefined)
});
results.push(results['BASOPHIL#'] = {
if(results.WBC) results.push(results['BASOPHIL#'] = {
name: 'BASOPHIL#', unit: 'K/cmm', range: '0.0 - 0.2',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 0 ? 'L' : x > 0.2 ? 'H' : undefined)
@ -131,7 +131,7 @@ function lab_parse1default(data) {
value: x = +results.MONOS.value,
flag: (x < 1.7 ? 'L' : x > 9.3 ? 'H' : undefined)
});
results.push(results['MONOCYTE#'] = {
if(results.WBC) results.push(results['MONOCYTE#'] = {
name: 'MONOCYTE#', unit: 'K/cmm', range: '0.11 - 0.59',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 0.11 ? 'L' : x > 0.59 ? 'H' : undefined)
@ -143,7 +143,7 @@ function lab_parse1default(data) {
value: x = (results.hasOwnProperty('LYMPHS') ? +results.LYMPHS.value : 0) + (results.hasOwnProperty('ATYPICAL LYMPHOCYTES') ? +results['ATYPICAL LYMPHOCYTES'].value : 0),
flag: (x < 15 ? 'L' : x > 41 ? 'H' : undefined)
});
results.push(results['LYMPHOCYTE#'] = {
if(results.WBC) results.push(results['LYMPHOCYTE#'] = {
name: 'LYMPHOCYTE#', unit: 'K/cmm', range: '1.2 - 3.4',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 1.2 ? 'L' : x > 3.4 ? 'H' : undefined)
@ -195,20 +195,36 @@ export function measurement_parse(data) {
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
res.user = row.substring(idx + 2);
m = value.match(/^(?:(.*?)(?: (\S+))?)(\*)?(?: \((?:(.*?)(?: (\S+))?)\))?\s*$/);
res.value = m[4] ? m[4] : m[1];
res.unit = m[4] ? m[5] : m[2];
res.flag = m[3];
res.value_american = m[4] ? m[1] : m[4];
res.unit_american = m[4] ? m[2] : m[5];
if(res.value.charAt(res.value.length - 1) == '%') {
res.unit = '%';
res.value = res.value.substring(0, res.value.length - 1);
if(m = value.match(/(?:^(?<value>[\d\.\/%]+)(?: (?<unit>\w+) \((?<value2>[\d\.\/%]+) (?<unit2>\w+)\))?(?<flag>\*)? (?: (?<comment>.*))?$)|(?:^(?<value3>[\d\.\/%]+)(?<flag3>\*)?\s*(?<comment3>.*)$)/)) {
if(m.groups.value2) {
res.value = m.groups.value2;
res.unit = m.groups.unit2;
res.value_american = m.groups.value;
res.unit_american = m.groups.unit;
res.flag = m.groups.flag;
res.comment = m.groups.comment;
} else if(m.groups.value) {
res.value = m.groups.value;
res.unit = m.groups.unit;
res.flag = m.groups.flag;
res.comment = m.groups.comment;
} else if(m.groups.value3) {
res.value = m.groups.value3;
res.flag = m.groups.flag3;
res.comment = m.groups.comment3;
} else res.comment = value;
if(res.comment) res.comment = res.comment.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
}
if(res.name == 'B/P') {
var bpsplit = res.value.split('/');
extras.push({...res, name: 'SBP', range: '90 - 120', unit: 'mmHg', value: bpsplit[0] });
extras.push({...res, name: 'DBP', range: '60 - 80', unit: 'mmHg', value: bpsplit[1] });
if(res.value) {
if(res.value.charAt(res.value.length - 1) == '%') {
res.unit = '%';
res.value = res.value.substring(0, res.value.length - 1);
}
if(res.name == 'B/P') {
var bpsplit = res.value.split('/');
extras.push({...res, name: 'SBP', range: '90 - 120', unit: 'mmHg', value: bpsplit[0] });
extras.push({...res, name: 'DBP', range: '60 - 80', unit: 'mmHg', value: bpsplit[1] });
}
}
return res;
}

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_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) {
row = row.split('^');
row = [...row[0].split(';'), row[1]];
@ -157,18 +188,20 @@ export const d_parse_orderoptions_meddose = data => {
};
export const d_parse_notifications_fastuser = data => d_split(data, '^', 'info', 'patient', 'location', 'urgency', 'time', 'message', 'unk_6', 'meta', 'unk_7', 'unk_8').map(row => {
var meta = row.meta.split(';');
var xqaid = row.meta_xqaid = meta[0];
row.meta_duz = meta[1];
row.meta_time = +meta[2];
if(xqaid.startsWith('OR,')) {
xqaid = xqaid.split(',');
row.meta_source = xqaid[0];
row.meta_dfn = xqaid[1];
row.meta_ien = xqaid[2]; // file 100.9 IEN
} else if(xqaid.startsWith('TIU')) {
row.meta_source = 'TIU';
row.meta_ien = xqaid.substring(3); // document IEN (DA)
if(row.meta) {
var meta = row.meta.split(';');
var xqaid = row.meta_xqaid = meta[0];
row.meta_duz = meta[1];
row.meta_time = +meta[2];
if(xqaid.startsWith('OR,')) {
xqaid = xqaid.split(',');
row.meta_source = xqaid[0];
row.meta_dfn = xqaid[1];
row.meta_ien = xqaid[2]; // file 100.9 IEN
} else if(xqaid.startsWith('TIU')) {
row.meta_source = 'TIU';
row.meta_ien = xqaid.substring(3); // document IEN (DA)
}
}
return row;
});
@ -336,14 +369,22 @@ export function Client(cid, secret) {
this.TIU_DELETE_RECORD = aflow((...args) => this.call({ method: 'TIU_DELETE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_SIGN_RECORD = aflow((...args) => this.call({ method: 'TIU_SIGN_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.ORQQCN_LIST = aflow((...args) => this.call({ method: 'ORQQCN_LIST', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'IEN', 'time', 'status', 'orderable', 'type', 'has_children', 'text', 'order_ifn', 'type_abbr'));
this.ORQQCN_DETAIL = aflow((...args) => this.call({ method: 'ORQQCN_DETAIL', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
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.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, f_split('^', 'apptinfo', 'datetime', 'location', 'status')));
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.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_VALIDSIG = memoized(aflow((...args) => this.call({ method: 'ORWU_VALIDSIG', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap));
this.ORWU1_NEWLOC = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWU1_NEWLOC', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
this.ORWU1_NEWLOC = memoized(aflow((...args) => this.call({ method: 'ORWU1_NEWLOC', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
this.ORWDX_DGNM = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGNM', ...args), d_log, d_unwrap));
this.ORWDX_DGRP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGRP', ...args), d_log, d_unwrap));

28
main.py
View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
import json
import secrets
import string
@ -111,8 +112,8 @@ def application():
client._cache_persistent(persistent=util.Store(f'cache.{client._server["volume"].lower()}.{client._server["uci"].lower()}.{user[0]}.db', journal_mode='WAL').memo)
return jsonify_result(user, id=request.json.get('id'))
else:
from auth import XUIAMSSOi_MySsoTokenVBA
if token := XUIAMSSOi_MySsoTokenVBA():
import XWBSSOi
if token := XWBSSOi.get_sso_token(application='CPRSChart.exe'):
user = client.authenticate(token)
client._cache_persistent(persistent=util.Store(f'cache.{client._server["volume"].lower()}.{client._server["uci"].lower()}.{user[0]}.db', journal_mode='WAL').memo)
return jsonify_result(user, id=request.json.get('id'))
@ -160,6 +161,29 @@ def application():
logger.exception(request.url)
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>')
def cb_static(path):
return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html')

4
rpc.py
View File

@ -237,13 +237,13 @@ class ClientAsync(object):
if __name__ == '__main__':
import getpass, code
from auth import XUIAMSSOi_MySsoTokenVBA
import XWBSSOi
client = ClientSync(host='test.northport.med.va.gov', port=19009)
#client = ClientSync(host='vista.northport.med.va.gov', port=19209)
threading.Thread(target=client.keepalive, daemon=True).start()
print('\r\n'.join(client.XUS_INTRO_MSG()))
if token := XUIAMSSOi_MySsoTokenVBA():
if token := XWBSSOi.get_sso_token(application='CPRSChart.exe'):
print('authenticate', repr(client.authenticate(token)))
else:
print('authenticate', repr(client.authenticate(f"{getpass.getpass('ACCESS CODE: ')};{getpass.getpass('VERIFY CODE: ')}")))