diff --git a/.gitignore b/.gitignore index 1dd9dcb..8ef0880 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,6 @@ dist .yarn/install-state.gz .pnp.* +# Application-specific +*.db* + diff --git a/LICENSE b/LICENSE index 2071b23..fd236fe 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +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: diff --git a/XWBHash.py b/XWBHash.py new file mode 100644 index 0000000..8cc8c40 --- /dev/null +++ b/XWBHash.py @@ -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_>UDKb7H=CT8S!', + 'NZW:1}K$byP;jk)7\'`x90B|cq@iSsEnu,(l-hf.&Y_?J#R]+voQXU8mrV[!p4tg~OMez CAaGFD6H53%L/dT2<*>"{\\wI=', + 'vCiJ[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\'?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~hanG', + 'xVa1\']_GU#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_y|m};d)-7DZ"Fe/Y9 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] diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..83e92bb --- /dev/null +++ b/auth.py @@ -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) diff --git a/htdocs/App.vue b/htdocs/App.vue new file mode 100644 index 0000000..9eae57f --- /dev/null +++ b/htdocs/App.vue @@ -0,0 +1,52 @@ + + + diff --git a/htdocs/Autocomplete.vue b/htdocs/Autocomplete.vue new file mode 100644 index 0000000..ca41a23 --- /dev/null +++ b/htdocs/Autocomplete.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/htdocs/DateRangePicker.vue b/htdocs/DateRangePicker.vue new file mode 100644 index 0000000..72c7050 --- /dev/null +++ b/htdocs/DateRangePicker.vue @@ -0,0 +1,154 @@ + + + diff --git a/htdocs/DateRangePickerRange.vue b/htdocs/DateRangePickerRange.vue new file mode 100644 index 0000000..05062fa --- /dev/null +++ b/htdocs/DateRangePickerRange.vue @@ -0,0 +1,39 @@ + + + diff --git a/htdocs/Login.vue b/htdocs/Login.vue new file mode 100644 index 0000000..f528335 --- /dev/null +++ b/htdocs/Login.vue @@ -0,0 +1,75 @@ + + + diff --git a/htdocs/Navbar.vue b/htdocs/Navbar.vue new file mode 100644 index 0000000..eea1cb2 --- /dev/null +++ b/htdocs/Navbar.vue @@ -0,0 +1,43 @@ + + + diff --git a/htdocs/RoutePatientDetail.vue b/htdocs/RoutePatientDetail.vue new file mode 100644 index 0000000..1124731 --- /dev/null +++ b/htdocs/RoutePatientDetail.vue @@ -0,0 +1,80 @@ + + + diff --git a/htdocs/RoutePatientLookup.vue b/htdocs/RoutePatientLookup.vue new file mode 100644 index 0000000..68a051e --- /dev/null +++ b/htdocs/RoutePatientLookup.vue @@ -0,0 +1,32 @@ + + + diff --git a/htdocs/RouteSchedule.vue b/htdocs/RouteSchedule.vue new file mode 100644 index 0000000..5d22ed5 --- /dev/null +++ b/htdocs/RouteSchedule.vue @@ -0,0 +1,59 @@ + + + diff --git a/htdocs/ViewData.vue b/htdocs/ViewData.vue new file mode 100644 index 0000000..fc5e6b0 --- /dev/null +++ b/htdocs/ViewData.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/htdocs/ViewLabs.vue b/htdocs/ViewLabs.vue new file mode 100644 index 0000000..7b8c054 --- /dev/null +++ b/htdocs/ViewLabs.vue @@ -0,0 +1,50 @@ + + + diff --git a/htdocs/ViewPatientLookup.vue b/htdocs/ViewPatientLookup.vue new file mode 100644 index 0000000..bc7e919 --- /dev/null +++ b/htdocs/ViewPatientLookup.vue @@ -0,0 +1,57 @@ + + + diff --git a/htdocs/ViewResourceLookup.vue b/htdocs/ViewResourceLookup.vue new file mode 100644 index 0000000..0abead8 --- /dev/null +++ b/htdocs/ViewResourceLookup.vue @@ -0,0 +1,101 @@ + + + diff --git a/htdocs/ViewSchedule.vue b/htdocs/ViewSchedule.vue new file mode 100644 index 0000000..26fda93 --- /dev/null +++ b/htdocs/ViewSchedule.vue @@ -0,0 +1,89 @@ + + + diff --git a/htdocs/ViewVitals.vue b/htdocs/ViewVitals.vue new file mode 100644 index 0000000..82697ff --- /dev/null +++ b/htdocs/ViewVitals.vue @@ -0,0 +1,57 @@ + + + diff --git a/htdocs/ViewVitalsLabs.vue b/htdocs/ViewVitalsLabs.vue new file mode 100644 index 0000000..93baae5 --- /dev/null +++ b/htdocs/ViewVitalsLabs.vue @@ -0,0 +1,92 @@ + + + diff --git a/htdocs/cookie.mjs b/htdocs/cookie.mjs new file mode 100644 index 0000000..b466744 --- /dev/null +++ b/htdocs/cookie.mjs @@ -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 }; diff --git a/htdocs/index.html b/htdocs/index.html new file mode 100644 index 0000000..0de00f7 --- /dev/null +++ b/htdocs/index.html @@ -0,0 +1,46 @@ + + + + + + WebVistA + + + + +
+ + + + + + diff --git a/htdocs/reportparser.mjs b/htdocs/reportparser.mjs new file mode 100644 index 0000000..3a4d661 --- /dev/null +++ b/htdocs/reportparser.mjs @@ -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(/^(?.*?)(?: (?L\*|L|H\*|H))?\s+(?:(?.{10}) (?.{16}) \[(?\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(?.*?)\s{2,}(?.*?)(?: (?L\*|L|H\*|H))?\s+(?:(?.{10}) (?.{16}) \[(?\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\]: (?.*?) \[(?\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; +} diff --git a/htdocs/table-sticky.css b/htdocs/table-sticky.css new file mode 100644 index 0000000..ee70548 --- /dev/null +++ b/htdocs/table-sticky.css @@ -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; +} diff --git a/htdocs/userstyle.css b/htdocs/userstyle.css new file mode 100644 index 0000000..1420262 --- /dev/null +++ b/htdocs/userstyle.css @@ -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; +} diff --git a/htdocs/util.mjs b/htdocs/util.mjs new file mode 100644 index 0000000..2623abc --- /dev/null +++ b/htdocs/util.mjs @@ -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); + } +} diff --git a/htdocs/vista.mjs b/htdocs/vista.mjs new file mode 100644 index 0000000..e108ae2 --- /dev/null +++ b/htdocs/vista.mjs @@ -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 +}; diff --git a/htdocs/vistax.mjs b/htdocs/vistax.mjs new file mode 100644 index 0000000..6816fc8 --- /dev/null +++ b/htdocs/vistax.mjs @@ -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 +}; diff --git a/main.py b/main.py new file mode 100644 index 0000000..0ff560c --- /dev/null +++ b/main.py @@ -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//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//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//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/') + 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//') + 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('/') + 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba2f417 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask[async]>=2.2,<2.3 \ No newline at end of file diff --git a/rpc.py b/rpc.py new file mode 100644 index 0000000..f662440 --- /dev/null +++ b/rpc.py @@ -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()) diff --git a/run.cmd b/run.cmd new file mode 100644 index 0000000..092d5d0 --- /dev/null +++ b/run.cmd @@ -0,0 +1,2 @@ +py -3-32 -m pip install -r requirements.txt +py -3-32 main.py \ No newline at end of file diff --git a/util.py b/util.py new file mode 100644 index 0000000..bbb3b50 --- /dev/null +++ b/util.py @@ -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(\d{3})(\d{2})(\d{2})(?:\.(\d{2})?(\d{2})?(\d{2})?)?)' # George Timson's format +re_dt_today = r'(?PT)' # today +re_dt_now = r'(?PN)' # now +re_dt_mdy = r'(?P(\d{1,2})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # m/d/yy, m/d/yyyy +re_dt_ymd = r'(?P(\d{4})[^\w@?]+(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # yyyy/m/d +re_dt_yyyymmdd = r'(?P(\d{4})(\d{2})(\d{2}))' # yyyymmdd +re_dt_Mdy = r'(?P([A-Z]{3,})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # M/d/yy, M/d/yyyy +re_dt_dMy = r'(?P((\d{1,2})[^\w@?]+[A-Z]{3,})[^\w@?]+(\d{4}|\d{2})\s*)' # d/M/yy, d/M/yyyy +re_dt_md = r'(?P(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # m/d +re_dt_offset = r'(?P([-+]\d+)(H|W|M)?)' # +#U +re_dt_time = r'(?:@?(?P