First
This commit is contained in:
parent
2028b35226
commit
d2b1086fd7
3
.gitignore
vendored
3
.gitignore
vendored
@ -292,3 +292,6 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# Application-specific
|
||||||
|
*.db*
|
||||||
|
|
||||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) <year> <copyright holders>
|
Copyright (c) 2022 Jiang Yio
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
48
XWBHash.py
Normal file
48
XWBHash.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
cipherpad = (
|
||||||
|
'wkEo-ZJt!dG)49K{nX1BS$vH<&:Myf*>Ae0jQW=;|#PsO`\'%+rmb[gpqN,l6/hFC@DcUa ]z~R}"V\\iIxu?872.(TYL5_3',
|
||||||
|
'rKv`R;M/9BqAF%&tSs#Vh)dO1DZP> *fX\'u[.4lY=-mg_ci802N7LTG<]!CWo:3?{+,5Q}(@jaExn$~p\\IyHwzU"|k6Jeb',
|
||||||
|
'\\pV(ZJk"WQmCn!Y,y@1d+~8s?[lNMxgHEt=uw|X:qSLjAI*}6zoF{T3#;ca)/h5%`P4$r]G\'9e2if_>UDKb7<v0&- RBO.',
|
||||||
|
'depjt3g4W)qD0V~NJar\\B "?OYhcu[<Ms%Z`RIL_6:]AX-zG.#}$@vk7/5x&*m;(yb2Fn+l\'PwUof1K{9,|EQi>H=CT8S!',
|
||||||
|
'NZW:1}K$byP;jk)7\'`x90B|cq@iSsEnu,(l-hf.&Y_?J#R]+voQXU8mrV[!p4tg~OMez CAaGFD6H53%L/dT2<*>"{\\wI=',
|
||||||
|
'vCiJ<oZ9|phXVNn)m K`t/SI%]A5qOWe\\&?;jT~M!fz1l>[D_0xR32c*4.P"G{r7}E8wUgyudF+6-:B=$(sY,LkbHa#\'@Q',
|
||||||
|
'hvMX,\'4Ty;[a8/{6l~F_V"}qLI\\!@x(D7bRmUH]W15J%N0BYPkrs&9:$)Zj>u|zwQ=ieC-oGA.#?tfdcO3gp`S+En K2*<',
|
||||||
|
'jd!W5[];4\'<C$/&x|rZ(k{>?ghBzIFN}fAK"#`p_TqtD*1E37XGVs@0nmSe+Y6Qyo-aUu%i8c=H2vJ\\) R:MLb.9,wlO~P',
|
||||||
|
'2ThtjEM+!=xXb)7,ZV{*ci3"8@_l-HS69L>]\\AUF/Q%:qD?1~m(yvO0e\'<#o$p4dnIzKP|`NrkaGg.ufCRB[; sJYwW}5&',
|
||||||
|
'vB\\5/zl-9y:Pj|=(R\'7QJI *&CTX"p0]_3.idcuOefVU#omwNZ`$Fs?L+1Sk<,b)hM4A6[Y%aDrg@~KqEW8t>H};n!2xG{',
|
||||||
|
'sFz0Bo@_HfnK>LR}qWXV+D6`Y28=4Cm~G/7-5A\\b9!a#rP.l&M$hc3ijQk;),TvUd<[:I"u1\'NZSOw]*gxtE{eJp|y (?%',
|
||||||
|
'M@,D}|LJyGO8`$*ZqH .j>c~h<d=fimszv[#-53F!+a;NC\'6T91IV?(0x&/{B)w"]Q\\YUWprk4:ol%g2nE7teRKbAPuS_X',
|
||||||
|
'.mjY#_0*H<B=Q+FML6]s;r2:e8R}[ic&KA 1w{)vV5d,$u"~xD/Pg?IyfthO@CzWp%!`N4Z\'3-(o|J9XUE7k\\TlqSb>anG',
|
||||||
|
'xVa1\']_GU<X`|\\NgM?LS9{"jT%s$}y[nvtlefB2RKJW~(/cIDCPow4,>#zm+:5b@06O3Ap8=*7ZFY!H-uEQk; .q)i&rhd',
|
||||||
|
'I]Jz7AG@QX."%3Lq>METUo{Pp_ |a6<0dYVSv8:b)~W9NK`(r\'4fs&wim\\kReC2hg=HOj$1B*/nxt,;c#y+![?lFuZ-5D}',
|
||||||
|
'Rr(Ge6F Hx>q$m&C%M~Tn,:"o\'tX/*yP.{lZ!YkiVhuw_<KE5a[;}W0gjsz3]@7cI2\\QN?f#4p|vb1OUBD9)=-LJA+d`S8',
|
||||||
|
'I~k>y|m};d)-7DZ"Fe/Y<B:xwojR,Vh]O0Sc[`$sg8GXE!1&Qrzp._W%TNK(=J 3i*2abuHA4C\'?Mv\\Pq{n#56LftUl@9+',
|
||||||
|
'~A*>9 WidFN,1KsmwQ)GJM{I4:C%}#Ep(?HB/r;t.&U8o|l[\'Lg"2hRDyZ5`nbf]qjc0!zS-TkYO<_=76a\\X@$Pe3+xVvu',
|
||||||
|
'yYgjf"5VdHc#uA,W1i+v\'6|@pr{n;DJ!8(btPGaQM.LT3oe?NB/&9>Z`-}02*%x<7lsqz4OS ~E$\\R]KI[:UwC_=h)kXmF',
|
||||||
|
'5:iar.{YU7mBZR@-K|2 "+~`M%8sq4JhPo<_X\\Sg3WC;Tuxz,fvEQ1p9=w}FAI&j/keD0c?)LN6OHV]lGy\'$*>nd[(tb!#'
|
||||||
|
)
|
||||||
|
|
||||||
|
cipherpad_reversed = tuple({c: i for i, c in enumerate(m)} for m in cipherpad)
|
||||||
|
|
||||||
|
def encrypt(plaintext: str):
|
||||||
|
associator_idx = identifier_idx = random.randrange(l := len(cipherpad))
|
||||||
|
while identifier_idx == associator_idx:
|
||||||
|
identifier_idx = random.randrange(l)
|
||||||
|
associator = cipherpad_reversed[associator_idx]
|
||||||
|
identifier = cipherpad[identifier_idx]
|
||||||
|
return chr(associator_idx + 32) + ''.join(identifier[associator[i]] for i in plaintext) + chr(identifier_idx + 32)
|
||||||
|
|
||||||
|
def encrypt0(plaintext: str):
|
||||||
|
return f' {plaintext} '
|
||||||
|
|
||||||
|
def decrypt(ciphertext: str):
|
||||||
|
associator_idx = ord(ciphertext[-1]) - 32
|
||||||
|
identifier_idx = ord(ciphertext[0]) - 32
|
||||||
|
associator = cipherpad_reversed[associator_idx]
|
||||||
|
identifier = cipherpad[identifier_idx]
|
||||||
|
return ''.join(identifier[associator[i]] for i in ciphertext[1:-1])
|
||||||
|
|
||||||
|
__all__ = [encrypt, encrypt0, decrypt]
|
17
auth.py
Normal file
17
auth.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#!/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)
|
52
htdocs/App.vue
Normal file
52
htdocs/App.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<Navbar :user="user" />
|
||||||
|
<div class="container">
|
||||||
|
<router-view v-if="user"></router-view>
|
||||||
|
<Login :secret="secret" v-model:client="client" v-model:user="user" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Navbar from './Navbar.vue';
|
||||||
|
import Login from './Login.vue';
|
||||||
|
import RouteSchedule from './RouteSchedule.vue';
|
||||||
|
import RoutePatientLookup from './RoutePatientLookup.vue';
|
||||||
|
import RoutePatientDetail from './RoutePatientDetail.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Navbar, Login
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
secret: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
client: null,
|
||||||
|
user: null,
|
||||||
|
heartbeat: null,
|
||||||
|
banner: '',
|
||||||
|
authenticated: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
store: () => store
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async client(value) {
|
||||||
|
if(this.heartbeat) window.clearInterval(this.heartbeat);
|
||||||
|
else {
|
||||||
|
[
|
||||||
|
{ path: '/', component: RouteSchedule, props: { client: this.client } },
|
||||||
|
{ path: '/patient', component: RoutePatientLookup, props: { client: this.client } },
|
||||||
|
{ path: '/patient/:id', component: RoutePatientDetail, props: { client: this.client } },
|
||||||
|
].forEach(route => this.$root.$router.addRoute(route));
|
||||||
|
await this.$root.$router.replace(this.$route);
|
||||||
|
}
|
||||||
|
this.heartbeat = await value.heartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
104
htdocs/Autocomplete.vue
Normal file
104
htdocs/Autocomplete.vue
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="autocomplete">
|
||||||
|
<input type="text" @input="option_open" v-model="xvalue" @keydown.down="option_down" @keydown.up="option_up" @keydown.enter="option_enter" />
|
||||||
|
<ul id="autocomplete-results" v-show="open" class="autocomplete-results">
|
||||||
|
<li class="loading" v-if="!items">Loading results...</li>
|
||||||
|
<li v-else v-for="(result, i) in results" :key="i" @click="option_click(result)" class="autocomplete-result" :class="{ 'is-active': i === index }">{{ result }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.autocomplete {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-results {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
height: 120px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-result {
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-result.is-active,
|
||||||
|
.autocomplete-result:hover {
|
||||||
|
background-color: #4AAE9B;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
default: () => [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
xvalue: '',
|
||||||
|
results: [],
|
||||||
|
open: false,
|
||||||
|
index: -1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value(val) {
|
||||||
|
this.xvalue = val;
|
||||||
|
},
|
||||||
|
xvalue(val) {
|
||||||
|
this.$emit('update:value', val);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.xvalue = this.value;
|
||||||
|
document.addEventListener('click', this.option_close)
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
document.removeEventListener('click', this.option_close)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
option_open() {
|
||||||
|
if(this.items) {
|
||||||
|
this.results = this.items.filter((item) => item.toLowerCase().indexOf(this.xvalue.toLowerCase()) > -1);
|
||||||
|
this.open = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
option_down() {
|
||||||
|
if(this.index < this.results.length) this.index++;
|
||||||
|
},
|
||||||
|
option_up() {
|
||||||
|
if(this.index > 0) this.index--;
|
||||||
|
},
|
||||||
|
option_enter() {
|
||||||
|
this.xvalue = this.results[this.index];
|
||||||
|
this.open = false;
|
||||||
|
this.index = -1;
|
||||||
|
},
|
||||||
|
option_click(result) {
|
||||||
|
this.xvalue = result;
|
||||||
|
this.open = false;
|
||||||
|
},
|
||||||
|
option_close(evt) {
|
||||||
|
if(!this.$el.contains(evt.target)) {
|
||||||
|
this.open = false;
|
||||||
|
this.index = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
154
htdocs/DateRangePicker.vue
Normal file
154
htdocs/DateRangePicker.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<template v-if="x_range == '1D'">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, -24*60*60*1000*(reversed ? -1 : +1))">🡠</button>
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, 24*60*60*1000*(reversed ? -1 : +1))">🡢</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="x_range == '1W'">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, -7*24*60*60*1000*(reversed ? -1 : +1))">🡠</button>
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, 7*24*60*60*1000*(reversed ? -1 : +1))">🡢</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="x_range == '1M'">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -1*(reversed ? -1 : +1))">🡠</button>
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 1*(reversed ? -1 : +1))">🡢</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="x_range == '6M'">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -6*(reversed ? -1 : +1))">🡠</button>
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 6*(reversed ? -1 : +1))">🡢</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="x_range == '1Y'">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -12*(reversed ? -1 : +1))">🡠</button>
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 12*(reversed ? -1 : +1))">🡢</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="x_range == '2Y'">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -24*(reversed ? -1 : +1))">🡠</button>
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 24*(reversed ? -1 : +1))">🡢</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="x_range == 'Range'">
|
||||||
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
|
<input type="date" class="form-control" v-model="disp_date_end" />
|
||||||
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DateRangePickerRange from './DateRangePickerRange.vue';
|
||||||
|
|
||||||
|
function timeshift(date, ms) {
|
||||||
|
return new Date(date.getTime() + ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
function datecalc(date, range, direction) {
|
||||||
|
switch(range) {
|
||||||
|
case '1D':
|
||||||
|
return timeshift(date, Math.sign(direction)*24*60*60*1000);
|
||||||
|
case '1W':
|
||||||
|
return timeshift(date, Math.sign(direction)*7*24*60*60*1000);
|
||||||
|
case '1M':
|
||||||
|
return timeshift_month(date, Math.sign(direction)*1);
|
||||||
|
case '6M':
|
||||||
|
return timeshift_month(date, Math.sign(direction)*6);
|
||||||
|
case '1Y':
|
||||||
|
return timeshift_month(date, Math.sign(direction)*12);
|
||||||
|
case '2Y':
|
||||||
|
return timeshift_month(date, Math.sign(direction)*24);
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
DateRangePickerRange
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
range: {
|
||||||
|
type: String,
|
||||||
|
default: '1D'
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: String,
|
||||||
|
default: '-1'
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
type: Date,
|
||||||
|
default: new Date()
|
||||||
|
},
|
||||||
|
date_end: {
|
||||||
|
type: Date,
|
||||||
|
default: new Date()
|
||||||
|
},
|
||||||
|
reversed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
x_range: this.range,
|
||||||
|
x_date: this.date
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
disp_date: {
|
||||||
|
get() {
|
||||||
|
return this.x_date.toLocaleDateString('en-CA');
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
value = value.split('-')
|
||||||
|
this.x_date = new Date(value[0], value[1] - 1, value[2]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disp_date_end: {
|
||||||
|
get() {
|
||||||
|
return this.x_date_end.toLocaleDateString('en-CA');
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
value = value.split('-')
|
||||||
|
this.x_date_end = new Date(value[0], value[1] - 1, value[2]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
params() {
|
||||||
|
return {
|
||||||
|
x_date: this.x_date,
|
||||||
|
x_range: this.x_range,
|
||||||
|
direction: this.direction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
params(value) {
|
||||||
|
if(value.x_range != 'Range') this.x_date_end = datecalc(value.x_date, value.x_range, value.direction);
|
||||||
|
this.$emit('update:date', value.x_date);
|
||||||
|
this.$emit('update:date_end', this.x_date_end);
|
||||||
|
},
|
||||||
|
date(value) { this.x_date = value; },
|
||||||
|
x_date(value) { this.$emit('update:date', value); },
|
||||||
|
range(value) { this.x_range = value; },
|
||||||
|
x_range(value) { this.$emit('update:range', value); }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
timeshift, timeshift_month
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$emit('update:date_end', this.x_date_end = datecalc(this.x_date, this.x_range, this.direction));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
39
htdocs/DateRangePickerRange.vue
Normal file
39
htdocs/DateRangePickerRange.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<select class="form-select form-select-sm" style="width: auto;" v-model="x_modelValue">
|
||||||
|
<option value="1D">{{disp_direction}}1D</option>
|
||||||
|
<option value="1W">{{disp_direction}}1W</option>
|
||||||
|
<option value="1M">{{disp_direction}}1M</option>
|
||||||
|
<option value="6M">{{disp_direction}}6M</option>
|
||||||
|
<option value="1Y">{{disp_direction}}1Y</option>
|
||||||
|
<option value="2Y">{{disp_direction}}2Y</option>
|
||||||
|
<option value="Range">Range</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '1D'
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
default: '-1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
disp_direction() {
|
||||||
|
return this.direction > 0 ? '+' : this.direction < 0 ? '−' : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(value) { this.x_modelValue = value; },
|
||||||
|
x_modelValue(value) { this.$emit('update:modelValue', this.x_modelValue); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
75
htdocs/Login.vue
Normal file
75
htdocs/Login.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header"><template v-if="user">{{user[2]}}</template><template v-else>Login</template></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text row"><code class="col" v-if="banner"><pre>{{banner.join('\n')}}</pre></code><code class="col" v-if="user"><pre>{{user.join('\n')}}</pre></code></p>
|
||||||
|
</div>
|
||||||
|
<div class="input-group flex-nowrap" v-if="!user">
|
||||||
|
<span class="input-group-text">🔑</span>
|
||||||
|
<input type="password" class="form-control" placeholder="Access Code" v-model="accesscode" />
|
||||||
|
<input type="password" class="form-control" placeholder="Verify Code" v-model="verifycode" />
|
||||||
|
<button class="btn btn-primary" type="button" v-on:click="submit">Login<template v-if="!(accesscode || verifycode)"> (omit AV codes for SAML)</template></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import vistax from './vistax.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
secret: String,
|
||||||
|
client: Object,
|
||||||
|
user: {
|
||||||
|
type: Array,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:client': Object,
|
||||||
|
'update:user': {
|
||||||
|
type: Array,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
x_client: this.client,
|
||||||
|
x_user: this.user,
|
||||||
|
banner: null,
|
||||||
|
accesscode: null,
|
||||||
|
verifycode: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
client(value) { this.x_client = value; },
|
||||||
|
x_client(value) { this.$emit('update:client', value); },
|
||||||
|
user(value) { this.x_user = value; },
|
||||||
|
x_user(value) { this.$emit('update:user', value); }
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.x_client = await vistax.Client.fromCookie(this.secret);
|
||||||
|
this.banner = await this.x_client.XUS_INTRO_MSG();
|
||||||
|
if((await this.x_client.userinfo()).result) try {
|
||||||
|
var user = await this.x_client.XUS_GET_USER_INFO();
|
||||||
|
this.x_user = user[0] ? user : null
|
||||||
|
} catch(ex) {
|
||||||
|
this.x_user = null;
|
||||||
|
}
|
||||||
|
this.$emit('update:user', this.x_user);
|
||||||
|
console.log('Backend secret', this.secret);
|
||||||
|
console.log(this.banner);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async submit(evt) {
|
||||||
|
var res = await ((this.accesscode && this.verifycode) ? this.x_client.authenticate(this.accesscode + ';' + this.verifycode) : this.x_client.authenticate());
|
||||||
|
if(!!res.result[0]) {
|
||||||
|
var user = await this.x_client.XUS_GET_USER_INFO();
|
||||||
|
this.x_user = user[0] ? user : null
|
||||||
|
} else this.x_user = null;
|
||||||
|
this.$emit('update:user', this.x_user);
|
||||||
|
console.log('Authenticate', res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
43
htdocs/Navbar.vue
Normal file
43
htdocs/Navbar.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="navbar navbar-expand-lg bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/"><template v-if="user">{{user[2]}}</template><template v-else>WebVistA</template></a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/">Schedule</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/patient">Patient</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" v-if="user">
|
||||||
|
<a class="nav-link disabled">{{user[3]}}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<form class="d-flex" role="search">
|
||||||
|
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
|
||||||
|
<button class="btn btn-outline-success" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import vistax from './vistax.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
user: {
|
||||||
|
type: Array,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
80
htdocs/RoutePatientDetail.vue
Normal file
80
htdocs/RoutePatientDetail.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="info">
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header">{{info.name}} #{{$route.params.id}} ${{info.pid}}</div>
|
||||||
|
<div class="card-body row" style="font-family: monospace;">
|
||||||
|
<div class="col" v-if="info.dob"><strong>DOB:</strong> {{strptime_vista(info.dob).toLocaleDateString('en-CA')}}</div>
|
||||||
|
<div class="col" v-if="info.age"><strong>Age:</strong> {{info.age}}</div>
|
||||||
|
<div class="col" v-if="info.sex"><strong>Sex:</strong> {{info.sex}}</div>
|
||||||
|
<div class="col" v-if="info.sc_percentage"><strong>SC%:</strong> {{info.sc_percentage}}</div>
|
||||||
|
<div class="col" v-if="info.type"><strong>Type:</strong> {{info.type}}</div>
|
||||||
|
<div class="col" v-if="info.ward"><strong>Ward:</strong> {{info.ward}}</div>
|
||||||
|
<div class="col" v-if="info.room_bed"><strong>Room/bed:</strong> {{info.room_bed}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Vitals</span>
|
||||||
|
<DateRangePicker range="1M" direction="-1" v-model:date="vitals_date" v-model:date_end="vitals_date_begin" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ViewVitalsLabs :client="client" :dfn="$route.params.id" :date_begin="vitals_date_begin" :date_end="vitals_date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { uniq, groupByArray, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
import ViewVitalsLabs from './ViewVitalsLabs.vue';
|
||||||
|
|
||||||
|
var now = new Date();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
DateRangePicker, ViewVitalsLabs
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
info: null,
|
||||||
|
vitals_date: now,
|
||||||
|
vitals_date_begin: now,
|
||||||
|
labs_date: now,
|
||||||
|
labs_date_begin: now
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
info(value) {
|
||||||
|
if((value) && (value.name)) document.title = value.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime_vista
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
if(this.$route.params.id.startsWith('$')) {
|
||||||
|
var id = this.$route.params.id.substring(1);
|
||||||
|
if(id.length == 9) {
|
||||||
|
var patient = await this.client.ORWPT_FULLSSN(id);
|
||||||
|
this.$router.replace('/patient/' + patient[0].dfn);
|
||||||
|
} else if(id.length == 5) {
|
||||||
|
var name = this.$route.query.name.toUpperCase();
|
||||||
|
var patient = await this.client.ORWPT_LAST5(id);
|
||||||
|
for(var i = 0; i < patient.length; ++i) if(name == patient[i].name) {
|
||||||
|
this.$router.replace('/patient/' + patient[0].dfn);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else this.info = await this.client.ORWPT16_ID_INFO(this.$route.params.id);
|
||||||
|
},
|
||||||
|
async beforeRouteUpdate(to, from, next) {
|
||||||
|
this.info = await this.client.ORWPT16_ID_INFO(to.params.id);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
32
htdocs/RoutePatientLookup.vue
Normal file
32
htdocs/RoutePatientLookup.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header">Patients</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ViewPatientLookup :client="client" v-model:selection="selection" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ViewPatientLookup from './ViewPatientLookup.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewPatientLookup
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selection: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
59
htdocs/RouteSchedule.vue
Normal file
59
htdocs/RouteSchedule.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header">Clinics</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ViewResourceLookup :client="client" v-model:selection="selection" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Schedule</span>
|
||||||
|
<DateRangePicker range="1D" direction="+1" v-model:date="date" v-model:date_end="date_end" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ViewSchedule :client="client" :selection="selection" :date_begin="datefmt(date)" :date_end="datefmt(new Date(date_end.getTime() - 1))" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import cookie from './cookie.mjs';
|
||||||
|
|
||||||
|
import ViewResourceLookup from './ViewResourceLookup.vue';
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
import ViewSchedule from './ViewSchedule.vue';
|
||||||
|
|
||||||
|
function dateonly(date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewResourceLookup, DateRangePicker, ViewSchedule
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
var resources = cookie.get('vista.resources');
|
||||||
|
return {
|
||||||
|
selection: resources ? (resources.split(',').filter(x => x) || []) : [],
|
||||||
|
date: dateonly(new Date()),
|
||||||
|
date_end: dateonly(new Date())
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selection(value, oldvalue) {
|
||||||
|
cookie.set('vista.resources', value.join(','), 7);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
datefmt(date) {
|
||||||
|
return date ? date.toLocaleDateString('en-CA') : '';
|
||||||
|
//return (new Date(date.getTime() + date.getTimezoneOffset()*60000)).toLocaleDateString('en-CA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
233
htdocs/ViewData.vue
Normal file
233
htdocs/ViewData.vue
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<div style="font-family: monospace;" role="region" tabindex="0" v-if="(resultset) && (resultset.length > 0)">
|
||||||
|
<table class="table-sticky table-data">
|
||||||
|
<thead>
|
||||||
|
<tr><th class="name">{{name}}</th><th class="date" v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr) }" ref="headers"><div class="year">{{group.datehdr.year}}</div><div class="monthdate">{{group.datehdr.monthdate}}</div><div class="hourminute" :class="{ daily }">{{group.datehdr.hourminute}}</div></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody v-for="report in reports">
|
||||||
|
<template v-for="name in report">
|
||||||
|
<tr v-if="names[name]">
|
||||||
|
<th>{{name}}</th>
|
||||||
|
<td v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr), abnormal_ref: abnormal_ref(group.values[name]), abnormal_ref_low: abnormal_ref_low(group.values[name]), abnormal_ref_high: abnormal_ref_high(group.values[name]), abnormal_iqr: abnormal_iqr(group.values[name]), abnormal_iqr_low: abnormal_iqr_low(group.values[name]), abnormal_iqr_high: abnormal_iqr_high(group.values[name]) }" :title="tooltip(group.values[name])">{{group.values[name] ? group.values[name].value : ''}}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="name in names">
|
||||||
|
<tr v-if="!names_excluded[name]">
|
||||||
|
<th>{{name}}</th>
|
||||||
|
<td v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr), abnormal_ref: abnormal_ref(group.values[name]), abnormal_ref_low: abnormal_ref_low(group.values[name]), abnormal_ref_high: abnormal_ref_high(group.values[name]), abnormal_iqr: abnormal_iqr(group.values[name]), abnormal_iqr_low: abnormal_iqr_low(group.values[name]), abnormal_iqr_high: abnormal_iqr_high(group.values[name]) }" :title="tooltip(group.values[name])">{{group.values[name] ? group.values[name].value : ''}}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table.table-sticky {
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
table.table-sticky th:first-child {
|
||||||
|
border-right: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
table.table-sticky tbody {
|
||||||
|
border-top: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
table.table-sticky tbody tr {
|
||||||
|
border-top: 1px dashed #dee2e6;
|
||||||
|
}
|
||||||
|
table.table-sticky td:nth-of-type(odd) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
table.table-sticky tbody th, table.table-sticky th.name {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
table.table-sticky th.date {
|
||||||
|
font-size: 80%;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
table.table-sticky th.date .monthdate {
|
||||||
|
font-size: 125%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
table.table-sticky th.date .hourminute.daily {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
table.table-sticky tbody td {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
max-width: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.first {
|
||||||
|
border-left: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.year, .monthdate {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.first .year, .first .monthdate {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
[role="region"][tabindex] {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { uniq, groupByArray, quantile_sorted } from './util.mjs';
|
||||||
|
|
||||||
|
function isNumeric(x) {
|
||||||
|
return (x !== '') && (x !== false) && (x !== null) && (!isNaN(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statistics(resultset) {
|
||||||
|
var res = {}, group, item;
|
||||||
|
for(var i = resultset.length - 1; i >= 0; --i) {
|
||||||
|
item = resultset[i];
|
||||||
|
if(isNumeric(item.value)) {
|
||||||
|
if(res[item.name]) res[item.name].push(+item.value);
|
||||||
|
else res[item.name] = [+item.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(var k in res) if(res.hasOwnProperty(k)) {
|
||||||
|
item = res[k].sort((a, b) => a - b);
|
||||||
|
item = res[k] = {
|
||||||
|
n: item.length,
|
||||||
|
q25: quantile_sorted(item, 0.25),
|
||||||
|
q50: quantile_sorted(item, 0.50),
|
||||||
|
q75: quantile_sorted(item, 0.75)
|
||||||
|
}
|
||||||
|
item.range = item.q25 != item.q75 ? ('IQR: ' + item.q25 + ' - ' + item.q75) : ('Median: ' + item.q50);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function date_header(date) {
|
||||||
|
var datestr = date.toLocaleDateString('en-CA');
|
||||||
|
var timestr = date.toLocaleTimeString('en-GB');
|
||||||
|
return {
|
||||||
|
datestr, timestr,
|
||||||
|
year: datestr.substring(0, 4),
|
||||||
|
monthdate: datestr.substring(5),
|
||||||
|
hourminute: timestr.substring(0, 5),
|
||||||
|
second: timestr.substring(6)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
resultset: {
|
||||||
|
type: Array,
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
daily: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
reports: {
|
||||||
|
type: Array,
|
||||||
|
default: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
groups() {
|
||||||
|
if(this.daily) return groupByArray(this.resultset, x => new Date(x.time.getFullYear(), x.time.getMonth(), x.time.getDate())).map(function(group) {
|
||||||
|
group = group.values.reduce(((acc, x) => ((acc.values[x.name] || (acc.values[x.name] = [])).push(x), acc)), { key: group.key, datehdr: date_header(group.key), values: {}});
|
||||||
|
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
|
||||||
|
var items = group.values[k].sort((a, b) => a.time - b.time);
|
||||||
|
var strings = items.map(item => item.time.toLocaleTimeString('en-GB') + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : ''));
|
||||||
|
var flags = uniq(items.map(item => item.flag).filter(x => x).map(x => x.charAt(0)));
|
||||||
|
var comments = uniq(items.map(item => item.comment).filter(x => x));
|
||||||
|
var numbers = uniq(items.map(item => item.value).filter(x => isNumeric(x)));
|
||||||
|
var min = Math.min.apply(null, numbers);
|
||||||
|
var max = Math.max.apply(null, numbers);
|
||||||
|
group.values[k] = {
|
||||||
|
time: group.key,
|
||||||
|
name: k,
|
||||||
|
unit: items[0].unit,
|
||||||
|
range: items[0].range,
|
||||||
|
value: numbers.length > 1 ? min + ' - ' + max : numbers.length == 1 ? numbers[0] : items.length == 1 ? items[0].value : 'MULTIPLE', min: min, max: max,
|
||||||
|
flag: flags.length > 1 ? '*' : flags.length == 1 ? flags[0] : null,
|
||||||
|
comment: (strings.join('\n') + '\n\n' + comments.join('\n\n')).replace(/^\s+|\s+$/g, '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}).sort((a, b) => (a.key > b.key) - (a.key < b.key));
|
||||||
|
else return groupByArray(this.resultset, x => x.time).map(group => group.values.reduce(((acc, x) => (acc.values[x.name] = x, acc)), { key: group.key, datehdr: date_header(group.key), values: {}})).sort((a, b) => (a.key > b.key) - (a.key < b.key));
|
||||||
|
},
|
||||||
|
names() {
|
||||||
|
var res = uniq(this.resultset.map(x => x.name));
|
||||||
|
return res.reduce((acc, x) => (acc[x] = true, acc), res);
|
||||||
|
},
|
||||||
|
names_excluded() {
|
||||||
|
var res = {};
|
||||||
|
this.reports.forEach(report => report.reduce((acc, x) => (acc[x] = true, acc), res));
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
statistics() {
|
||||||
|
return statistics(this.resultset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async resultset(value) {
|
||||||
|
this.$nextTick(() => this.$refs.headers ? this.$refs.headers.scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
tooltip(item) {
|
||||||
|
if(item) {
|
||||||
|
var res = [], stat;
|
||||||
|
if(item.range) res.push('Ref: ' + item.range + ' ' + item.unit + (item.flag ? ' [' + item.flag + ']' : ''));
|
||||||
|
if(stat = this.statistics[item.name]) res.push(stat.range + (item.range ? ' ' + item.unit : '') + (isNaN(parseFloat(item.value)) ? '' : item.value < stat.q25 ? ' [L]' : item.value > stat.q75 ? ' [H]' : ''));
|
||||||
|
if(item.comment) {
|
||||||
|
if(res.length > 0) res.push('');
|
||||||
|
res.push(item.comment);
|
||||||
|
}
|
||||||
|
return res.join('\n');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abnormal_ref(item) {
|
||||||
|
return (item) && (item.flag);
|
||||||
|
},
|
||||||
|
abnormal_ref_low(item) {
|
||||||
|
return (item) && (item.flag) && (item.flag.indexOf('L') >= 0);
|
||||||
|
},
|
||||||
|
abnormal_ref_high(item) {
|
||||||
|
return (item) && (item.flag) && (item.flag.indexOf('H') >= 0);
|
||||||
|
},
|
||||||
|
abnormal_iqr(item) {
|
||||||
|
var stat;
|
||||||
|
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
|
||||||
|
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return (item.min < stat.q25) || (item.max > stat.q75);
|
||||||
|
else if(isNumeric(item.value)) return (item.value < stat.q25) || (item.value > stat.q75);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abnormal_iqr_low(item) {
|
||||||
|
var stat;
|
||||||
|
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
|
||||||
|
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.min < stat.q25;
|
||||||
|
else if(isNumeric(item.value)) return item.value < stat.q25;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abnormal_iqr_high(item) {
|
||||||
|
var stat;
|
||||||
|
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
|
||||||
|
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.max > stat.q75;
|
||||||
|
else if(isNumeric(item.value)) return item.value > stat.q75;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
50
htdocs/ViewLabs.vue
Normal file
50
htdocs/ViewLabs.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<ViewData name="Labs" :resultset="resultset" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { strftime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import ViewData from './ViewData.vue';
|
||||||
|
|
||||||
|
function normalize(rs) {
|
||||||
|
return rs.map(function(x) {
|
||||||
|
return {
|
||||||
|
time: x.time_collected,
|
||||||
|
name: x.name,
|
||||||
|
unit: x.unit,
|
||||||
|
range: x.range,
|
||||||
|
value: x.value,
|
||||||
|
flag: x.flag,
|
||||||
|
comment: x.comment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewData
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async params(value) {
|
||||||
|
this.resultset = normalize(await this.client.ORWLRR_INTERIM_RESULTS(value.dfn, value.date_end, value.date_begin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
57
htdocs/ViewPatientLookup.vue
Normal file
57
htdocs/ViewPatientLookup.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">🔎</span>
|
||||||
|
<input class="form-control" v-model="query_raw" />
|
||||||
|
</div>
|
||||||
|
<div style="max-height: 30em; overflow-y: auto;">
|
||||||
|
<table class="table table-striped" style="font-family: monospace;" v-if="(resultset) && (resultset.length > 0)">
|
||||||
|
<thead>
|
||||||
|
<tr><th>DFN</th><th>Name</th><th>PID</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in resultset">
|
||||||
|
<td>{{row.dfn}}</td>
|
||||||
|
<td><router-link :to="'/patient/' + row.dfn">{{row.name}}</router-link></td>
|
||||||
|
<td>{{row.pid}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce } from './util.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
selection: {}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: [],
|
||||||
|
query_raw: '',
|
||||||
|
query_view: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
query_raw(value) {
|
||||||
|
this.query_sync(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.query_sync = debounce(async function(value) {
|
||||||
|
this.query_view = value = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
||||||
|
this.resultset = value ? (await this.client.ORWPT16_LOOKUP(value)) : [];
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
101
htdocs/ViewResourceLookup.vue
Normal file
101
htdocs/ViewResourceLookup.vue
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">🔎</span>
|
||||||
|
<input class="form-control" v-model="query_raw" />
|
||||||
|
</div>
|
||||||
|
<div style="max-height: 30em; overflow-y: auto;">
|
||||||
|
<table class="table table-striped" style="font-family: monospace;" v-if="resultset_raw && resultset_raw.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr><th></th><th>ID</th><th>Name</th><th>Type</th><th>User</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="row in resultset_filtered">
|
||||||
|
<tr :class="{ 'table-active': row.selected }" v-if="row.INACTIVE != 'YES'">
|
||||||
|
<td><input type="checkbox" v-model="row.selected" /></td>
|
||||||
|
<td>{{row.RESOURCEID}}</td>
|
||||||
|
<td>{{row.RESOURCE_NAME}}</td>
|
||||||
|
<td>{{row.RESOURCETYPE}}</td>
|
||||||
|
<td>{{row.USERNAME}}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="font-family: monospace;" v-if="resultset_selected.length">
|
||||||
|
<span class="badge bg-primary" style="cursor: default; margin-right: 0.35em;" v-on:click="reset">CLEAR {{resultset_selected.length}}</span>
|
||||||
|
<span class="badge bg-secondary" style="cursor: default; margin-right: 0.35em;" v-for="row in resultset_selected" v-on:click="row.selected = false;">❌ {{row.RESOURCEID}} {{row.RESOURCE_NAME}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce } from './util.mjs';
|
||||||
|
|
||||||
|
function arrayeq1(a, b) {
|
||||||
|
if(a.length == b.length) {
|
||||||
|
for(var i = a.length - 1; i >= 0; --i) if(a[i] != b[i]) return false;
|
||||||
|
return true;
|
||||||
|
} else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_selection(resultset, selection) {
|
||||||
|
var mapping = selection.reduce((obj, key) => (obj[key] = true, obj), {});
|
||||||
|
for(var i = resultset.length -1; i >= 0; --i) resultset[i].selected = resultset[i].RESOURCEID in mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
selection: {
|
||||||
|
type: Array,
|
||||||
|
default: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:selection': Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset_raw: [],
|
||||||
|
query_raw: '',
|
||||||
|
query_view: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
resultset_filtered() {
|
||||||
|
var query_view = this.query_view.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase();
|
||||||
|
return query_view ? this.resultset_raw.filter(row => (query_view == row.RESOURCEID) || (row.RESOURCE_NAME.indexOf(query_view) >= 0) || (row.USERNAME.indexOf(query_view) >= 0)) : this.resultset_raw;
|
||||||
|
},
|
||||||
|
resultset_selected() {
|
||||||
|
return this.resultset_raw.filter(row => row.selected);
|
||||||
|
},
|
||||||
|
resultset_selection() {
|
||||||
|
return this.resultset_selected.map(x => x.RESOURCEID);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selection(value) {
|
||||||
|
if(!arrayeq1(value, this.resultset_selection)) update_selection(this.resultset_raw, value);
|
||||||
|
},
|
||||||
|
resultset_selection(value) {
|
||||||
|
this.$emit('update:selection', value);
|
||||||
|
},
|
||||||
|
query_raw(value) {
|
||||||
|
this.query_sync(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
reset(evt) {
|
||||||
|
var selection = this.resultset_selected.slice();
|
||||||
|
for(var i = selection.length - 1; i >= 0; --i) selection[i].selected = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.query_sync = debounce(function(value) { this.query_view = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' '); }, 500);
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
var rs = (await this.client.SDEC_RESOURCE()).slice();
|
||||||
|
update_selection(rs, this.selection);
|
||||||
|
this.resultset_raw = rs;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
89
htdocs/ViewSchedule.vue
Normal file
89
htdocs/ViewSchedule.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<table class="table" style="font-family: monospace;" v-if="appointments && appointments.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Clinic</th><th>Patient</th><th>Note</th><th>Assignee</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in appointments" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
|
||||||
|
<td>{{row.ApptDate}}</td>
|
||||||
|
<td>{{row.Clinic}}</td>
|
||||||
|
<td v-if="production"><router-link :to="'/patient/$' + row.HRN">{{row.Name}} ${{row.HRN}}</router-link></td>
|
||||||
|
<td v-else><router-link :title="unscramble(row.Name)" :to="'/patient/$' + row.Name.charAt(0) + row.HRN.slice(-4) + '?name=' + row.Name">{{row.Name}} ${{row.HRN}}</router-link></td>
|
||||||
|
<td>{{row.NOTE}} [{{row.APPT_MADE_BY}} on {{row.DATE_APPT_MADE}}]</td>
|
||||||
|
<td><Autocomplete :value="practitioner[row.Name]" @update:value="x => set_practitioner(row.Name, x)" :items="practitioner_list" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import cookie from './cookie.mjs';
|
||||||
|
import { uniq, strHashHSL } from './util.mjs';
|
||||||
|
|
||||||
|
import Autocomplete from './Autocomplete.vue';
|
||||||
|
|
||||||
|
function datefm(datestr) {
|
||||||
|
var date = datestr ? new Date(datestr) : new Date();
|
||||||
|
date = new Date(date.getTime() + date.getTimezoneOffset()*60000);
|
||||||
|
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function strtr(s, a, b) {
|
||||||
|
var res = '';
|
||||||
|
for(var i = 0; i < s.length; ++i) {
|
||||||
|
var j = a.indexOf(s.charAt(i));
|
||||||
|
res += j >= 0 ? b.charAt(j) : s.charAt(i);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Autocomplete
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
selection: {
|
||||||
|
type: Array,
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
date_begin: String,
|
||||||
|
date_end: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
appointments: [],
|
||||||
|
practitioner: {},
|
||||||
|
production: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { selection: this.selection, date_begin: this.date_begin, date_end: this.date_end };
|
||||||
|
},
|
||||||
|
practitioner_list() {
|
||||||
|
return this.practitioner ? uniq(Object.values(this.practitioner)).sort() : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async params(value) {
|
||||||
|
this.appointments = value.selection.length > 0 ? (await this.client.SDEC_CLINLET(value.selection.join('|') + '|', datefm(value.date_begin), datefm(value.date_end))).sort((a, b) => (new Date(a.ApptDate)) - (new Date(b.ApptDate))) : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strHashHSL,
|
||||||
|
unscramble(name) {
|
||||||
|
return name.length > 0 ? (name.charAt(0) + strtr(name.substring(1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'LKJIHGFEDCBAZYXWVUTSRQPONM')) : name;
|
||||||
|
},
|
||||||
|
set_practitioner(patient, practitioner) {
|
||||||
|
this.practitioner[patient] = practitioner;
|
||||||
|
cookie.set('vista.practitioner', JSON.stringify(this.practitioner), 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
var practitioner = cookie.get('vista.practitioner');
|
||||||
|
if(practitioner) this.practitioner = JSON.parse(practitioner);
|
||||||
|
this.production = (await this.client.serverinfo()).result.production == '1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
57
htdocs/ViewVitals.vue
Normal file
57
htdocs/ViewVitals.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<ViewData name="Vitals" :resultset="resultset" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { strftime_vista, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import ViewData from './ViewData.vue';
|
||||||
|
|
||||||
|
const mapping = {
|
||||||
|
'T': { range: '35 - 38' },
|
||||||
|
'P': { range: '60 - 100', unit: 'bpm' },
|
||||||
|
'R': { range: '12 - 19', unit: 'bpm' },
|
||||||
|
'Pulse Oximetry': { range: '95 - 100' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalize(rs) {
|
||||||
|
return rs.map(function(x) {
|
||||||
|
var res = {
|
||||||
|
time: x.datetime,
|
||||||
|
name: x.name,
|
||||||
|
unit: x.unit,
|
||||||
|
value: x.value,
|
||||||
|
flag: x.flag,
|
||||||
|
comment: x.user
|
||||||
|
};
|
||||||
|
return mapping[x.name] ? Object.assign(res, mapping[x.name]) : res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewData
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async params(value) {
|
||||||
|
this.resultset = normalize(await this.client.GMV_EXTRACT_REC(value.dfn, value.date_end, value.date_begin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
92
htdocs/ViewVitalsLabs.vue
Normal file
92
htdocs/ViewVitalsLabs.vue
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="form-check form-check-inline form-switch form-check-label" v-for="report in reports">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="report.selected" /> {{report.name}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<ViewData :resultset="resultset" :daily="true" :reports="reports_selected" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { strftime_vista, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import ViewData from './ViewData.vue';
|
||||||
|
|
||||||
|
const reports = [
|
||||||
|
{ name: 'Vitals', value: ['T', 'P', 'R', 'SBP', 'DBP', 'Pulse Oximetry', 'Wt', 'Ht', 'Pain'], selected: true },
|
||||||
|
{ name: 'CBC', value: ['HGB', 'MCV', 'PLT', 'WBC', 'NEUTROPHIL#'], selected: false },
|
||||||
|
{ name: 'Renal', value: ['CREATININE', 'UREA NITROGEN', 'EGFR CKD-EPI 2021', 'Estimated GFR dc\'d 3/30/2022'], 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: 'Coagulation', value: ['PT', 'INR', 'PTT'], selected: false },
|
||||||
|
{ name: 'Vitamins', value: ['FERRITIN', 'IRON', 'TIBC', 'B 12', 'FOLATE', 'VITAMIN D TOTAL 25-OH'], selected: false },
|
||||||
|
{ name: 'Thyroid', value: ['TSH', 'T4 (THYROXINE)'], selected: false }
|
||||||
|
];
|
||||||
|
reports.reduce((acc, x) => acc[x] = x, reports);
|
||||||
|
|
||||||
|
const vitals_mapping = {
|
||||||
|
'T': { range: '35 - 38' },
|
||||||
|
'P': { range: '60 - 100', unit: 'bpm' },
|
||||||
|
'R': { range: '12 - 19', unit: 'bpm' },
|
||||||
|
'Pulse Oximetry': { range: '95 - 100' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function vitals_normalize(rs) {
|
||||||
|
return rs.map(function(x) {
|
||||||
|
var res = {
|
||||||
|
time: x.datetime,
|
||||||
|
name: x.name,
|
||||||
|
unit: x.unit,
|
||||||
|
value: x.value,
|
||||||
|
flag: x.flag,
|
||||||
|
comment: x.user
|
||||||
|
};
|
||||||
|
return vitals_mapping[x.name] ? Object.assign(res, vitals_mapping[x.name]) : res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labs_normalize(rs) {
|
||||||
|
return rs.map(function(x) {
|
||||||
|
return {
|
||||||
|
time: x.time_collected,
|
||||||
|
name: x.name,
|
||||||
|
unit: x.unit,
|
||||||
|
range: x.range,
|
||||||
|
value: x.value,
|
||||||
|
flag: x.flag,
|
||||||
|
comment: x.comment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewData
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: null,
|
||||||
|
reports
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||||
|
},
|
||||||
|
reports_selected() {
|
||||||
|
return this.reports.filter(x => x.selected).map(x => x.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async params(value, oldvalue) {
|
||||||
|
this.resultset = vitals_normalize(await this.client.GMV_EXTRACT_REC(value.dfn, value.date_end, value.date_begin)).concat(labs_normalize(await this.client.ORWLRR_INTERIM_RESULTS(value.dfn, value.date_end, value.date_begin)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
29
htdocs/cookie.mjs
Normal file
29
htdocs/cookie.mjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// https://stackoverflow.com/a/24103596
|
||||||
|
// https://www.quirksmode.org/js/cookies.html
|
||||||
|
|
||||||
|
export function set(name, value, days) {
|
||||||
|
var expires = '';
|
||||||
|
if(days) {
|
||||||
|
var date = new Date();
|
||||||
|
date.setTime(date.getTime() + (days*24*60*60*1000));
|
||||||
|
expires = '; expires=' + date.toUTCString();
|
||||||
|
}
|
||||||
|
document.cookie = name + '=' + (value || '') + expires + '; path=/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(name) {
|
||||||
|
var nameEQ = name + '=';
|
||||||
|
var ca = document.cookie.split(';');
|
||||||
|
for(var i = 0; i < ca.length; i++) {
|
||||||
|
var c = ca[i];
|
||||||
|
while(c.charAt(0)==' ') c = c.substring(1, c.length);
|
||||||
|
if(c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reset(name) {
|
||||||
|
document.cookie = name +'=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { set, get, reset };
|
46
htdocs/index.html
Normal file
46
htdocs/index.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WebVistA</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/css/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="/table-sticky.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="/userstyle.css" />
|
||||||
|
</head>
|
||||||
|
<body><div id='root'></div></body>
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue@3.2"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue-router@4"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/js/bootstrap.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue3-sfc-loader/dist/vue3-sfc-loader.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var loadModule = window['vue3-sfc-loader'].loadModule;
|
||||||
|
var options = {
|
||||||
|
moduleCache: {
|
||||||
|
vue: Vue
|
||||||
|
},
|
||||||
|
async getFile(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if(res.ok) return {
|
||||||
|
getContentData: asBinary => asBinary ? res.arrayBuffer() : res.text(),
|
||||||
|
}
|
||||||
|
else throw Object.assign(new Error(res.statusText + ' ' + url), { res });
|
||||||
|
},
|
||||||
|
addStyle(textContent) {
|
||||||
|
const style = Object.assign(document.createElement('style'), { textContent });
|
||||||
|
const ref = document.head.getElementsByTagName('style')[0] || null;
|
||||||
|
document.head.insertBefore(style, ref);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var secret = window.location.hash.substring(1);
|
||||||
|
var app = Vue.createApp({
|
||||||
|
components: {
|
||||||
|
'app': Vue.defineAsyncComponent(function() { return loadModule('/App.vue', options); })
|
||||||
|
},
|
||||||
|
data: function() { return { secret: secret }; },
|
||||||
|
template: '<app :secret="secret"></app>'
|
||||||
|
});
|
||||||
|
app.use(VueRouter.createRouter({ history: VueRouter.createWebHistory(), routes: [] }));
|
||||||
|
app.mount('#root');
|
||||||
|
</script>
|
||||||
|
</html>
|
158
htdocs/reportparser.mjs
Normal file
158
htdocs/reportparser.mjs
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
function isEqualArray(a, b) {
|
||||||
|
if(a.length == b.length) {
|
||||||
|
for(var i = a.length - 1; i >= 0; --i) if(a[i] != b[i]) return false;
|
||||||
|
return true;
|
||||||
|
} else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lab_parse(data) {
|
||||||
|
data = data.join('\n');
|
||||||
|
if(data == '\nNo Data Found') return [];
|
||||||
|
return data.split('\n===============================================================================\n \n').map(lab_parse1).filter(x => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lab_reparse_results(reports) {
|
||||||
|
var res = [], report, result;
|
||||||
|
for(var i = 0; i < reports.length; ++i) {
|
||||||
|
if((report = reports[i]).hasOwnProperty('results')) {
|
||||||
|
report = Object.assign({}, report);
|
||||||
|
var results = report.results;
|
||||||
|
delete report.results;
|
||||||
|
if(report.hasOwnProperty('comment')) delete report.comment;
|
||||||
|
for(var j = 0; j < results.length; ++j) res.push(result = Object.assign({}, report, results[j]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lab_parse1(data) {
|
||||||
|
if(data.startsWith('\n')) return lab_parse1default(data);
|
||||||
|
if(data.startsWith(' ---- MICROBIOLOGY ----\n')) return lab_parse1microbiology(data);
|
||||||
|
if(data.startsWith('Performing Lab Sites\n')) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lab_parse1default(data) {
|
||||||
|
var res = {}, m, x, line;
|
||||||
|
if(m = data.match(/^Report Released Date\/Time: (.*)/m)) res.time_released = new Date(m[1]); // 'Aug 24, 2022@07:15'
|
||||||
|
if(m = data.match(/^Provider: (.*)/m)) res.practitioner = m[1]; // 'BARGNES,VINCENT HARRY III'
|
||||||
|
if(m = data.match(/^ Specimen: (.*?)\.\s*(.*)/m)) {
|
||||||
|
res.specimen = m[1]; // 'SERUM'
|
||||||
|
res.accession = m[2]; // 'CH 0800 6706'
|
||||||
|
}
|
||||||
|
if(m = data.match(/^ Specimen Collection Date: (.*)/m)) res.time_collected = new Date(m[1]); // 'Aug 24, 2022'
|
||||||
|
data = data.split('\n Test name Result units Ref. range Site Code\n')[1].split('\n');
|
||||||
|
var results = res.results = [];
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
if((line = data[i]).startsWith('Comment: ')) {
|
||||||
|
res.comment = data.slice(i).join('\n').substring(9);
|
||||||
|
break;
|
||||||
|
} else if(line.startsWith(' Eval: ')) {
|
||||||
|
if(results.length > 0) {
|
||||||
|
x = results[results.length - 1];
|
||||||
|
if(x.comment) x.comment.push(line.substring(12));
|
||||||
|
else x.comment = [line.substring(12)];
|
||||||
|
} else console.log('DANGLING:', line);
|
||||||
|
} else if((line.startsWith('COVID-19 SCR (CEPHEID-RAPID)')) && (m = line.substring(28).match(/^(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.{16}) \[(?<site>\d+)\])?$/))) {
|
||||||
|
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
|
||||||
|
results.push(x = m.groups);
|
||||||
|
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
|
||||||
|
x.name = 'COVID-19 SCR (CEPHEID-RAPID)';
|
||||||
|
} else if(m = line.match(/^\b(?<name>.*?)\s{2,}(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.{16}) \[(?<site>\d+)\])?$/)) {
|
||||||
|
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
|
||||||
|
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(' [')) {
|
||||||
|
if(results.length > 0) results[results.length - 1].site = line.split('[')[1].split(']')[0]
|
||||||
|
else console.log('DANGLING:', line);
|
||||||
|
} else if(line.startsWith(' ')) {
|
||||||
|
if(results.length > 0) {
|
||||||
|
x = results[results.length - 1];
|
||||||
|
if(line.endsWith(']')) {
|
||||||
|
x.range = line.split('[')[0].replace(/^\s+|\s+$/g, '');
|
||||||
|
x.site = line.split('[')[1].split(']')[0];
|
||||||
|
} else x.range = line.replace(/^\s+|\s+$/g, '');
|
||||||
|
} else console.log('DANGLING:', line);
|
||||||
|
} else console.log('INVALID:', line);
|
||||||
|
}
|
||||||
|
for(var i = results.length - 1; i >= 0; --i) {
|
||||||
|
results[(x = results[i]).name] = x;
|
||||||
|
if(x.comment) x.comment = x.comment.join('\n');
|
||||||
|
}
|
||||||
|
if((res.accession.startsWith('HE ')) && ((results.hasOwnProperty('SEGS')) || (results.hasOwnProperty('BANDS')))) {
|
||||||
|
results.push(results['NEUTROPHIL%'] = {
|
||||||
|
name: 'NEUTROPHIL%', unit: '%', range: '42.2 - 75.2',
|
||||||
|
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#'] = {
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lab_parse1microbiology(data) {
|
||||||
|
var res = {}, lines = data.split('\n'), line, m;
|
||||||
|
var idx_body = lines.indexOf(' ');
|
||||||
|
for(var i = 0; i < lines.length; ++i) {
|
||||||
|
line = lines[i];
|
||||||
|
if(line.startsWith('Accession [UID]: ')) {
|
||||||
|
if(m = line.match(/^Accession \[UID\]: (?<accession>.*?) \[(?<accession_uid>\d+)\]/)) { // 'BCUL 22 819 [3922000819]'
|
||||||
|
res.accession = m.groups.accession;
|
||||||
|
res.accession_uid = m.groups.accession_uid;
|
||||||
|
}
|
||||||
|
if(m = line.match(/Received: (.*)$/)) res.time_received = new Date(m[1]); // 'Aug 01, 2022@11:57'
|
||||||
|
} else if(line.startsWith('Collection sample: ')) {
|
||||||
|
res.sample = line.substring(0, 39).substring(19).replace(/^\s+|\s+$/g, '');
|
||||||
|
res.time_collected = new Date(line.substring(39).split('Collection date: ')[1].replace(/^\s+|\s+$/g, ''));
|
||||||
|
} else if(line.startsWith('Site/Specimen: ')) {
|
||||||
|
res.specimen = line.substring(15).replace(/^\s+|\s+$/g, '');
|
||||||
|
} else if(line.startsWith('Provider: ')) {
|
||||||
|
res.practitioner = line.substring(10).replace(/^\s+|\s+$/g, '');
|
||||||
|
} else if(line.startsWith('Comment on specimen:')) {
|
||||||
|
res.comment = lines.slice(i, idx_body).join('\n').substring(20).replace(/^\s+|\s+$/g, '');
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var idx_footer = lines.indexOf('=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--')
|
||||||
|
if(idx_footer > idx_body) {
|
||||||
|
res.body = lines.slice(idx_body, idx_footer).join('\n').replace(/^\s+|\s+$/g, '');
|
||||||
|
res.footer = lines.slice(idx_footer + 1).join('\n').replace(/^\s+|\s+$/g, '');
|
||||||
|
} else res.body = lines.slice(idx_body).join('\n').replace(/^\s+|\s+$/g, '');
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measurement_parse(data) {
|
||||||
|
var extras = [];
|
||||||
|
var res = data.map(function(row) {
|
||||||
|
if(row.charAt(0) != ' ') {
|
||||||
|
var res = {}, idx = 0, value, m;
|
||||||
|
res.measurement_ien = row.substring(0, idx = row.indexOf('^'));
|
||||||
|
if(res.measurement_ien == '0') return; // '0^NO VITALS/MEASUREMENTS ENTERED WITHIN THIS PERIOD'
|
||||||
|
res.datetime = new Date(row.substring(idx + 1, idx = row.indexOf(' ', idx)));
|
||||||
|
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
|
||||||
|
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
|
||||||
|
res.user = row.substring(idx + 3);
|
||||||
|
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(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;
|
||||||
|
}
|
||||||
|
}).filter(x => x);
|
||||||
|
res.push(...extras);
|
||||||
|
return res;
|
||||||
|
}
|
45
htdocs/table-sticky.css
Normal file
45
htdocs/table-sticky.css
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
table.table-sticky {
|
||||||
|
white-space: nowrap;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
table.table-sticky thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 25vw;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
table.table-sticky td {
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table-sticky tbody th {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
table.table-sticky thead th:first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
table.table-sticky tbody th {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
table.table-sticky caption {
|
||||||
|
text-align: left;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="region"][tabindex] {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 98vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
[role="region"][tabindex]:focus {
|
||||||
|
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5);
|
||||||
|
outline: 0;
|
||||||
|
}
|
25
htdocs/userstyle.css
Normal file
25
htdocs/userstyle.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
table.table-data .abnormal_ref::after {
|
||||||
|
content: ' *';
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_ref_low::after {
|
||||||
|
content: ' L';
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_ref_high::after {
|
||||||
|
content: ' H';
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_iqr, table.table-data .abnormal_iqr_low.abnormal_iqr_high {
|
||||||
|
color: #f39a27;
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_iqr_low {
|
||||||
|
color: #976ed7;
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_iqr_high {
|
||||||
|
color: #c23b23;
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_ref, table.table-data .abnormal_iqr {
|
||||||
|
background-color: #fbffde;
|
||||||
|
}
|
||||||
|
table.table-data .abnormal_ref_low.abnormal_iqr_low, table.table-data .abnormal_ref_high.abnormal_iqr_high {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #ffd1d1;
|
||||||
|
}
|
79
htdocs/util.mjs
Normal file
79
htdocs/util.mjs
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
export function uniq(xs) {
|
||||||
|
var seen = {};
|
||||||
|
return xs.filter(x => seen.hasOwnProperty(x) ? false : (seen[x] = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupBy(xs, key) {
|
||||||
|
return xs.reduce(function(rv, x) {
|
||||||
|
var v = key instanceof Function ? key(x) : x[key];
|
||||||
|
(rv[v] = rv[v] || []).push(x);
|
||||||
|
return rv;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByArray(xs, key) {
|
||||||
|
var mapping = {};
|
||||||
|
return xs.reduce(function(rv, x) {
|
||||||
|
var v = key instanceof Function ? key(x) : x[key];
|
||||||
|
var el = mapping[v];
|
||||||
|
if(el) el.values.push(x);
|
||||||
|
else rv.push(mapping[v] = { key: v, values: [x] });
|
||||||
|
return rv;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pivotByArray(xs, key, reducer) {
|
||||||
|
var groups = groupByArray(xs, key);
|
||||||
|
groups.forEach(function(group) {
|
||||||
|
group.aggregate = group.values.reduce(reducer, {});
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quantile_sorted(arr_sorted, quantile) {
|
||||||
|
var pos = (arr_sorted.length - 1) * quantile, base = Math.floor(pos), rest = pos - base;
|
||||||
|
return arr_sorted[base + 1] !== undefined ? arr_sorted[base] + rest * (arr_sorted[base + 1] - arr_sorted[base]) : arr_sorted[base];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strHashCode(str) {
|
||||||
|
var hash = 0;
|
||||||
|
for(var i = 0; i < str.length; ++i) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
return hash & hash; // convert to 32 bit
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strHashJenkins(str) {
|
||||||
|
for(var hash = 0, i = str.length; i--;) hash += str.charCodeAt(i), hash += hash << 10, hash ^= hash >> 6;
|
||||||
|
hash += hash << 3;
|
||||||
|
hash ^= hash >> 11;
|
||||||
|
return (hash + (hash << 15) & 4294967295) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strHashHex(str) {
|
||||||
|
var hash = strHashJenkins(str), color = '#';
|
||||||
|
for(var i = 0; i < 3; ++i) color += ('00' + ((hash >> (i * 8)) & 0xFF).toString(16)).slice(-2);
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strHashHSL(str, lightness='50%') {
|
||||||
|
var hash = strHashJenkins(str);
|
||||||
|
return 'hsl(' + (hash%360) + ',' + (hash%100) + '%,' + lightness + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strftime_vista(date) {
|
||||||
|
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate() + date.getHours()/100 + date.getMinutes()/10000 + date.getSeconds()/1000000 + date.getMilliseconds()/1000000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strptime_vista(s) {
|
||||||
|
s = +s;
|
||||||
|
var date = Math.floor(s), time = s - date;
|
||||||
|
return new Date(Math.floor(date/10000) + 1700, (Math.floor(date/100) + '').slice(-2) - 1, (date + '').slice(-2), Math.floor(time*100), (Math.floor(time*10000) + '').slice(-2), (Math.floor(time*1000000) + '').slice(-2), (Math.floor(time*1000000000) + '').slice(-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce(fn, delay) {
|
||||||
|
var clock = null;
|
||||||
|
return function() {
|
||||||
|
clearTimeout(clock);
|
||||||
|
var self = this, args = arguments;
|
||||||
|
clock = setTimeout(function() { fn.apply(self, args) }, delay);
|
||||||
|
}
|
||||||
|
}
|
51
htdocs/vista.mjs
Normal file
51
htdocs/vista.mjs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
export async function connect(secret, host='vista.northport.med.va.gov', port=19209) {
|
||||||
|
return await (await fetch('/v1/vista', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ params: { secret: secret, host: host, port: port }, id: Date.now() })
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function call(cid, method, ...params) {
|
||||||
|
return await (await fetch('/v1/vista/' + cid, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ method: method, params: params, id: Date.now() })
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callctx(cid, context, method, ...params) {
|
||||||
|
return await (await fetch('/v1/vista/' + cid, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ method: method, params: params, context: context, id: Date.now() })
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverinfo(cid) {
|
||||||
|
return await (await fetch('/v1/vista/' + cid + '/serverinfo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}'
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userinfo(cid) {
|
||||||
|
return await (await fetch('/v1/vista/' + cid + '/userinfo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}'
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticate(cid, avcode=null) {
|
||||||
|
return await (await fetch('/v1/vista/' + cid + '/authenticate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ params: avcode ? { avcode } : {} })
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default window.vista = {
|
||||||
|
connect, call, callctx, serverinfo, userinfo, authenticate
|
||||||
|
};
|
171
htdocs/vistax.mjs
Normal file
171
htdocs/vistax.mjs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import vista from './vista.mjs';
|
||||||
|
import cookie from './cookie.mjs';
|
||||||
|
import { lab_parse, lab_reparse_results, measurement_parse } from './reportparser.mjs';
|
||||||
|
|
||||||
|
function RPCError(type, ...args) {
|
||||||
|
this.name = type;
|
||||||
|
this.message = args;
|
||||||
|
}
|
||||||
|
RPCError.prototype = Object.create(Error.prototype);
|
||||||
|
|
||||||
|
export function logged(fn, name) {
|
||||||
|
return async function(...args) {
|
||||||
|
var res = await fn(...args);
|
||||||
|
console.log(name, ...args, res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapped(fn) {
|
||||||
|
return async function(...args) {
|
||||||
|
var res = await fn(...args);
|
||||||
|
if(res.error) throw new RPCError(res.error.type, ...res.error.args);
|
||||||
|
else return res.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoized(fn) {
|
||||||
|
var cache = {};
|
||||||
|
return async function(...args) {
|
||||||
|
var key = JSON.stringify(args);
|
||||||
|
return cache.hasOwnProperty(key) ? cache[key] : (cache[key] = await fn(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caretseparated(fn, columns=null) {
|
||||||
|
return async function(...args) {
|
||||||
|
if(columns) return (await fn(...args)).map(function(row) {
|
||||||
|
row = row.split('^');
|
||||||
|
for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) row[columns[i]] = row[i];
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
else return (await fn(...args)).map(function(row) { return row.split('^'); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caretseparated1(fn, columns=null) {
|
||||||
|
return async function(...args) {
|
||||||
|
var res = (await fn(...args)).split('^');
|
||||||
|
if(columns) for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) res[columns[i]] = res[i];
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labreportparsed(fn) {
|
||||||
|
return async function(...args) {
|
||||||
|
return lab_parse(await fn(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tabulated(fn, mapping) {
|
||||||
|
return async function(...args) {
|
||||||
|
var res = (await fn(...args)).map(function(row) { return row.slice(); }), nrow = res.length;
|
||||||
|
for(var i = 0; i < nrow; ++i) {
|
||||||
|
var row = res[i], ncol = row.length;
|
||||||
|
for(var j = 0; j < ncol; ++j) if(mapping.hasOwnProperty(j)) row[mapping[j]] = row[j];
|
||||||
|
res.push()
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Client(cid, secret) {
|
||||||
|
var heartbeat = null;
|
||||||
|
|
||||||
|
this.secret = secret;
|
||||||
|
this.cid = cid;
|
||||||
|
|
||||||
|
this.call = (method, ...params) => vista.call(cid, method, ...params);
|
||||||
|
this.callctx = (context, method, ...params) => vista.callctx(cid, context, method, ...params);
|
||||||
|
this.heartbeat = async function(interval=null) {
|
||||||
|
if(!interval) interval = 0.45*1000*(await this.XWB_GET_BROKER_INFO())[0];
|
||||||
|
if(heartbeat) window.clearInterval(heartbeat);
|
||||||
|
this.XWB_IM_HERE();
|
||||||
|
return heartbeat = window.setInterval(this.XWB_IM_HERE, interval);
|
||||||
|
}
|
||||||
|
this.serverinfo = () => vista.serverinfo(cid);
|
||||||
|
this.userinfo = () => vista.userinfo(cid);
|
||||||
|
this.authenticate = (avcode=null) => vista.authenticate(cid, avcode);
|
||||||
|
|
||||||
|
this.XWB_IM_HERE = unwrapped(logged(() => vista.call(cid, 'XWB_IM_HERE'), 'XWB_IM_HERE'));
|
||||||
|
|
||||||
|
this.XUS_INTRO_MSG = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XUS_INTRO_MSG'), 'XUS_INTRO_MSG')));
|
||||||
|
this.XWB_GET_BROKER_INFO = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XWB_GET_BROKER_INFO'), 'XWB_GET_BROKER_INFO')));
|
||||||
|
this.XUS_GET_USER_INFO = memoized(unwrapped(logged(() => vista.call(cid, 'XUS_GET_USER_INFO'), 'XUS_GET_USER_INFO')));
|
||||||
|
|
||||||
|
this.SDEC_RESOURCE = memoized(unwrapped(logged(() => vista.callctx(cid, ['SDECRPC'], 'SDEC_RESOURCE'), 'SDEC_RESOURCE')));
|
||||||
|
this.SDEC_CLINLET = memoized(unwrapped(logged((...args) => vista.callctx(cid, ['SDECRPC'], 'SDEC_CLINLET', ...args), 'SDEC_CLINLET')));
|
||||||
|
|
||||||
|
this.ORWPT_FULLSSN = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_FULLSSN', ...args), 'ORWPT_FULLSSN')), ['dfn', 'name', 'date', 'pid']));
|
||||||
|
this.ORWPT_LAST5 = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_LAST5', ...args), 'ORWPT_LAST5')), ['dfn', 'name', 'date', 'pid']));
|
||||||
|
this.ORWPT_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_ID_INFO', ...args), 'ORWPT_ID_INFO')), ['pid', 'dob', 'sex', 'vet', 'sc_percentage', 'ward', 'room_bed', 'name']));
|
||||||
|
this.ORWPT16_LOOKUP = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT16_LOOKUP', ...args), 'ORWPT16_LOOKUP')), ['dfn', 'name', 'pid']));
|
||||||
|
this.ORWPT16_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT16_ID_INFO', ...args), 'ORWPT16_ID_INFO')), ['pid', 'dob', 'age', 'sex', 'sc_percentage', 'type', 'ward', 'room_bed', 'name']));
|
||||||
|
|
||||||
|
this.ORQQVI_VITALS = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORQQVI_VITALS', ...args), 'ORQQVI_VITALS')), ['measurement_ien', 'type', 'value', 'datetime', 'value_american', 'value_metric']));
|
||||||
|
this.ORQQVI_VITALS_FOR_DATE_RANGE = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORQQVI_VITALS_FOR_DATE_RANGE', ...args), 'ORQQVI_VITALS_FOR_DATE_RANGE')), ['measurement_ien', 'type', 'value', 'datetime']));
|
||||||
|
|
||||||
|
this.GMV_EXTRACT_REC = memoized(async (dfn, oredt, orsdt) => measurement_parse(await unwrapped(logged((...args0) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'GMV_EXTRACT_REC', args0.join('^')), 'GMV_EXTRACT_REC'))(dfn, oredt, '', orsdt)));
|
||||||
|
|
||||||
|
this.ORWLRR_INTERIM = memoized(labreportparsed(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWLRR_INTERIM', ...args), 'ORWLRR_INTERIM'))));
|
||||||
|
this.ORWLRR_INTERIM_RESULTS = memoized(async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args)));
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
Client._registry = {};
|
||||||
|
|
||||||
|
Client.fromID = function(cid, secret) {
|
||||||
|
if(Client._registry[cid]) return Client._registry[cid];
|
||||||
|
return Client._registry[cid] = new Client(cid, secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.fromScratch = async function(secret, host='vista.northport.med.va.gov', port=19209) {
|
||||||
|
var data = await vista.connect(secret, host, port);
|
||||||
|
if(data.result) return Client.fromID(data.result, secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.fromCookie = async function(secret, host='vista.northport.med.va.gov', port=19209) {
|
||||||
|
if(!secret) secret = cookie.get('secret');
|
||||||
|
if(secret) {
|
||||||
|
if(secret != cookie.get('secret')) {
|
||||||
|
console.log('Using new secret', secret);
|
||||||
|
var client = await Client.fromScratch(secret, host, port);
|
||||||
|
if(client) {
|
||||||
|
cookie.set('secret', secret);
|
||||||
|
cookie.set('cid', client.cid);
|
||||||
|
console.log('Established connection', client.cid);
|
||||||
|
return client;
|
||||||
|
} else {
|
||||||
|
cookie.reset('secret');
|
||||||
|
cookie.reset('cid');
|
||||||
|
console.log('Failed to connect');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if(!cookie.get('cid')) {
|
||||||
|
console.log('Using saved secret', secret);
|
||||||
|
var client = await Client.fromScratch(secret, host, port);
|
||||||
|
if(client) {
|
||||||
|
cookie.set('secret', secret);
|
||||||
|
cookie.set('cid', client.cid);
|
||||||
|
console.log('Established connection', client.cid);
|
||||||
|
return client;
|
||||||
|
} else {
|
||||||
|
cookie.reset('secret');
|
||||||
|
cookie.reset('cid');
|
||||||
|
console.log('Failed connection');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Using saved secret and connection', secret);
|
||||||
|
var cid = cookie.get('cid');
|
||||||
|
var client = Client.fromID(cid, secret);
|
||||||
|
if((await vista.call(cid, 'XWB_IM_HERE')).result == '1') return client;
|
||||||
|
cookie.reset('cid');
|
||||||
|
return await Client.fromCookie(secret, host, port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default window.vistax = {
|
||||||
|
RPCError, Client, connect: Client.fromCookie
|
||||||
|
};
|
147
main.py
Normal file
147
main.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from flask import Flask, request, send_from_directory
|
||||||
|
from flask.json import jsonify
|
||||||
|
from flask.json.provider import DefaultJSONProvider
|
||||||
|
|
||||||
|
import rpc
|
||||||
|
import util
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s %(name)-12s %(levelname)-8s %(message)s'))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
class JSONProviderX(DefaultJSONProvider):
|
||||||
|
@staticmethod
|
||||||
|
def default(obj):
|
||||||
|
return json.dumps(obj, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
class CacheProxyRPC(util.CacheProxy):
|
||||||
|
def __init__(self, obj, persistent=None, volatile=None, prefix=''):
|
||||||
|
util.CacheProxy.__init__(self, obj)
|
||||||
|
if persistent is None:
|
||||||
|
persistent = util.Store().memo
|
||||||
|
if volatile is None:
|
||||||
|
volatile = util.Store().memo
|
||||||
|
self._cache(('__call__', 'close', 'authenticate', 'keepalive', 'XWB_CREATE_CONTEXT', 'XWB_IM_HERE'), None)
|
||||||
|
self._cache(('SDEC_RESOURCE', 'ORWLRR_ALLTESTS_ALL'), persistent, prefix=prefix, ttl=float('inf'))
|
||||||
|
self._cache(('XWB_GET_BROKER_INFO', 'XUS_INTRO_MSG'), volatile, prefix=prefix, ttl=float('inf'))
|
||||||
|
self._cache(None, volatile, prefix=prefix, ttl=float('-inf'))
|
||||||
|
def _cache_persistent(self, persistent=None, prefix=''):
|
||||||
|
if persistent is None:
|
||||||
|
persistent = util.Store().memo
|
||||||
|
self._cache(('SDEC_RESOURCE', 'ORWLRR_ALLTESTS_ALL'), persistent, prefix=prefix, ttl=float('inf'))
|
||||||
|
|
||||||
|
def application():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.json = JSONProviderX(app)
|
||||||
|
|
||||||
|
app.secret = secret = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
||||||
|
clients = {}
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def cb_index():
|
||||||
|
return send_from_directory('./htdocs', 'index.html')
|
||||||
|
|
||||||
|
@app.post('/v1/vista')
|
||||||
|
def cb_connect():
|
||||||
|
params = request.json['params']
|
||||||
|
if params.get('secret') == secret:
|
||||||
|
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
||||||
|
while cid in clients:
|
||||||
|
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
||||||
|
clients[cid] = client = CacheProxyRPC(rpc.ClientSync(host=params.get('host', 'test.northport.med.va.gov'), port=int(params.get('port', 19009))))
|
||||||
|
return jsonify({ 'result': cid, 'error': None, 'id': request.json.get('id') })
|
||||||
|
else:
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
|
||||||
|
|
||||||
|
@app.post('/v1/vista/<cid>/serverinfo')
|
||||||
|
def cb_serverinfo(cid):
|
||||||
|
try:
|
||||||
|
client = clients[cid]
|
||||||
|
return jsonify({ 'result': client._obj._server._asdict() if client._obj._server else None, 'error': None, 'id': request.json.get('id') })
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
||||||
|
|
||||||
|
@app.post('/v1/vista/<cid>/userinfo')
|
||||||
|
def cb_userinfo(cid):
|
||||||
|
try:
|
||||||
|
client = clients[cid]
|
||||||
|
return jsonify({ 'result': client._obj._user, 'error': None, 'id': request.json.get('id') })
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
||||||
|
|
||||||
|
@app.post('/v1/vista/<cid>/authenticate')
|
||||||
|
def cb_authenticate(cid):
|
||||||
|
params = request.json['params']
|
||||||
|
try:
|
||||||
|
client = clients[cid]
|
||||||
|
if 'avcode' in params:
|
||||||
|
user = client.authenticate(params['avcode'])
|
||||||
|
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, 'error': None, 'id': request.json.get('id') })
|
||||||
|
else:
|
||||||
|
from auth import XUIAMSSOi_MySsoTokenVBA
|
||||||
|
if token := XUIAMSSOi_MySsoTokenVBA():
|
||||||
|
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, 'error': None, 'id': request.json.get('id') })
|
||||||
|
else:
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
||||||
|
|
||||||
|
@app.post('/v1/vista/<cid>')
|
||||||
|
def cb_call1(cid):
|
||||||
|
try:
|
||||||
|
client = clients[cid]
|
||||||
|
data = request.json
|
||||||
|
if 'context' in data:
|
||||||
|
return jsonify({ 'result': getattr(client, data['method'].upper())(*data.get('params', ()), context=data['context']), 'error': None, 'id': data.get('id') })
|
||||||
|
else:
|
||||||
|
return jsonify({ 'result': getattr(client, data['method'].upper())(*data.get('params', ())), 'error': None, 'id': data.get('id') })
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
||||||
|
|
||||||
|
@app.post('/v1/vista/<cid>/<method>')
|
||||||
|
def cb_call2(cid, method):
|
||||||
|
try:
|
||||||
|
client = clients[cid]
|
||||||
|
data = request.json
|
||||||
|
return jsonify({ 'result': getattr(client, method.upper())(*data.get('params', ())), 'error': None, 'id': data.get('id') })
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
||||||
|
|
||||||
|
@app.get('/<path:path>')
|
||||||
|
def cb_static(path):
|
||||||
|
return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html')
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def get_port():
|
||||||
|
import socket
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.bind(('localhost', 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import webbrowser
|
||||||
|
app = application()
|
||||||
|
port = get_port()
|
||||||
|
print(f'http://localhost:{port}/#{app.secret}')
|
||||||
|
webbrowser.open(f'http://localhost:{port}/#{app.secret}')
|
||||||
|
app.run(port=port)
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
flask[async]>=2.2,<2.3
|
233
rpc.py
Normal file
233
rpc.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import math
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
import warnings
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from XWBHash import encrypt0 as XWBHash_encrypt
|
||||||
|
|
||||||
|
from typing import Any, Union, Sequence
|
||||||
|
|
||||||
|
class RPCExc(Exception): pass
|
||||||
|
class RPCExcFormat(ValueError, RPCExc): pass
|
||||||
|
class RPCExcAuth(RPCExc): pass
|
||||||
|
class RPCExcServerError(RPCExc): pass
|
||||||
|
class RPCExcInvalidResult(RPCExc): pass
|
||||||
|
|
||||||
|
class MReference(str): pass
|
||||||
|
|
||||||
|
RecordServerInfo = namedtuple('RecordServerInfo', ('server', 'volume', 'uci', 'device', 'attempts', 'skip_signon_screen', 'domain', 'production'))
|
||||||
|
|
||||||
|
def s_pack(value: Any, encoding: str='latin-1'):
|
||||||
|
encoded = value.encode(encoding)
|
||||||
|
if len(encoded) <= 255:
|
||||||
|
return bytes((len(encoded),)) + encoded
|
||||||
|
raise ValueError('cannot s-pack string longer than 255 bytes: ' + repr(value))
|
||||||
|
|
||||||
|
def l_pack(value: Any, envelope: int=3, wrapped: bool=True, basictype=b'0', encoding: str='latin-1'):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in value.items())
|
||||||
|
return (b'2' + bare + b'f') if wrapped else bare
|
||||||
|
elif not isinstance(value, str) and hasattr(value, '__iter__'):
|
||||||
|
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in enumerate(value))
|
||||||
|
return (b'2' + bare + b'f') if wrapped else bare
|
||||||
|
elif isinstance(value, MReference):
|
||||||
|
return l_pack(str(value), envelope=envelope, basictype=b'1', encoding=encoding)
|
||||||
|
else:
|
||||||
|
encoded = str(value).encode(encoding)
|
||||||
|
if len(encoded) <= 10**envelope - 1:
|
||||||
|
bare = str(len(encoded)).zfill(envelope).encode(encoding) + encoded
|
||||||
|
return (basictype + bare + b'f') if wrapped else bare
|
||||||
|
raise ValueError(f'cannot l-pack string longer than {10**envelope - 1} bytes with an envelope of {envelope}: ' + repr(value))
|
||||||
|
|
||||||
|
def l_pack_maxlen(value: Any, encoding: str='latin-1'):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return max(max(l_pack_maxlen(k, encoding=encoding) for k in value.keys()), max(l_pack_maxlen(v, encoding=encoding) for v in value.values()))
|
||||||
|
elif not isinstance(value, str) and hasattr(value, '__iter__'):
|
||||||
|
return max(len(str(max(0, len(value) - 1))), max(l_pack_maxlen(v, encoding=encoding) for v in value))
|
||||||
|
else:
|
||||||
|
return len(str(value).encode(encoding))
|
||||||
|
|
||||||
|
def rpc_pack(name: str, *args: Any, command: bool=False, envelope: int=0, broker_version: str='XWB*1.1*65', encoding: str='latin-1'):
|
||||||
|
# protocol token [XWB]VTEX: [XWB] = NS broker [XWB], V = V 1, T = type 1, E = envelope size 3, X = XWBPRT 0
|
||||||
|
envelope = max(3, math.ceil(math.log10(max(1, max(l_pack_maxlen(arg, encoding=encoding) for arg in args)))) if envelope < 1 and len(args) else envelope)
|
||||||
|
return b'[XWB]11' + str(envelope).encode(encoding) + b'0' + (b'4' if command else (b'2' + s_pack(broker_version, encoding=encoding))) + s_pack(name, encoding=encoding) + b'5' + (b''.join(l_pack(arg, envelope=envelope, encoding=encoding) for arg in args) if len(args) > 0 else b'4f')
|
||||||
|
|
||||||
|
def rpc_unpack_result(data: str, encoding: str='latin-1'):
|
||||||
|
if data[:2] == b'\x00\x00':
|
||||||
|
if len(data) > 2 and data[2] == 0x18: # 0x18 is CAN
|
||||||
|
raise RPCExcServerError(data[3:].decode(encoding))
|
||||||
|
elif data[-1] == 0x1f: # 0x1f is US
|
||||||
|
return rpc_unpack_table(data[2:-1].decode(encoding).split('\x1e')) # 0x1e is RS
|
||||||
|
elif data[-2:] == b'\r\n':
|
||||||
|
return tuple(data[2:-2].decode(encoding).split('\r\n'))
|
||||||
|
else:
|
||||||
|
return data[2:].decode(encoding)
|
||||||
|
raise RPCExcFormat(data)
|
||||||
|
|
||||||
|
def rpc_unpack_table(rows: Sequence[str]):
|
||||||
|
# table: ROW\x1eROW\x1eROW\x1eROW\x1eROW\x1e\x1f; row: COL^COL^COL^COL^COL; header field: [IT]\d{5}.+
|
||||||
|
if len(rows) > 0 and len(hdr := rows[0]) > 0 and hdr[0] in ('I', 'T') and hdr[1:6].isdecimal():
|
||||||
|
header = [field[6:] for field in rows[0].split('^')]
|
||||||
|
return tuple(dict(zip(header, row.split('^'))) for row in rows[1:] if len(row) > 0)
|
||||||
|
else:
|
||||||
|
return tuple(tuple(row.split('^')) for row in rows if len(row) > 0)
|
||||||
|
|
||||||
|
def send_rpc_msg(sock: socket.socket, msg: bytes, end: bytes=b'\x04'):
|
||||||
|
sock.send(msg + end)
|
||||||
|
|
||||||
|
def recv_rpc_msg(sock: socket.socket, end: bytes=b'\x04', minsz: int=1024, maxsz: int=32768): # 0x04 is EOT
|
||||||
|
buf = b''
|
||||||
|
bufsz = minsz
|
||||||
|
while True:
|
||||||
|
if len(data := sock.recv(bufsz)) > 0:
|
||||||
|
buf += data
|
||||||
|
while (idx := buf.find(end)) >= 0:
|
||||||
|
if idx > 0:
|
||||||
|
yield buf[:idx]
|
||||||
|
bufsz = minsz
|
||||||
|
elif bufsz < maxsz:
|
||||||
|
bufsz = _x if (_x := bufsz << 1) < maxsz else maxsz
|
||||||
|
buf = buf[idx + 1:]
|
||||||
|
|
||||||
|
class ClientSync(object):
|
||||||
|
def __init__(self, host: str, port: int, TCPConnect: bool=True):
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.sock.connect((host, port))
|
||||||
|
self.recv_rpc_msg = recv_rpc_msg(self.sock)
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self._server = self._user = None
|
||||||
|
self.context = 'XUS SIGNON'
|
||||||
|
if TCPConnect and (res := self.TCPConnect(self.sock.getsockname()[0], '0', socket.gethostname())) != 'accept':
|
||||||
|
raise RPCExcInvalidResult('TCPConnect', self.sock.getsockname()[0], '0', socket.gethostname(), res)
|
||||||
|
def __call__(self, name: str, *args: Any, command: bool=False, envelope: int=0, context: Union[bool, None]=None, encoding='latin-1'):
|
||||||
|
name = name.replace('_', ' ')
|
||||||
|
with self.lock:
|
||||||
|
if name != 'XWB CREATE CONTEXT' and context and len(context) > 0 and self.context not in context:
|
||||||
|
send_rpc_msg(self.sock, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
|
||||||
|
if (res := rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)) != '1':
|
||||||
|
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
|
||||||
|
self.context = context
|
||||||
|
send_rpc_msg(self.sock, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
|
||||||
|
return rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)
|
||||||
|
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
|
||||||
|
command = key in commands
|
||||||
|
setattr(self, key, (thunk := lambda *args, **kw: self(key, *args, **kw, command=command)))
|
||||||
|
return thunk
|
||||||
|
def __del__(self):
|
||||||
|
if isinstance(getattr(self, 'sock'), socket.socket):
|
||||||
|
self('#BYE#', command=True)
|
||||||
|
self.sock.close()
|
||||||
|
def close(self):
|
||||||
|
if (res := self('#BYE#', command=True)) != '#BYE#':
|
||||||
|
warnings.warn(f'RPC #BYE# returned {repr(res)} instead of \'#BYE#\'')
|
||||||
|
self.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
self.sock.close()
|
||||||
|
self.sock = self.recv_rpc_msg = None
|
||||||
|
return res
|
||||||
|
def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
||||||
|
self._server = RecordServerInfo(*self('XUS SIGNON SETUP', '', '1', context=context))
|
||||||
|
res = self('XUS AV CODE', XWBHash_encrypt(identity))
|
||||||
|
if res[0] == '0' or res[2] != '0':
|
||||||
|
raise RPCExcAuth(res[3], res)
|
||||||
|
self._user = res
|
||||||
|
return res
|
||||||
|
def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
|
||||||
|
import time
|
||||||
|
interval = interval or 0.45*float(self.XWB_GET_BROKER_INFO(context=context)[0])
|
||||||
|
while True:
|
||||||
|
time.sleep(interval)
|
||||||
|
self.XWB_IM_HERE()
|
||||||
|
|
||||||
|
async def asend_rpc_msg(writer: asyncio.StreamWriter, msg: bytes, end: bytes=b'\x04'):
|
||||||
|
writer.write(msg + end)
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
async def arecv_rpc_msg(reader: asyncio.StreamReader, end: bytes=b'\x04', minsz: int=1024, maxsz: int=32768): # \x04 is EOT
|
||||||
|
buf = b''
|
||||||
|
bufsz = minsz
|
||||||
|
while True:
|
||||||
|
if len(data := await reader.read(bufsz)) > 0:
|
||||||
|
buf += data
|
||||||
|
while (idx := buf.find(end)) >= 0:
|
||||||
|
if idx > 0:
|
||||||
|
yield buf[:idx]
|
||||||
|
bufsz = minsz
|
||||||
|
elif bufsz < maxsz:
|
||||||
|
bufsz = _x if (_x := bufsz << 1) < maxsz else maxsz
|
||||||
|
buf = buf[idx + 1:]
|
||||||
|
else:
|
||||||
|
raise ConnectionAbortedError
|
||||||
|
|
||||||
|
class ClientAsync(object):
|
||||||
|
async def __new__(cls, *args, **kw):
|
||||||
|
await (self := super(ClientAsync, cls).__new__(cls)).__init__(*args, **kw)
|
||||||
|
return self
|
||||||
|
async def __init__(self, host: str, port: int, TCPConnect: bool=True):
|
||||||
|
self.reader, self.writer = await asyncio.open_connection(host, port)
|
||||||
|
self.arecv_rpc_msg = arecv_rpc_msg(self.reader)
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self._server = self._user = None
|
||||||
|
self.context = 'XUS SIGNON'
|
||||||
|
if TCPConnect and (res := await self.TCPConnect(self.writer.get_extra_info('sockname')[0], '0', socket.gethostname())) != 'accept':
|
||||||
|
raise RPCExcInvalidResult('TCPConnect', self.writer.get_extra_info('sockname')[0], '0', socket.gethostname(), res)
|
||||||
|
async def __call__(self, name: str, *args: Any, command: bool=False, envelope: int=0, context: Union[Sequence, None]=None, encoding='latin-1'):
|
||||||
|
name = name.replace('_', ' ')
|
||||||
|
async with self.lock:
|
||||||
|
if name != 'XWB CREATE CONTEXT' and context and len(context) > 0 and self.context not in context:
|
||||||
|
await asend_rpc_msg(self.writer, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
|
||||||
|
if (res := rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)) != '1':
|
||||||
|
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
|
||||||
|
self.context = context
|
||||||
|
await asend_rpc_msg(self.writer, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
|
||||||
|
return rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)
|
||||||
|
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
|
||||||
|
command = key in commands
|
||||||
|
async def thunk(*args, **kw):
|
||||||
|
return await self(key, *args, **kw, command=command)
|
||||||
|
setattr(self, key, thunk)
|
||||||
|
return thunk
|
||||||
|
def __del__(self):
|
||||||
|
if isinstance(getattr(self, 'writer'), asyncio.StreamWriter):
|
||||||
|
try:
|
||||||
|
self.writer.close()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
self.reader = self.writer = None
|
||||||
|
async def close(self):
|
||||||
|
if (res := await self('#BYE#', command=True)) != '#BYE#':
|
||||||
|
warnings.warn(f'RPC #BYE# returned {repr(res)} instead of \'#BYE#\'')
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
|
self.reader = self.writer = None
|
||||||
|
return res
|
||||||
|
async def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
||||||
|
self._server = RecordServerInfo(*await self('XUS SIGNON SETUP', '', '1', context=context))
|
||||||
|
res = await self('XUS AV CODE', XWBHash_encrypt(identity))
|
||||||
|
if res[0] == '0' or res[2] != '0':
|
||||||
|
raise RPCExcAuth(res[3], res)
|
||||||
|
self._user = res
|
||||||
|
return res
|
||||||
|
async def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
|
||||||
|
interval = interval or 0.45*float((await self.XWB_GET_BROKER_INFO(context=context))[0])
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
await self.XWB_IM_HERE()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import getpass, code
|
||||||
|
from auth import XUIAMSSOi_MySsoTokenVBA
|
||||||
|
|
||||||
|
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():
|
||||||
|
print('authenticate', repr(client.authenticate(token)))
|
||||||
|
else:
|
||||||
|
print('authenticate', repr(client.authenticate(f"{getpass.getpass('ACCESS CODE: ')};{getpass.getpass('VERIFY CODE: ')}")))
|
||||||
|
|
||||||
|
code.interact(local=globals())
|
2
run.cmd
Normal file
2
run.cmd
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
py -3-32 -m pip install -r requirements.txt
|
||||||
|
py -3-32 main.py
|
238
util.py
Normal file
238
util.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import itertools
|
||||||
|
import sqlite3
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cPickle import dumps, loads
|
||||||
|
except ImportError:
|
||||||
|
from pickle import dumps, loads
|
||||||
|
|
||||||
|
from typing import Any, Union, AsyncGenerator, Iterable, Tuple, Callable
|
||||||
|
|
||||||
|
class Store(object):
|
||||||
|
def __init__(self, database: Union[sqlite3.Connection, str]=':memory:', synchronous: bool=None, journal_mode: bool=None, default_factory: Union[Callable, None]=None):
|
||||||
|
self._db = database if isinstance(database, sqlite3.Connection) else sqlite3.connect(database, check_same_thread=False)
|
||||||
|
if synchronous:
|
||||||
|
self._db.execute(f'PRAGMA synchronous = {synchronous}')
|
||||||
|
if journal_mode:
|
||||||
|
self._db.execute(f'PRAGMA journal_mode = {journal_mode}')
|
||||||
|
self._mappings = WeakValueDictionary()
|
||||||
|
self._default_factory = default_factory
|
||||||
|
self.execute = self._db.execute
|
||||||
|
self.commit = self._db.commit
|
||||||
|
self.__enter__ = self._db.__enter__
|
||||||
|
self.__exit__ = self._db.__exit__
|
||||||
|
def __getitem__(self, key: str) -> 'Mapping':
|
||||||
|
if key not in self._mappings:
|
||||||
|
self._mappings[key] = res = Mapping(database=self, table=key)
|
||||||
|
return self._mappings[key]
|
||||||
|
def __delitem__(self, key: str):
|
||||||
|
with self._db:
|
||||||
|
self._db.execute(f'DROP TABLE "{key}"')
|
||||||
|
del self._mappings[key]
|
||||||
|
__getattr__ = __getitem__; __delattr__ = __delitem__
|
||||||
|
|
||||||
|
class Mapping(object):
|
||||||
|
def __init__(self, database: Union[Store, sqlite3.Connection, str]=':memory:', table: str='store'):
|
||||||
|
self._store = database if isinstance(database, Store) else Store(database)
|
||||||
|
self._tbl = table
|
||||||
|
self.commit = self._store.commit
|
||||||
|
with self._store._db:
|
||||||
|
self._store.execute(f'CREATE TABLE IF NOT EXISTS "{self._tbl}" (key TEXT PRIMARY KEY, value BLOB, ts FLOAT)')
|
||||||
|
self._store.execute(f'CREATE INDEX IF NOT EXISTS "{self._tbl}_ts" ON "{self._tbl}" (ts)')
|
||||||
|
def __enter__(self):
|
||||||
|
return self._store.__enter__()
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
return self._store.__exit__(exc_type, exc_val, exc_tb)
|
||||||
|
def count(self, ttl: float=float('inf'), now: float=0) -> int:
|
||||||
|
return self._store.execute(f'SELECT COUNT(*) FROM "{self._tbl}" WHERE ts > ?', ((now or time.time()) - ttl,)).fetchone()[0]
|
||||||
|
def has(self, key: str, ttl: float=float('inf'), now: float=0) -> bool:
|
||||||
|
for row in self._store.execute(f'SELECT 1 FROM "{self._tbl}" WHERE key = ? AND ts > ? LIMIT 1', (key, (now or time.time()) - ttl)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
def get(self, key: Union[str, slice], ttl: float=float('inf'), now: float=0, **kw) -> Any:
|
||||||
|
if isinstance(key, slice):
|
||||||
|
key, ttl, now = key.start, key.stop, key.step
|
||||||
|
for row in self._store.execute(f'SELECT value FROM "{self._tbl}" WHERE key = ? AND ts > ? LIMIT 1', (key, (now or time.time()) - ttl)):
|
||||||
|
return loads(row[0])
|
||||||
|
if 'default' in kw:
|
||||||
|
return kw['default']
|
||||||
|
elif self._store._default_factory is not None:
|
||||||
|
return self.set(key, self._store._default_factory(), now=(now or time.time()))
|
||||||
|
raise KeyError(key)
|
||||||
|
def set(self, key: str, value: Any, now: float=0, commit: bool=False) -> Any:
|
||||||
|
self._store.execute(f'REPLACE INTO "{self._tbl}" (key, value, ts) VALUES (?, ?, ?)', (key, dumps(value), now or time.time()))
|
||||||
|
if commit:
|
||||||
|
self._store.commit()
|
||||||
|
return value
|
||||||
|
def remove(self, key: str, commit: bool=False) -> None:
|
||||||
|
self._store.execute(f'DELETE FROM "{self._tbl}" WHERE key = ?', (key,))
|
||||||
|
if commit:
|
||||||
|
self._store.commit()
|
||||||
|
def keys(self, ttl: float=float('inf'), now: float=0) -> Iterable[str]:
|
||||||
|
return (row[0] for row in self._store.execute(f'SELECT key FROM "{self._tbl}" WHERE ts > ? ORDER BY rowid', ((now or time.time()) - ttl,)))
|
||||||
|
def values(self, ttl: float=float('inf'), now: float=0) -> Iterable:
|
||||||
|
return (loads(row[0]) for row in self._store.execute(f'SELECT value FROM "{self._tbl}" WHERE ts > ? ORDER BY rowid', ((now or time.time()) - ttl,)))
|
||||||
|
def items(self, ttl: float=float('inf'), now: float=0) -> Iterable[Tuple[str, Any]]:
|
||||||
|
return ((row[0], loads(row[1])) for row in self._store.execute(f'SELECT key, value FROM "{self._tbl}" WHERE ts > ? ORDER BY rowid', ((now or time.time()) - ttl,)))
|
||||||
|
def clear(self, ttl: float=0, now: float=0, commit: bool=False) -> None:
|
||||||
|
self._store.execute(f'DELETE FROM "{self._tbl}" WHERE ts <= ?', ((now or time.time()) - ttl,)) if ttl > 0 else self._store.execute(f'DELETE FROM "{self._tbl}"')
|
||||||
|
if commit:
|
||||||
|
self._store.commit()
|
||||||
|
__len__ = count; __contains__ = has; __getitem__ = get; __setitem__ = set; __delitem__ = remove; __iter__ = keys
|
||||||
|
|
||||||
|
class CacheProxy(object):
|
||||||
|
def __init__(self, obj):
|
||||||
|
self._obj = obj
|
||||||
|
self._mapping = {}
|
||||||
|
def _cache(self, key, cache, prefix='', ttl=0):
|
||||||
|
if key is None or isinstance(key, str):
|
||||||
|
self._mapping[key] = (cache, prefix, ttl)
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
delattr(self, key)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
for k in key:
|
||||||
|
self._cache(k, cache, prefix, ttl)
|
||||||
|
return self
|
||||||
|
def __getattr__(self, key):
|
||||||
|
if key in self._mapping:
|
||||||
|
cache, prefix, ttl = self._mapping[key]
|
||||||
|
elif None in self._mapping:
|
||||||
|
cache, prefix, ttl = self._mapping[None]
|
||||||
|
else:
|
||||||
|
return getattr(self._obj, key)
|
||||||
|
if cache is None:
|
||||||
|
return getattr(self._obj, key)
|
||||||
|
if asyncio.iscoroutinefunction(value := getattr(self._obj, key)):
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
async def fetch(*args, _cache_key, **kw):
|
||||||
|
async with lock:
|
||||||
|
with cache:
|
||||||
|
res = cache[_cache_key] = await value(*args, **kw)
|
||||||
|
return res
|
||||||
|
async def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=True, **kw):
|
||||||
|
_cache_key = prefix + key + repr(args) + repr(kw)
|
||||||
|
try:
|
||||||
|
return cache[_cache_key:_cache_ttl]
|
||||||
|
except KeyError:
|
||||||
|
kw['_cache_key'] = _cache_key
|
||||||
|
if _cache_stale and cache.has(_cache_key):
|
||||||
|
asyncio.ensure_future(fetch(*args, **kw))
|
||||||
|
return cache[_cache_key]
|
||||||
|
return await fetch(*args, **kw)
|
||||||
|
elif callable(value):
|
||||||
|
lock = threading.Lock()
|
||||||
|
def fetch(*args, _cache_key, **kw):
|
||||||
|
with lock, cache:
|
||||||
|
res = cache[_cache_key] = value(*args, **kw)
|
||||||
|
return res
|
||||||
|
def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=True, **kw):
|
||||||
|
_cache_key = prefix + key + repr(args) + repr(kw)
|
||||||
|
try:
|
||||||
|
return cache[_cache_key:_cache_ttl]
|
||||||
|
except KeyError:
|
||||||
|
kw['_cache_key'] = _cache_key
|
||||||
|
if _cache_stale and cache.has(_cache_key):
|
||||||
|
threading.Thread(target=fetch, args=args, kwargs=kw).start()
|
||||||
|
return cache[_cache_key]
|
||||||
|
return fetch(*args, **kw)
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
setattr(self, key, thunk)
|
||||||
|
return thunk
|
||||||
|
|
||||||
|
class SyncProxy(object):
|
||||||
|
def __init__(self, obj, loop=None):
|
||||||
|
self._obj = obj
|
||||||
|
self._loop = loop or asyncio.get_event_loop()
|
||||||
|
def __getattr__(self, key):
|
||||||
|
if asyncio.iscoroutinefunction(value := getattr(self._obj, key)):
|
||||||
|
setattr(self, key, (thunk := lambda *args, **kw: asyncio.run_coroutine_threadsafe(value(*args, **kw), loop=self._loop).result()))
|
||||||
|
return thunk
|
||||||
|
elif callable(value):
|
||||||
|
setattr(self, key, value)
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
re_dt_fileman = r'(?P<dt_fileman>(\d{3})(\d{2})(\d{2})(?:\.(\d{2})?(\d{2})?(\d{2})?)?)' # George Timson's format
|
||||||
|
re_dt_today = r'(?P<dt_today>T)' # today
|
||||||
|
re_dt_now = r'(?P<dt_now>N)' # now
|
||||||
|
re_dt_mdy = r'(?P<dt_mdy>(\d{1,2})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # m/d/yy, m/d/yyyy
|
||||||
|
re_dt_ymd = r'(?P<dt_ymd>(\d{4})[^\w@?]+(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # yyyy/m/d
|
||||||
|
re_dt_yyyymmdd = r'(?P<dt_yyyymmdd>(\d{4})(\d{2})(\d{2}))' # yyyymmdd
|
||||||
|
re_dt_Mdy = r'(?P<dt_Mdy>([A-Z]{3,})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # M/d/yy, M/d/yyyy
|
||||||
|
re_dt_dMy = r'(?P<dt_dMy>((\d{1,2})[^\w@?]+[A-Z]{3,})[^\w@?]+(\d{4}|\d{2})\s*)' # d/M/yy, d/M/yyyy
|
||||||
|
re_dt_md = r'(?P<dt_md>(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # m/d
|
||||||
|
re_dt_offset = r'(?P<offset>([-+]\d+)(H|W|M)?)' # +#U
|
||||||
|
re_dt_time = r'(?:@?(?P<time>(\d{1,2}):?(\d{1,2})))' # time
|
||||||
|
re_dt_ext = r'(?P<ext>[<>])' # (nonstandard extension)
|
||||||
|
rx_dt = re.compile(f'^{re_dt_fileman}|(?:(?:{re_dt_today}|{re_dt_now}|{re_dt_mdy}|{re_dt_ymd}|{re_dt_yyyymmdd}|{re_dt_Mdy}|{re_dt_dMy}|{re_dt_md})?{re_dt_offset}?{re_dt_time}?{re_dt_ext}?)$', re.IGNORECASE)
|
||||||
|
def vista_strptime(s: str) -> datetime.datetime:
|
||||||
|
"""Parse VistA-style datetime strings into Python datetime.datetime objects"""
|
||||||
|
if m := rx_dt.match(s.strip().lower()):
|
||||||
|
m = m.groupdict()
|
||||||
|
if m['dt_fileman']:
|
||||||
|
m1 = re.match(re_dt_fileman, m['dt_fileman'])
|
||||||
|
return datetime.datetime(year=1700 + int(m1.group(2)), month=int(m1.group(3)), day=int(m1.group(4)), hour=int(m1.group(5) or 0), minute=int(m1.group(6) or 0), second=int(m1.group(7) or 0))
|
||||||
|
date = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if m['dt_today']: pass
|
||||||
|
elif m['dt_now']: date = datetime.datetime.now()
|
||||||
|
elif m['dt_mdy']: date = date.replace(month=int((m1 := re.match(re_dt_mdy, m['dt_mdy'], flags=re.I)).group(2)), day=int(m1.group(3)), year=vista_strptime_year(int(m1.group(4)), date))
|
||||||
|
elif m['dt_ymd']: date = date.replace(year=int((m1 := re.match(re_dt_ymd, m['dt_ymd'], flags=re.I)).group(2)), month=int(m1.group(3)), day=int(m1.group(4)))
|
||||||
|
elif m['dt_yyyymmdd']: date = date.replace(year=int((m1 := re.match(re_dt_yyyymmdd, m['dt_yyyymmdd'], flags=re.I)).group(2)), month=int(m1.group(3)), day=int(m1.group(4)))
|
||||||
|
elif m['dt_Mdy']: date = date.replace(month=vista_strptime_month((m1 := re.match(re_dt_Mdy, m['dt_Mdy'], flags=re.I)).group(2)), day=int(m1.group(3)), year=vista_strptime_year(int(m1.group(4)), date))
|
||||||
|
elif m['dt_dMy']: date = date.replace(day=int((m1 := re.match(re_dt_dMy, m['dt_dMy'], flags=re.I)).group(2)), month=vista_strptime_month(m1.group(3)), year=vista_strptime_year(int(m1.group(4)), date))
|
||||||
|
elif m['dt_md']: date = date.replace(month=int((m1 := re.match(re_dt_md, m['dt_md'], flags=re.I)).group(2)), day=int(m1.group(3)))
|
||||||
|
time = datetime.time()
|
||||||
|
if m['time']:
|
||||||
|
if m['dt_now']:
|
||||||
|
raise ValueError('cannot specify time with N or H offset')
|
||||||
|
m1 = re.match(re_dt_time, m['time'], flags=re.I)
|
||||||
|
date = date.replace(hour=int(m1.group(2)), minute=int(m1.group(3)))
|
||||||
|
if m['offset']:
|
||||||
|
m1 = re.match(re_dt_offset, m['offset'], flags=re.I)
|
||||||
|
if (offset_unit := m1.group(3)) == 'h' and (m['time'] or m['dt_today']):
|
||||||
|
raise ValueError('cannot specify time or T with H offset')
|
||||||
|
date = vista_strptime_offset(date, int(m1.group(2)), offset_unit or 'd')
|
||||||
|
if m['ext']:
|
||||||
|
if m['ext'] == '<':
|
||||||
|
date = date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
elif m['ext'] == '>':
|
||||||
|
date = date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
if date.year < 1800:
|
||||||
|
raise ValueError('cannot specify year before 1800')
|
||||||
|
return date
|
||||||
|
else:
|
||||||
|
raise ValueError(f'invalid date/time {s}')
|
||||||
|
def vista_strptime_year(y: int, today: datetime.datetime) -> int:
|
||||||
|
"""Promote years to 4 digits"""
|
||||||
|
return y if y >= 1000 else y2000 if (y2000 := y + 2000) < today.year + 20 else y + 1900
|
||||||
|
def vista_strptime_month(m: str, mapping: dict={'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12}) -> int:
|
||||||
|
"""Convert en-US month names to integers"""
|
||||||
|
return mapping[m[:3]]
|
||||||
|
def vista_strptime_offset(base: datetime.datetime, offset: int, suffix: str, mapping: dict={'h': 'hours', 'd': 'days', 'w': 'weeks', 'm': 'months'}) -> datetime.datetime:
|
||||||
|
"""Apply datetime offset"""
|
||||||
|
return (base + datetime.timedelta(**{mapping[suffix]: offset})) if suffix != 'm' else base.replace(month=month) if (month := base.month + offset) <= 12 else base.replace(month=month%12, year=base.year + month//12)
|
||||||
|
def vista_strftime(date: Union[datetime.datetime, datetime.date]) -> str:
|
||||||
|
"""Convert Python datetime.datetime objects into conventional FileMan/Timson format"""
|
||||||
|
return f'{date.year - 1700:03}{date.month:02}{date.day:02}' if isinstance(date, datetime.date) else f'{date.year - 1700:03}{date.month:02}{date.day:02}.{date.hour:02}{date.minute:02}{date.second:02}'
|
||||||
|
def vista_datefloat(date: Union[datetime.datetime, datetime.date]) -> float:
|
||||||
|
"""Convert Python datetime.datetime objects into floating point FileMan/Timson format"""
|
||||||
|
res = 10000*(date.year - 1700) + 100*date.month + date.day
|
||||||
|
return (res + (time := date.time()).hour/100 + time.minute/10000 + time.second/1000000 + time.microsecond/1000000000000) if isinstance(date, datetime.datetime) else res
|
||||||
|
|
||||||
|
def validate(s):
|
||||||
|
for c in s:
|
||||||
|
if (not 32 <= (x := ord(c)) <= 126) or x == 94:
|
||||||
|
raise ValueError(f'{repr(c)} not allowed in string')
|
Loading…
Reference in New Issue
Block a user