This commit is contained in:
Jiang Yio 2022-09-22 07:10:08 -04:00
parent 2028b35226
commit 7f5ed98386
33 changed files with 2611 additions and 1 deletions

3
.gitignore vendored
View File

@ -292,3 +292,6 @@ dist
.yarn/install-state.gz
.pnp.*
# Application-specific
*.db*

View File

@ -1,6 +1,6 @@
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:

48
XWBHash.py Normal file
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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>nuVistA</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>

View 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>

View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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>nuVistA</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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
flask[async]>=2.2,<2.3

233
rpc.py Normal file
View 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
View File

@ -0,0 +1,2 @@
py -3-32 -m pip install -r requirements.txt
py -3-32 main.py

238
util.py Normal file
View 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')