This commit is contained in:
Jiang Yio 2024-03-02 00:34:29 -05:00 committed by inportb
commit 7be5ebcdaa
49 changed files with 3907 additions and 0 deletions

166
.gitignore vendored Normal file
View File

@ -0,0 +1,166 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Application-specific
*.ini
*.db*

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2023 jyio
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# vistassh-py
Python-based web interface for VistA roll-and-scroll terminal

193
XWBSSOi.py Normal file
View File

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

285
autoproc.py Normal file
View File

@ -0,0 +1,285 @@
#!/usr/bin/env python3
import sys
import getpass
import re
import codecs
import asyncio
import contextlib
import logging
from collections import namedtuple
from typing import Optional, Union, Sequence, NamedTuple, Callable
logger = logging.getLogger(__name__)
ExpectMatch = namedtuple('PatternMatch', ('batch', 'index', 'pattern', 'match', 'groups', 'groupdict', 'before'))
ExpectMatch.__new__.__defaults__ = (None,)*len(ExpectMatch._fields)
class ExpectQ(object):
"""Provide an expect-like interface over an asyncio queue"""
def __init__(self, pipequeue: asyncio.Queue, timeout_settle: float=1):
self.pipequeue = pipequeue
self.buffer = ''
self.timeout_settle = timeout_settle
def set_timeout(self, timeout_settle: float=1):
"""Set default timeout"""
self.timeout_settle = timeout_settle
def reset(self, buffer: str=''):
"""Clear or restore buffer"""
self.buffer = buffer
clear = reset
async def prompts(self, endl: str='\r\n', timeout_settle: Optional[float]=None, throw: bool=False):
len_endl = len(endl)
while True:
if (pos := self.buffer.rfind(endl)) >= 0:
buffer = self.buffer
self.buffer = ''
yield buffer, pos + len_endl
while True:
try:
self.buffer += await asyncio.wait_for(self.pipequeue.get(), timeout=(timeout_settle or self.timeout_settle))
break
except asyncio.TimeoutError: # no more data
if throw:
raise
yield None, None
async def promptmatches(self, *mappings: Union[str, re.Pattern, tuple, list], endl: str='\r\n', timeout_settle: Optional[float]=None, throw: bool=False):
for i, mapping in enumerate(mappings):
try:
match mapping:
case (str() as pattern, response) if response is None or isinstance(response, str) or callable(response):
async for buffer, pos in self.prompts(endl=endl, timeout_settle=timeout_settle, throw=True):
if pattern == buffer[pos:]:
yield (m := self.ExactMatch(batch=i, index=0, pattern=mapping, match=mapping, groups=None, groupdict=None, before=buffer[:pos])), (response(m) if callable(response) else response)
break
else:
self.reset(buffer)
case (re.Pattern() as pattern, response) if response is None or isinstance(response, str) or callable(response):
async for buffer, pos in self.prompts(endl=endl, timeout_settle=timeout_settle, throw=True):
if match := pattern.search(buffer[pos:]):
yield (m := self.PatternMatch(batch=i, index=0, pattern=pattern, match=match, groups=match.groups(), groupdict=match.groupdict(), before=buffer[:pos])), (response(m) if callable(response) else response)
break
else:
self.reset(buffer)
case (*_,) as components:
exact = {}
expr = {}
for j, component in enumerate(components):
match component:
case (str() as pattern, response, *rest) if response is None or isinstance(response, str) or callable(response):
exact[pattern] = (j, response, None if len(rest) < 1 else rest[0])
case (re.Pattern() as pattern, response, *rest) if response is None or isinstance(response, str) or callable(response):
expr[pattern] = (j, response, None if len(rest) < 1 else rest[0])
async for buffer, pos in self.prompts(endl=endl, timeout_settle=timeout_settle, throw=True):
if buffer is not None:
prompt = buffer[pos:]
if prompt in exact:
j, response, end = exact[prompt]
interrupt = yield (m := self.ExactMatch(batch=i, index=j, pattern=prompt, match=prompt, groups=None, groupdict=None, before=buffer[:pos])), (response(m) if callable(response) else response)
else:
for pattern in expr:
if match := pattern.search(prompt):
j, response, end = expr[pattern]
interrupt = yield (m := self.PatternMatch(batch=i, index=j, pattern=pattern, match=match, groups=match.groups(), groupdict=match.groupdict(), before=buffer[:pos])), (response(m) if callable(response) else response)
break
else:
self.reset(buffer)
continue
if interrupt:
yield
break
elif end:
break
except asyncio.TimeoutError as ex: # no more data
if throw:
raise asyncio.TimeoutError(*(ex.args + (i, mapping)))
yield None, None
async def earliest(self, *patterns: Union[str, re.Pattern], timeout_settle: Optional[float]=None, throw: bool=False) -> Optional[NamedTuple]:
"""Wait for any string or regular expression pattern match, specified in *patterns, and optionally raise exception upon timeout"""
try:
while True:
for i, pattern in enumerate(patterns): # try every option
if isinstance(pattern, str):
if (pos := self.buffer.find(pattern)) >= 0: # found it
res = self.ExactMatch(index=i, pattern=pattern, match=pattern, groups=None, groupdict=None, before=self.buffer[:pos])
self.buffer = self.buffer[pos + len(pattern):]
return res
else:
if match := pattern.search(self.buffer): # found it
res = self.PatternMatch(index=i, pattern=pattern, match=match, groups=match.groups(), groupdict=match.groupdict(), before=self.buffer[:match.start()])
self.buffer = self.buffer[match.end():]
return res
else: # fetch more data
self.buffer += await asyncio.wait_for(self.pipequeue.get(), timeout=(timeout_settle or self.timeout_settle))
except asyncio.TimeoutError: # no more data
if throw:
raise
return None
async def startswith(self, *patterns: Union[str, re.Pattern], timeout_settle: Optional[float]=None, throw: bool=False) -> Optional[NamedTuple]:
"""Wait for any string or regular expression pattern match, specified in *patterns, at the start of the stream and optionally raise exception upon timeout"""
try:
while True:
for i, pattern in enumerate(patterns): # try every option
if isinstance(pattern, str):
if self.buffer.startswith(pattern): # found it
res = self.ExactMatch(index=i, pattern=pattern, match=pattern, groups=None, groupdict=None, before='')
self.buffer = self.buffer[len(pattern):]
return res
else:
if match := pattern.match(self.buffer): # found it
res = self.PatternMatch(index=i, pattern=pattern, match=match, groups=match.groups(), groupdict=match.groupdict(), before=self.buffer[:match.start()])
self.buffer = self.buffer[match.end():]
return res
else: # fetch more data
self.buffer += await asyncio.wait_for(self.pipequeue.get(), timeout=(timeout_settle or self.timeout_settle))
except asyncio.TimeoutError: # no more data
if throw:
raise
return None
async def endswith(self, *patterns: Union[str, re.Pattern], timeout_settle: Optional[float]=None, throw: bool=False) -> Optional[NamedTuple]:
"""Wait for any string or regular expression pattern match, specified in *patterns, at the end of the stream and optionally raise exception upon timeout"""
try:
while True:
for i, pattern in enumerate(patterns): # try every option
if isinstance(pattern, str):
if self.buffer.endswith(pattern): # found it
res = self.ExactMatch(index=i, pattern=pattern, match=pattern, groups=None, groupdict=None, before=self.buffer[:-len(pattern)])
self.buffer = ''
return res
else:
if match := pattern.search(self.buffer): # found it
res = self.PatternMatch(index=i, pattern=pattern, match=match, groups=match.groups(), groupdict=match.groupdict(), before=self.buffer[:match.start()])
self.buffer = self.buffer[match.end():]
return res
else: # fetch more data
self.buffer += await asyncio.wait_for(self.pipequeue.get(), timeout=(timeout_settle or self.timeout_settle))
except asyncio.TimeoutError: # no more data
if throw:
raise
return None
__call__ = earliest
ExactMatch = type('ExactMatch', (ExpectMatch,), {})
PatternMatch = type('PatternMatch', (ExpectMatch,), {})
class LockableCallable(object):
def __init__(self, func: Callable, lock: asyncio.Lock=None):
if lock is None:
lock = asyncio.Lock()
self.lock = lock
self.locked = lock.locked
self.acquire = lock.acquire
self.release = lock.release
self.func = func
self.__name__ = func.__name__
self.__doc__ = func.__doc__
def __call__(self, *args, **kw):
return self.func(*args, **kw)
async def __aenter__(self):
await self.lock.acquire()
async def __aexit__(self, exc_type, exc, tb):
self.lock.release()
async def withlock(self, *args, **kw):
async with self.lock:
return self.func(*args, **kw)
async def create_instrumented_subprocess_exec(*args: str, stdin_endl=b'\n', **kw) -> asyncio.subprocess.Process:
"""Create asyncio subprocess, coupled to host stdio, with ability to attach tasks that could inspect its stdout and inject into its stdin"""
process = await asyncio.create_subprocess_exec(*args, **kw)
tasks = set()
queues = set()
def create_task(*args, **kw):
tasks.add(item := asyncio.create_task(*args, **kw))
item.add_done_callback(tasks.remove)
return item
process.create_task = create_task
def subscribe(pipequeue=None):
queues.add(pipequeue := pipequeue or asyncio.Queue())
pipequeue.unsubscribe = lambda: queues.remove(pipequeue)
return pipequeue
process.subscribe = subscribe
def sendline(data=None, endl=None):
if data is not None:
process.stdin.write(data.encode('utf-8') + (endl or stdin_endl))
else:
process.stdin.write(endl or stdin_endl)
process.sendline = LockableCallable(sendline)
create_task(stdout_writer(process.stdout, queues), name='@task:stdout-writer') # stdout
process_wait = process.wait
async def wait_wrapper(): # clean up tasks at the end
await process_wait()
proc_id = id(process)
logger.debug('SHUTDOWN [proc#%d]: cleaning up'%proc_id)
for item in set(tasks): # copy set to avoid RuntimeError: Set changed size during iteration
if not item.done():
item.cancel()
try:
logger.debug('SHUTDOWN [proc#%d]: stopping [task#%d] %r'%(proc_id, id(item), item))
await item
except asyncio.CancelledError:
pass
logger.debug('SHUTDOWN [proc#%d]: stopped [task#%d]'%(proc_id, id(item)))
logger.debug('SHUTDOWN [proc#%d]: done'%proc_id)
process.wait = wait_wrapper
return process
async def stdout_writer(pipe: asyncio.StreamWriter, subscribers: Sequence[asyncio.Task], chunksize: int=4096, echo: bool=True):
"""Read data from pipe, decode into Unicode strings, and send to subscribers"""
try:
decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
while True:
try:
chunk = await pipe.read(chunksize) # fetch a bunch of bytes
if not chunk: # EOF
break
text = decoder.decode(chunk)
except asyncio.TimeoutError:
continue
except UnicodeDecodeError: # should not encounter errors with errors='replace'
logger.exception('stdout_writer')
break # bail on error
else:
if echo: # echo to stdout
sys.stdout.write(text)
sys.stdout.flush()
for item in subscribers: # distribute to subscribers
await item.put(text)
except KeyboardInterrupt:
logger.info('KeyboardInterrupt: stdout_writer')
@contextlib.contextmanager
def subscribe(proc):
queue = proc.subscribe()
queue.sendline = proc.sendline
try:
yield queue
finally:
queue.unsubscribe()
@contextlib.asynccontextmanager
async def subscribe_async(proc):
queue = proc.subscribe()
queue.sendline = proc.sendline
try:
yield queue
finally:
queue.unsubscribe()
@contextlib.contextmanager
def expect(proc):
queue = proc.subscribe()
expect = ExpectQ(queue)
expect.sendline = proc.sendline
try:
yield expect
finally:
queue.unsubscribe()
@contextlib.asynccontextmanager
async def expect_async(proc):
queue = proc.subscribe()
expect = ExpectQ(queue)
expect.sendline = proc.sendline
try:
yield expect
finally:
queue.unsubscribe()

30
ext_discovery.py Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
import re
import autoproc
async def cmd_listclinics(proc):
"""Fetch list of clinics"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Appointment List')
assert await expect.endswith('\r\nSelect division: ALL// ')
proc.sendline() # default ALL
assert await expect.endswith('\r\nCount, Non Count, or Both: C//')
proc.sendline('Both')
assert await expect.endswith('\r\nSelect clinic: ALL// ')
proc.sendline('??')
assert await expect.earliest('\r\n Choose from:')
while m_delimiter := await expect.endswith('\r\n Type <Enter> to continue or \'^\' to exit: ', '\r\nSelect clinic: ALL// '):
for line in m_delimiter.before.splitlines():
line = line.strip()
if len(line) > 0:
assert (m := re.match(r'^(\d+)\s{2,}(.*?)(?:\s{2,}(.*?))?$', line))
yield { 'uid': int(m.group(1)), 'name': m.group(2).upper(), 'provider': m.group(3).upper() if m.group(3) else None }
if m_delimiter.index == 0:
proc.sendline()
else:
proc.sendline('^')
break
proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()

83
ext_lab.py Normal file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env python3
import re
import datetime
import util
import autoproc
import logging
logger = logging.getLogger(__name__)
local_tzinfo = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
def parse(text):
data = { 'body': text.rstrip() }
if match := re.search(r'\bProvider: \b(?P<practitioner>.*?)\r\n', text):
data.update(match.groupdict())
if match := re.search(r'\bSpecimen: \b(?P<specimen>.*?)\r\n', text):
data.update(match.groupdict())
if match := re.search(r'\bAccession \[UID\]: \b(?P<accession>.*?) \[(?P<uid>.*?)\]\r\n', text):
data.update(match.groupdict())
if match := re.search(r'\bReport Released: \b(?P<time_released>.*?)\r\n', text):
value = match.group(1)
data['time_released'] = datetime.datetime.strptime(value, '%b %d, %Y@%H:%M').replace(tzinfo=local_tzinfo) if '@' in value else datetime.datetime.strptime(value, '%b %d, %Y').replace(tzinfo=local_tzinfo)
if match := re.search(r'\bSpecimen Collection Date: \b(?P<time_collected>.*?)\r\n', text):
value = match.group(1)
data['time_collected'] = datetime.datetime.strptime(value, '%b %d, %Y@%H:%M').replace(tzinfo=local_tzinfo) if '@' in value else datetime.datetime.strptime(value, '%b %d, %Y').replace(tzinfo=local_tzinfo)
if match := re.match(r'\s+----(.*?)----\s+page \d+\r\n', text):
data['title'] = match.group(1)
if match := re.search(r'\bAccession \[UID\]: (?P<accession>.*?) \[(?P<uid>.*?)\]\s+Received: (?P<time_received>.*?)\r\n', text):
data.update(match.groupdict())
data['time_received'] = datetime.datetime.strptime(data['time_received'], '%b %d, %Y@%H:%M').replace(tzinfo=local_tzinfo) if '@' in data['time_received'] else datetime.datetime.strptime(data['time_received'], '%b %d, %Y').replace(tzinfo=local_tzinfo)
if match := re.search(r'\bReport Completed: \b(?P<time_completed>.*?)\r\n', text):
value = match.group(1)
data['time_completed'] = datetime.datetime.strptime(value, '%b %d, %Y@%H:%M').replace(tzinfo=local_tzinfo) if '@' in value else datetime.datetime.strptime(value, '%b %d, %Y').replace(tzinfo=local_tzinfo)
if match := re.search(r'\bCollection sample: (?P<specimen>.*?)\s+Collection date: (?P<time_collected>.*?)\r\n', text):
data.update(match.groupdict())
data['time_collected'] = datetime.datetime.strptime(data['time_collected'], '%b %d, %Y %H:%M').replace(tzinfo=local_tzinfo) if ':' in data['time_collected'] else datetime.datetime.strptime(data['time_collected'], '%b %d, %Y').replace(tzinfo=local_tzinfo)
return data
async def cmd_reports(proc, mrn, alpha, omega):
"""Fetch lab reports"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Laboratory Menu')
async for prompt, response in expect.promptmatches(
('Select HOSPITAL LOCATION NAME: ', None),
('Select Laboratory Menu Option: ', '13'), # Interim report
('Select Patient Name: ', mrn),
(
('Do you wish to continue with this patient [Yes/No]? ', 'Yes'),
('Date to START with: TODAY//', util.vista_strftime(omega), True),
),
('Date to END with: T-7//', util.vista_strftime(alpha)),
('Print address page? NO// ', None), # default NO
(
('Do you want to start each note on a new page? NO// ', None), # default NO
('DEVICE: HOME// ', 'HOME;90;1023', True),
),
throw=True):
proc.sendline(response)
assert await expect.earliest(' HOME(CRT)\r\n')
pages = []
async for prompt, response in expect.promptmatches((
(re.compile(r' PRESS \'\^\' TO STOP $'), None),
(re.compile(r' \'\^\' TO STOP$'), None),
('Select Patient Name: ', None),
('Select Laboratory Menu Option: ', None, True),
), throw=True):
proc.sendline(response)
match prompt:
case autoproc.ExpectMatch(index=(0|1), before=before):
if left := re.match(r'(?:\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r\n\r\n.+?[ ]{2,}Report date: .+?\r\n Pat ID: \d{3}-\d{2}-\d{4}[ ]{2,}SEX: \w[ ]{2,}DOB: .+?[ ]{2,}LOC: .+?\r\n)|(?:\r\n\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r\n.+?[ ]{2,}\d{3}-\d{2}-\d{4}[ ]{2,}AGE: \d+[^\r\n]*?\r\n)|(?:\r[ ]+\r\r\n.+?[ ]{2,}\d{3}-\d{2}-\d{4}[ ]{2,}AGE: \d+[ ]{2,}.+?\r\n)', before):
pages.append(before[len(left.group(0)):])
elif re.match(r'(?:\r\n)+.+?[ ]{2,}\d{3}-\d{2}-\d{4}[ ]{2,}.+?[ ]+$', before) or re.match(r'^(?:\r\n)+$', before):
pass
else:
print(repr(before))
assert False
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
text = re.sub(r'\r\n\s+>> CONTINUATION OF .+? <<(?:(?:\r\n)|(?:\s+page \d+))', '', '\r\n'.join(pages))
positions = [m.start() for m in re.finditer(r'(?:(?:[ ]+----MICROBIOLOGY----[ ]+page \d+\r\n\r\n)|(?:[ ]+))Reporting Lab:', text)]
positions.append(len(text))
for i in range(len(positions) - 1):
yield parse(text[positions[i]:positions[i + 1]])

67
ext_measurement.py Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
import re
import datetime
import util
import autoproc
units = {
'P': 'bpm',
'R': 'bpm',
'B/P': 'mmHg',
'Body Mass Index': 'kg/m²'
}
local_tzinfo = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
async def cmd_entries(proc, mrn, alpha, omega):
"""Fetch measurements"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Results Reporting Menu')
async for prompt, response in expect.promptmatches(
(
(' Press return to continue ', None),
('Select Patient(s): ', mrn, True),
('Select Patient: ', mrn, True),
),
('Select Item(s): ', '7'), # Vitals Cumulative Report
('Start Date [Time]: T// ', util.vista_strftime(alpha)),
(re.compile(r'^Ending Date \[Time\] \(inclusive\): (.*?)// $'), util.vista_strftime(omega)),
('DEVICE: HOME// ', 'HOME;;1023'),
timeout_settle=2, throw=True):
proc.sendline(response)
assert await expect.earliest(' HOME(CRT)\r\n')
pages = []
async for prompt, response in expect.promptmatches((
('Press return to continue "^" to escape ', None),
('Press RETURN to continue or \'^\' to exit: ', None),
('Select Clinician Menu Option: ', None, True),
), throw=True):
proc.sendline(response)
if prompt.index == 0 or prompt.index == 1:
pages.append(re.sub(r'^\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r\n[^\r\n]+? Cumulative Vitals\/Measurements Report[ ]+Page \d+\r\n\r\n-{10,}\r\n(?:\d{2}\/\d{2}\/\d{2} \(continued\)\r\n\r\n)?|\r\n\r\n\*\*\*[^\r\n]+\r\n\r\n[^\r\n]+?VAF 10-7987j\r\nUnit:[^\r\n]+\r\nDivision:[^\r\n]+(?:\r\n)?$', '', prompt.before))
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
for m_date in re.finditer(r'^(?P<date>\d{2}\/\d{2}\/\d{2})\r\n(?P<body>.*?\r\n)(?:(?=\d{2}\/)|\r\n|$)', '\r\n'.join(pages), re.DOTALL|re.MULTILINE):
g_date = m_date.group('date')
for m_time in re.finditer(r'^(?P<time>\d{2}:\d{2})\r\n(?P<body>.*?\r\n)(?:(?=\d{2}:)|\r\n|$)', m_date.group('body'), re.DOTALL|re.MULTILINE):
dt = datetime.datetime.strptime(g_date + ' ' + m_time.group('time'), '%m/%d/%y %H:%M').replace(tzinfo=local_tzinfo)
for m_entry in re.finditer(r'^[ ]{4}\b(?P<name>[^:]+):[ ]+(?P<value_american>[\d\.\/%]+)(?P<flag>\*)?(?: (?P<unit_american>\w+))?(?: \((?P<value_metric>\d\S*) (?P<unit_metric>\S+)\))?(?: \((?P<method>[^\)\r\n]+)\))?(?P<comment>(?:\r\n[ ]{9}\S[^\r\n]*)*)', m_time.group('body'), re.DOTALL|re.MULTILINE):
m_entry = m_entry.groupdict()
m_entry['time'] = dt
if m_entry['value_american'].endswith('%'):
m_entry['value_american'] = m_entry['value_american'][:-1]
m_entry['unit_american'] = '%'
if m_entry['value_metric']:
m_entry['value'] = m_entry['value_metric']
m_entry['unit'] = m_entry['unit_metric']
else:
m_entry['value'] = m_entry['value_american']
m_entry['unit'] = m_entry['unit_american']
if m_entry['unit'] is None:
m_entry['unit'] = units.get(m_entry['name'])
m_entry['comment'] = re.sub(r'\s+', ' ', m_entry['comment'].strip()) if m_entry['comment'] else None
yield m_entry
if m_entry['name'] == 'B/P':
a, b = m_entry['value'] .split('/', 1)
yield dict(m_entry, name='SBP', value=a)
yield dict(m_entry, name='DBP', value=b)

63
ext_note.py Normal file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
import re
import datetime
import util
import autoproc
import logging
logger = logging.getLogger(__name__)
def isnew(text):
return text.startswith('\r\n\r\nNOTE DATED: ') or re.match(r'\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r(-{10,})\r\n.*?Progress Notes\r\n\1\r\nNOTE DATED: ', text)
def collapse(text):
return re.sub(r'\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r(-{10,})\r\n.*?Progress Notes\r\n\1\r\n(.*?\*\* CONTINUED FROM PREVIOUS SCREEN \*\*\r\n)?', '', text).strip()
local_tzinfo = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
def parse(text):
data = re.match(r'NOTE DATED: (?P<note_dated>.*?)\r\n(?:LOCAL TITLE: (?P<local_title>.*?)\r\n)?(?:STANDARD TITLE: (?P<standard_title>.*?)\r\n)?(?:VISIT: (?P<visit>.*?)\r\n)?(?:ADMITTED: (?P<admitted>.*?)\r\n)?', text).groupdict()
data['note_dated'] = datetime.datetime.strptime(data['note_dated'], '%m/%d/%Y %H:%M').replace(tzinfo=local_tzinfo) if ':' in data['note_dated'] else datetime.datetime.strptime(data['note_dated'], '%m/%d/%Y').replace(tzinfo=local_tzinfo)
data['body'] = text
return data
async def cmd_reports(proc, mrn, alpha, omega):
"""Fetch progress notes"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^PNPT')
assert await expect.endswith('\r\nSelect PATIENT NAME: ')
proc.sendline(mrn)
assert await expect.endswith('\r\nPrint Notes Beginning: ')
proc.sendline(util.vista_strftime(omega))
assert await expect.endswith('\r\n Thru: ')
proc.sendline(util.vista_strftime(alpha))
assert await expect.endswith('\r\nDo you want WORK copies or CHART copies? CHART// ')
proc.sendline() # default CHART
if await expect.endswith('\r\nDo you want to start each note on a new page? NO// '):
proc.sendline() # default NO
assert await expect.endswith('\r\nDEVICE: HOME// ')
proc.sendline('HOME;;1023')
assert await expect.earliest(' HOME(CRT)\r\n')
pages = []
while True:
match m_delimiter := await expect.endswith('\r\nType <Enter> to continue or \'^\' to exit: ', '\r\nSelect PATIENT NAME: '):
case autoproc.ExpectMatch(index=0, before=before):
if isnew(before) and len(pages) > 0:
yield parse(collapse('\r\n'.join(pages)))
pages = []
pages.append(before)
proc.sendline()
case autoproc.ExpectMatch(index=1, before=before):
if isnew(before) and len(pages) > 0:
yield parse(collapse('\r\n'.join(pages)))
pages = []
pages.append(before)
yield parse(collapse('\r\n'.join(pages)))
proc.sendline('^')
assert await expect.endswith('\r\nSelect Progress Notes Print Options Option: ')
break
case _: assert False
proc.sendline('^')
proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()

85
ext_order.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import re
import datetime
import util
import autoproc
import logging
logger = logging.getLogger(__name__)
local_tzinfo = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
def parse(raw, prev=None):
assert (m := re.match(r'\b(?P<date_entered>\d{2}/\d{2}/\d{2}) (?P<status>[ a-z+]{3})(?:(?P<plus>\+)|[ ])(?P<text0>[^\r\n]{40}) (?P<requestor_name>[^\r\n]{,10})(?: (?P<date_start>\d{2}/\d{2}/\d{2})|[ ]{9})?(?: (?P<date_stop>\d{2}/\d{2})|[ ]{7})?\r\n (?P<time_entered>\d{2}:\d{2}) (?P<text1>[^\r\n]{40}) (?P<requestor_occupation>[^\r\n]{,10})[ ]{4}(?:(?P<time_start>\d{2}:\d{2})|[ ]{5})?[ ]{2}(?:(?P<time_stop>\d{2}:\d{2})|[ ]{5})?\r\n(?P<text>.*?)\r\n\r\n', raw, re.DOTALL))
data = { 'body': raw.strip() }
data.update((k, prev[k] if v == '"' and prev else v) for k, v in ((k, (v.strip() or None) if v is not None else v) for k, v in m.groupdict().items()))
if data.get('date_entered'):
if data.get('time_entered') and data['time_entered'] != '24:00':
data['datetime_entered'] = datetime.datetime.strptime(data['date_entered'] + ' ' + data['time_entered'], '%m/%d/%y %H:%M').replace(tzinfo=local_tzinfo)
else:
data['datetime_entered'] = datetime.datetime.strptime(data['date_entered'], '%m/%d/%y').replace(tzinfo=local_tzinfo).date()
if data.get('date_start'):
if data.get('time_start') and data['time_start'] != '24:00':
data['datetime_start'] = datetime.datetime.strptime(data['date_start'] + ' ' + data['time_start'], '%m/%d/%y %H:%M').replace(tzinfo=local_tzinfo)
else:
data['datetime_start'] = datetime.datetime.strptime(data['date_start'], '%m/%d/%y').replace(tzinfo=local_tzinfo).date()
if data.get('date_stop'):
reference = data.get('datetime_start') or datetime.datetime.now()
if isinstance(reference, datetime.date):
reference = datetime.datetime.combine(reference, datetime.time(0, 0), tzinfo=local_tzinfo)
if data.get('time_stop') and data['time_stop'] != '24:00':
data['datetime_stop'] = adjustyear(datetime.datetime.strptime(data['date_stop'] + ' ' + data['time_stop'], '%m/%d %H:%M').replace(tzinfo=local_tzinfo), reference)
else:
data['datetime_stop'] = adjustyear(datetime.datetime.strptime(data['date_stop'], '%m/%d').replace(tzinfo=local_tzinfo), reference).date()
if text := m.groupdict().get('text'):
assert (m := re.search(r'^[ ]{9}(?:Nrs:(?P<nrs>[^\r\n]{6})|[ ]{10})(?:Chrt:(?P<chrt>[^\r\n]{6})|[ ]{11})Typ:(?P<typ>[^\r\n]{20})Sgn:(?P<sgn>[^\r\n]+?)(?:\r\n|$)', text, re.MULTILINE))
data.update((k, v.strip()) for k, v in m.groupdict().items() if v is not None)
begin, end = m.span()
data['text'] = ((data['text0'].strip() + '\r\n') if data.get('text0') else '') + ((data['text1'].strip() + '\r\n') if data.get('text1') else '') + re.sub(r'\r\n[ \t]+|[ \t]+\r\n', '\r\n', text[:begin].strip())
if len(text) > end:
data['footer'] = text[end:]
text = text[:begin]
else:
data['text'] = ((data['text0'].strip() + '\r\n') if data.get('text0') else '') + (data['text1'].strip() if data.get('text1') else '')
del data['text0'], data['text1']
return data
def adjustyear(dt, after):
if dt > after:
return dt
dt = dt.replace(year=after.year)
return dt if dt > after else dt.replace(year=after.year + 1)
async def cmd_entries(proc, mrn, alpha, omega):
"""Fetch orders"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Results Reporting Menu')
if await expect.endswith('\r\n Press return to continue '): # No patients found
proc.sendline()
assert await expect.endswith('\r\nSelect Patient(s): ', '\r\nSelect Patient: ', timeout_settle=2)
proc.sendline(mrn)
assert await expect.endswith('\r\nSelect Item(s): ')
proc.sendline('9') # Order Summary for Date/time Range
assert await expect.endswith('\r\nStart Date [Time]: T// ')
proc.sendline(util.vista_strftime(alpha))
assert await expect.endswith(re.compile(r'\r\nEnding Date \[Time\] \(inclusive\): (.*?)// $'))
proc.sendline(util.vista_strftime(omega))
assert await expect.endswith('\r\nDEVICE: HOME// ')
proc.sendline('HOME;90;1023')
assert await expect.earliest(' HOME(CRT)\r\n')
pages = []
while True:
match m_delimiter := await expect.endswith('\r\nPress RETURN to continue, \'^\' to exit: ', '\r\nSelect Clinician Menu Option: '):
case autoproc.ExpectMatch(index=0, before=before):
pages.append(before)
proc.sendline()
case autoproc.ExpectMatch(index=1):
break
case _: assert False
proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
prev = None
for m in re.finditer(r'\b\d{2}/\d{2}/\d{2}.*?\r\n\r\n', '\r\n'.join(pages).replace('\x1b[1m', '').replace('\x1b[m', ''), re.DOTALL):
prev = parse(m.group(0), prev)
yield prev

70
ext_patient.py Normal file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env python3
import re
import autoproc
async def cmd_lookup_patient(proc, query):
"""Lookup patient"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Patient Inquiry')
assert await expect.endswith('\r\nSelect PATIENT NAME: ')
proc.sendline(query)
assert await expect.startswith(query)
res = []
single = False
async for prompt, response in expect.promptmatches((
(re.compile(r'^CHOOSE 1-\d+: $'), None),
('Type <Enter> to continue or \'^\' to exit: ', '^'),
('Do you wish to view active patient record flag details? Yes// ', 'No'),
('Do you want to continue processing this patient record? No// ', None),
('Select PATIENT NAME: ', None),
('Select Patient Appointments/Inpatient Inquiry Option: ', None, True),
), throw=True):
proc.sendline(response)
if prompt.index <= 4:
res.append(prompt.before[:-24] if prompt.index == 0 and prompt.before.endswith('\r\nENTER \'^\' TO STOP, OR \r\n') else prompt.before)
if 0 < prompt.index < 4:
single = True
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
if single:
return [re.search(r'[ ]{2}(?P<name>.+?)[ ]{2}(?:\((?P<alias>[^\)]*?)\))?[ ]{6}(?P<dob>\S+)[ ]{4}(?P<ssn>\S+(?:P \*\*Pseudo SSN\*\*)?)[ ]{5}(?P<yesno>\S+)[ ]{5}(?P<type>.+?)[ ]{6}(?P<no>[^\r\n]*)', res[0].replace('\r\n', '', 1)).groupdict()]
return [m.groupdict() for m in re.finditer(r'^[ ]{3}(?P<ordinal>\d+)[ ]{1,3}(?:(?P<last5>[A-Za-z][0-9]{4})[ ]{2})?(?P<name>.+?)[ ]{6,8}(?P<dob>\S+)[ ]{4}(?P<ssn>\S+(?:P \*\*Pseudo SSN\*\*)?)[ ]{5}(?P<yesno>\S+)[ ]{5}(?P<type>.+?)[ ]{6}(?P<no>[^\r\n]*)', re.sub(r'(.{80})\r\n(?! \d)', r'\1', '\r\n\r\n'.join(res)), re.MULTILINE)]
async def cmd_lookup_patient_ordinal(proc, query, ordinal, force=False):
"""Lookup patient"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Patient Inquiry')
assert await expect.endswith('\r\nSelect PATIENT NAME: ')
proc.sendline(query)
assert await expect.startswith(query)
res = []
ordinal = str(int(ordinal))
async for prompt, response in expect.promptmatches((
(re.compile(r'^CHOOSE 1-\d+: $'), None),
('Type <Enter> to continue or \'^\' to exit: ', None),
('Do you wish to view active patient record flag details? Yes// ', 'No'),
('Do you want to continue processing this patient record? No// ', 'Yes' if force else 'No'),
('Select PATIENT NAME: ', None),
('Select Patient Appointments/Inpatient Inquiry Option: ', None, True),
), timeout_settle=2, throw=True):
match prompt:
case autoproc.ExpectMatch(index=0, before=before):
if re.search(r'^[ ]{3}' + ordinal + r'[ ]+', before, re.MULTILINE):
proc.sendline(ordinal)
else:
proc.sendline()
case autoproc.ExpectMatch(index=(1|4), before=before):
res.append(before[13:] if before.startswith('\x1b[H\x1b[J\x1b[2J\x1b[H') else before)
proc.sendline()
case autoproc.ExpectMatch(index=2, before=before, match=match):
res.append(before + match)
proc.sendline(response)
case autoproc.ExpectMatch(index=3, before=before, match=match):
res.append(before + match)
proc.sendline(response)
case autoproc.ExpectMatch(index=5):
proc.sendline()
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
return re.sub(r'\r\n\r\n(?:[^\r\n;]+);(?:\([^\)]*?\))? (?:\d+ )?(?:\d{3}-\d{2}-\d{4}P?) (?:[^\r\n]+?)[ ]*?(\r\n={10,}\r\n)\r\n', r'\1', '\r\n'.join(res))

71
ext_rcrs.py Normal file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
import re
import util
import autoproc
def parse_xml_rcrs(text, summary):
summary = {row['uid']: row for row in summary}
for patient in re.finditer(r'<Patient>(.+?)</Patient>', text, re.DOTALL):
patient = patient.group(1)
if (pos := patient.find('<Tumor>')) >= 0:
data = parse_xml_items(patient[:pos])
data['tumors'] = [parse_xml_items(tumor.group(1)) for tumor in re.finditer(r'<Tumor>(.+?)</Tumor>', patient[pos:], re.DOTALL)]
for tumor in data['tumors']:
acc = tumor['accessionNumberHosp']
meta = tumor['meta'] = summary[acc[:4] + '-' + acc[4:] + '/' + tumor['sequenceNumberHospital']]
data['name'] = meta.pop('name')
data['last5'] = meta.pop('last5')
else:
data = parse_xml_items(patient)
data['tumors'] = []
yield data
def parse_xml_items(text):
return {item.group(1): item.group(2) for item in re.finditer(r'<Item naaccrId="([^"]+)">([^<]+)</Item>', text)}
async def cmd_patients(proc, alpha, omega):
"""Fetch patients from RCRS dump"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Create RCRS extract')
async for prompt, response in expect.prompts_any({
re.compile(r'^ Facility Identification Number \(FIN\): \d+// $'): None,
' Select date field to be used for Start/End range: ': 'Date DX',
re.compile(r'^ Start, Date DX: : \([^\)\r\n]+\): $'): util.vista_strftime(alpha),
re.compile(r'^ End, Date DX: : \([^\)\r\n]+\): TODAY// $'): util.vista_strftime(omega),
' Are these settings correct? YES// ': None,
}, throw=True):
proc.sendline(response)
if prompt.index == 4:
break
assert await expect.endswith('\r\n --------------------------------------------------------------\r\n\r\n\r\n')
proc.sendline() # skip delay
assert (m := await expect.endswith('\r\n\x1a'))
doc_rcrs = m.before
assert await expect.endswith('\r\nDEVICE: ', timeout_settle=31)
proc.sendline('HOME;80') # default HOME
assert await expect.earliest('HOME(CRT)\r\n')
summary = []
async for prompt, response in expect.prompts_any({
'\x07': None,
'Type <Enter> to continue or \'^\' to exit: ': None,
' Press \'RETURN\' to continue, \'^\' to stop: ': None,
'Select *..Utility Options Option: ': None,
'Select DHCP Tumor Registry Option: ': None,
}, throw=True):
proc.sendline(response)
if prompt.index == 0 or prompt.index == 1:
summary.extend({k.strip(): v.strip() for k, v in row.groupdict().items()} for row in re.finditer(r'(?P<last5>[A-Z]\d{4}) (?P<name>[^\r\n]{30}) (?P<uid>[^ \r\n]+) (?P<primarySite>[^ \r\n]+) (?P<dateOfDiagnosis>\d{2}/\d{2}/\d{4}) (?P<dateCaseLastChanged>\d{2}/\d{2}/\d{4})', prompt.before))
elif prompt.index == 4:
break
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
for item in parse_xml_rcrs(doc_rcrs, summary):
yield item
async def cmd_tumors(proc, alpha, omega):
async for patient in cmd_patients(proc, alpha, omega):
tumors = patient.pop('tumors')
for item in tumors:
item['patient'] = patient
yield item

107
ext_scheduling.py Normal file
View File

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Appointments"""
import re
import datetime
import autoproc
import logging
logger = logging.getLogger(__name__)
local_tzinfo = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
async def cmd_appointments(proc, clinics='NPT-HEM/ONC ATTENDING', date='T', storeview=None):
"""List appointments"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
proc.sendline('^Appointment List')
assert await expect.endswith('\r\nSelect division: ALL// ')
proc.sendline() # default ALL
assert await expect.endswith('\r\nCount, Non Count, or Both: C//')
proc.sendline('Both')
assert await expect.endswith('\r\nSelect clinic: ALL// ')
for line in clinics.strip().split('^'):
proc.sendline(line)
match await expect.endswith('\x07 ??\r\nSelect clinic: ALL// ', '\r\nSelect another clinic: ', '\r\nSelect clinic: ALL// ', re.compile(r'\r\nCHOOSE \d+-\d+: $'), '\r\n ...OK? Yes// '):
case autoproc.ExpectMatch(index=0):
raise RuntimeError
case autoproc.ExpectMatch(index=3):
proc.sendline('1') # choose first option
case autoproc.ExpectMatch(index=4):
proc.sendline() # accept confirmation
proc.sendline() # finish clinic selection with blank entry
assert await expect.endswith('\r\nFor date: ')
proc.sendline(date)
if storeview:
assert (m := await expect.endswith(re.compile(r'\((.*?)\)', flags=re.MULTILINE|re.DOTALL)))
date = datetime.datetime.strptime(m.group(1), '%b %d, %Y').date()
storeview.delete('date_scheduled=?', (str(date),))
assert await expect.endswith(re.compile(r'Include Primary Care assignment information in the output\? NO// ', flags=re.MULTILINE|re.DOTALL))
proc.sendline() # default NO
assert await expect.endswith('\r\nNumber of copies: 1// ')
proc.sendline() # default 1
assert await expect.endswith('\r\nDEVICE: HOME// ')
proc.sendline() # default HOME
assert await expect.earliest('Right Margin: 80// ')
proc.sendline() # default 80 (maximum 256)
async for clinic in vista_appointment_clinics(proc, expect):
body = re.split(r'(?:^|\n)([ \d]\d:\d{2} [AP]M)\n\n', clinic['body'])
for i in range(1, len(body), 2):
item = clinic.copy()
del item['body']
item['time_scheduled'] = datetime.datetime.combine(item['date_scheduled'], datetime.datetime.strptime(body[i].strip(), '%I:%M %p').time()).replace(tzinfo=local_tzinfo)
detail = re.sub(r'\r\n(\s{0,9}\S)', r'\1', body[i + 1]) # collapse hard wrap
name, detail = detail.split('\n', 1)
item['flag'] = name[:10].strip()
assert (match := re.search(r'^(?P<patient_name>\w.*?)\s+(?P<patient_last4>\d{4}).*?$', name[10:]))
item.update(match.groupdict())
match = re.search(r'^\s{41}\b(?P<location_ward>.*?)\n\s{41}\b(?P<location_bed>.*?)$', detail, re.MULTILINE)
if match:
item.update(match.groupdict())
match = re.search(r'^\s{10}Phone #: (?P<patient_phone>.*)$', detail, re.MULTILINE)
if match:
item.update(match.groupdict())
item['comment'] = '\r\n'.join(m.group(1) for m in re.finditer(r'^\s{15}(\w.*?)$', detail, re.MULTILINE))
yield item
proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ')
expect.clear()
async def vista_appointment_clinics(proc, expect):
"""List appointments by clinic, collecting all pages"""
item = None
while True:
m = await expect.earliest('\x07')
if m:
try:
head, body = re.split(r'\n_{10,}\n', m.before.replace('\r\n', '\n'))
except ValueError:
logger.warning('invalid page %r'%m.before)
continue
if body.strip() not in {'No activity found for this clinic date!', 'Clinic cancelled for this date!'}:
assert (m1 := re.search(r'^Date printed: (?P<time_printed>.*?)\s+Page: (?P<print_page>\d+)$', head, re.MULTILINE))
assert (m2 := re.search(r'Appointments for (?P<clinic>.+) clinic on (?P<day_scheduled>[A-Z]+) (?P<date_scheduled>(?:[A-Z]+) (?:[0-9]+), (?:[0-9]+))', head))
if int(m1.group('print_page')) > 1:
# next page of same report
assert datetime.datetime.strptime(m1.group('time_printed'), '%b %d,%Y@%H:%M') == item['time_printed']
assert m2.group('clinic') == item['clinic']
assert m2.group('day_scheduled') == item['day_scheduled']
assert datetime.datetime.strptime(m2.group('date_scheduled'), '%b %d, %Y').date() == item['date_scheduled']
item['body'] += '\r\n' + body.rstrip('\r\n') # concatenate report bodies
else:
# new report
if item is not None:
yield item
item = {
'time_printed': datetime.datetime.strptime(m1.group('time_printed'), '%b %d,%Y@%H:%M'),
'clinic': m2.group('clinic'),
'day_scheduled': m2.group('day_scheduled'),
'date_scheduled': datetime.datetime.strptime(m2.group('date_scheduled'), '%b %d, %Y').date(),
'body': body.strip('\r\n')
}
m1 = re.search(r'Appointments for (?P<clinic>.+) clinic on (?P<day>[A-Z]+) (?P<date>(?:[A-Z]+) (?:[0-9]+), (?:[0-9]+))', head)
assert await expect.endswith('\r\nPress return to continue or "^" to escape ')
proc.sendline()
else: # leftovers
if item is not None:
yield item
break

40
ext_session.py Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
from typing import Optional
import re
import configparser
import autoproc
import XWBSSOi
async def task_smartcard(proc, config: Optional[configparser.ConfigParser]=None):
"""Smartcard authentication"""
async with proc.sendline, autoproc.expect_async(proc) as expect:
if await expect.endswith('\r\nACCESS CODE: ', timeout_settle=60):
try:
certificate = config['auth']['certificate']
except (TypeError, KeyError):
choice = XWBSSOi.get_vista_certificate()
certificate = XWBSSOi.get_certificate_thumbprint(choice).hex() if choice else None
doc = await XWBSSOi.get_sso_token_async(certificate=certificate)
if doc:
proc.sendline(re.sub(r'[\r\n]', '', doc))
if await expect.earliest(re.compile(r'^\s+You last signed on\s+', flags=re.MULTILINE), timeout_settle=5):
if certificate:
config.set('auth', 'certificate', certificate)
proc.create_task(task_keepalive(proc, True), name='@task:keepalive')
return True
async def task_keepalive(proc, suppress=False):
"""Keepalive"""
with autoproc.expect(proc) as expect:
if suppress:
while True:
if m := await expect.endswith('\r\nDo you want to halt and continue with this option later? YES// ', '\r\nDo you really want to halt? YES// ', timeout_settle=60):
await proc.sendline.withlock('NO')
if m.index == 1:
print('Suppressed; type \'^\' or \'^Halt\' to actually halt.')
else:
while True:
if await expect.endswith('\r\nDo you want to halt and continue with this option later? YES// ', timeout_settle=60):
await proc.sendline.withlock('NO')

10
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

38
frontend/README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "vistassh-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.4",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"svelte": "^4.2.8",
"vite": "^5.0.10"
},
"type": "module",
"dependencies": {
"bootstrap": "^5.3.2"
}
}

12
frontend/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
//export const prerender = true;
export const ssr = false;

View File

@ -0,0 +1,30 @@
<script>
import { onDestroy } from 'svelte';
import 'bootstrap/dist/css/bootstrap.css';
import url_bootstrap from 'bootstrap/dist/js/bootstrap.bundle.js?url';
import { navlinks } from '$lib/stores.js';
import Navbar from '$lib/Navbar.svelte';
const links = navlinks.push({
name: 'VistA-SSH',
href: '/',
children: [
{ name: 'Lookup', href: '/lookup' },
{ name: 'Appointments', href: '/appointments' },
{ name: 'Clinics', href: '/clinics' },
{ name: 'RCRS', href: '/rcrs' },
{ name: 'Log', href: '/api/log.txt', target: '_blank' },
]
});
onDestroy(() => navlinks.pop(links));
</script>
<svelte:head>
<script src={url_bootstrap}></script>
</svelte:head>
<Navbar />
<main class="container-md">
<slot />
</main>

View File

@ -0,0 +1,15 @@
<svelte:head>
<title>VistA-SSH</title>
</svelte:head>
<div class="bg-body-tertiary p-5 rounded">
<h1 style="font-family: monospace; white-space: pre; text-align: center;">VVVV VVAAAA
VVVV VVAAAAAA
VVVV VVAA AAAA
VVVV VVAA AAAA
VVVV VVAA AAAA
VVVVVVAA AAAA
VVVVAA AAAAAAAAAAA
VVAA AAAAAAAAAAA</h1>
<h1 style="text-align: center;">🚧 VistA-SSH 🚧</h1>
</div>

View File

@ -0,0 +1,11 @@
import { get_api_appointments } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let clinics = await (await fetch('/api/config/user/clinics')).json();
let appointments = await get_api_appointments({ fetch, clinics, date: 'T' });
appointments.sort((a, b) => a.time_scheduled < b.time_scheduled ? -1 : a.time_scheduled > b.time_scheduled ? 1 : 0);
return {
clinics, appointments
};
}

View File

@ -0,0 +1,69 @@
<script>
import { debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_dtstr, filter_pattern, filter_test, filter_mark, filter_snippets } from '$lib/util.js';
export let data;
let query = '', pattern = null;
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
data.appointments.forEach(x => (delete x._content, x._content = escapeHTML(Object.values(x).join('\x00'))));
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
</script>
<svelte:head>
<title>Appointments</title>
</svelte:head>
<h1>Appointments</h1>
<div class="card mb-3 shadow">
{#if data.appointments.length > 0}
<table class="table appointments">
<thead>
<tr>
<th colspan="100" style="padding: 0;">
<div class="input-group">
<input type="text" class="form-control" placeholder="Filter" name="q" bind:value={query} />
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</th>
</tr>
</thead>
<tbody>
{#if pattern}
{#each data.appointments as row}
{#if filter_test(pattern, row._content)}
<tr style:--bs-table-bg={strHashHSL(row.clinic, '85%')}>
<td><div>{row.clinic}</div><div>{datetime_dtstr(new Date(row.time_scheduled))}</div></td>
<td><div><a href="/lookup?q={encodeURIComponent(row.patient_name.charAt(0) + row.patient_last4)}&name={encodeURIComponent(row.patient_name)}&rd=true">{row.patient_name} {row.patient_last4}</a></div><div>{row.patient_phone}</div></td>
<td class="comment">{@html filter_mark(pattern, escapeHTML(row.comment))}</td>
</tr>
{/if}
{/each}
{:else}
{#each data.appointments as row}
<tr style:--bs-table-bg={strHashHSL(row.clinic, '85%')}>
<td><div>{row.clinic}</div><div>{datetime_dtstr(new Date(row.time_scheduled))}</div></td>
<td><div><a href="/lookup?q={encodeURIComponent(row.patient_name.charAt(0) + row.patient_last4)}&name={encodeURIComponent(row.patient_name)}&rd=true">{row.patient_name} {row.patient_last4}</a></div><div>{row.patient_phone}</div></td>
<td class="comment">{row.comment}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
</div>
<style>
:global(table.appointments mark) {
padding: 0;
font-weight: bold;
background-color: #fff;
}
.card table.table {
margin-bottom: 0;
}
td.comment {
white-space: pre-line;
}
</style>

View File

@ -0,0 +1,14 @@
import { get_api_lookup } from '$lib/backend.js';
/** @type {import('./$types').LayoutLoad} */
export async function load({ params, fetch }) {
const mrn = params.mrn;
const facesheet = await get_api_lookup({ fetch, query: mrn, ordinal: '0', force: true });
const match = /^(?<name>[^\r\n;]+);(?:\((?<alias>[^\)]*?)\))? (?:(?<icn>\d+) )?(?<ssn>\d{3}-\d{2}-\d{4}P?) (?<dob>.+?)\s*$/m.exec(facesheet);
const facesheet_data = match ? match.groups : {};
const m_sex = /^Birth Sex[ ]+:[ ]+(.+?)$/m.exec(facesheet);
if(m_sex) facesheet_data.sex = m_sex[1];
return {
mrn, sensitive: facesheet.includes('***RESTRICTED RECORD***'), facesheet, facesheet_data
};
}

View File

@ -0,0 +1,19 @@
<script>
import { onDestroy } from 'svelte';
import { navlinks } from '$lib/stores.js';
export let data;
const links = navlinks.push({
name: data.facesheet_data.name ? data.facesheet_data.name.split(',')[0].toLowerCase().replace(/(?:^|\s|["'([{])+\S/g, m => m.toUpperCase()) + ' ' + data.facesheet_data.ssn.split('-')[2] : 'Chart',
href: '/chart/' + data.mrn,
children: [
{ name: 'Orders', href: '/chart/' + data.mrn + '/orders' },
{ name: 'Labs', href: '/chart/' + data.mrn + '/labs' },
{ name: 'Notes', href: '/chart/' + data.mrn + '/notes' },
{ name: 'Flowsheet', href: '/chart/' + data.mrn + '/flowsheet' },
]
});
onDestroy(() => navlinks.pop(links));
</script>
<slot />

View File

@ -0,0 +1,17 @@
<script>
import { page } from '$app/stores';
/** @type {import('./$types').PageData} */
export let data;
</script>
<svelte:head>
<title>{data.facesheet_data.name}</title>
</svelte:head>
<h1>{data.facesheet_data.name}{#if data.sensitive}{/if}</h1>
<div class="card">
<div class="card-body">
<pre class="card-text">{data.facesheet}</pre>
</div>
</div>

View File

@ -0,0 +1,14 @@
import { get_api_measurements, get_api_labs } from '$lib/backend.js';
const time_min = new Date(1700, 0, 1);
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch, parent }) {
const mrn = params.mrn, offset = 30;
const parentdata = await parent();
const measurements = await get_api_measurements({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
const labs = await get_api_labs({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, offset, facesheet_data: parentdata.facesheet_data, measurements, labs
};
}

View File

@ -0,0 +1,483 @@
<script>
import { tick } from 'svelte';
import { uniq, groupByArray, quantile_sorted, debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets_lines, inherit } from '$lib/util.js';
import { get_api_measurements, get_api_labs } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, component_items = decorate_measurements(data.measurements).concat(decorate_labs(data.labs));
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate_measurements(xs) {
return xs.map(x => (x = Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time) }, x), x.timestr = datetime_timestr(x._ts), x));
}
function decorate_labs(xs) {
return components(xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time_collected) }, x)));
}
function components(reports) {
const res = [], re_lab_test = /^(?<name>\w[^\r\n]{26})(?<value>[^\r\n]{8}) (?:(?<flag>\w[ \*])|[ ]) (?<unit>[^\r\n]{10}) (?<range>[^\r\n]{16}) \[(?<site>\d+)\]$/gm;
reports.forEach(function(x) {
let datestr = datetime_datestr(x._ts), timestr = datetime_timestr(x._ts), m, r;
while(m = re_lab_test.exec(x.body)) {
m = m.groups;
for(let k in m) if(m[k]) m[k] = m[k].replace(/^\s+|\s+$/g, '');
if((r = m.range) && (r.includes(' - '))) {
r = r.split(' - ');
m.rangeL = r[0];
m.rangeH = r[1];
}
m.datestr = datestr;
m.timestr = timestr;
m.report = x;
Object.assign(m, x);
delete m.body;
delete m._content;
res.push(m);
}
});
return res;
}
function calculate(items) {
var snapshots = [], results = [], history, update, item;
groupByArray(items, x => x._ts).map(group => group.values.reduce(((acc, x) => (acc.values[x.name] = x, acc)), { key: group.key, values: {}})).sort((a, b) => (a.key > b.key) - (a.key < b.key)).forEach(function(group) {
snapshots.push({ key: group.key, values: history = Object.assign(snapshots.length > 0 ? inherit(snapshots[snapshots.length - 1].values) : inherit(calc_constants), update = group.values) });
history['Time'] = update['Time'] = { time: group.key, value: group.key };
for(var i = 0; i < calc_functions.length; ++i) {
var calculation = calc_functions[i], deps = calculation.deps;
for(var j = deps.length - 1, satisfied = true, updated = false; j >= 0; --j) if(!history[deps[j]]) { satisfied = false; break; }
else if(update[deps[j]]) updated = true;
if((satisfied) && (updated)) {
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
results.push(history[calculation.name] = update[calculation.name] = Object.assign({ _ts: group.key, value: item }, calculation));
if((calculation.hasOwnProperty('rangeL')) && (item < calculation.rangeL)) update[calculation.name].flag = 'L';
else if((calculation.hasOwnProperty('rangeH')) && (item > calculation.rangeH)) update[calculation.name].flag = 'H';
}
}
}
});
return results;
}
const calc_constants = {
DOB: { _ts: null, value: new Date(data.facesheet_data.dob) },
Sex: { _ts: null, value: data.facesheet_data.sex }
};
const calc_functions = [
{ name: 'Age', unit: 'yr', deps: ['Time', 'DOB'], calc(Time, DOB, prev) { var x = Math.floor((Time - DOB.getTime())/3.15576e10); return x != prev ? x : undefined; } },
{ name: 'BMI', unit: 'kg/m²', rangeL: 18.5, rangeH: 24.9, range: '18.5 - 24.9', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (10000*Wt/(Ht*Ht)).toPrecision(3) },
{ name: 'BSA', unit: 'm²', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (0.007184*Math.pow(Ht, 0.725)*Math.pow(Wt, 0.425)).toPrecision(3) },
{ name: 'CrCl', unit: 'mL/min', deps: ['Age', 'Sex', 'Wt', 'CREATININE'], calc: (Age, Sex, Wt, CREATININE) => (((140 - Age) * Wt)/(72*CREATININE)*(Sex == 'MALE' ? 1 : 0.85)).toPrecision(4) },
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) }
];
$: component_calculated = component_items.concat(calculate(component_items));
$: component_groups = groupByArray(component_calculated, x => new Date(x._ts.getFullYear(), x._ts.getMonth(), x._ts.getDate())).map(function(group) {
group = group.values.reduce(((acc, x) => ((acc.values[x.name] || (acc.values[x.name] = [])).push(x), acc)), { key: group.key, datestr: datetime_datestr(group.key), datestr_year: group.key.getFullYear(), values: {}});
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
var items = group.values[k].sort((a, b) => a._ts - b._ts);
var strings = items.map(item => item.timestr + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : '')/* + (item.comment && item.comment.indexOf('\n') < 0 ? ' ' + item.comment : '')*/);
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 && x.indexOf('\n') >= 0));
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,
reports: items.map(item => item.report).filter(x => x),
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));
$: component_names = uniq(component_calculated.map(x => x.name));
$: component_stats = statistics(component_calculated);
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
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 tooltip(item) {
if(item) {
var res = [], stat;
if(item.range) res.push('Ref: ' + item.range + ' ' + item.unit + (item.flag ? ' [' + item.flag + ']' : ''));
if(stat = component_stats[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');
}
};
function abnormal_ref(item) {
return (item) && (item.flag);
};
function abnormal_ref_low(item) {
return (item) && (item.flag) && (item.flag.indexOf('L') >= 0);
};
function abnormal_ref_high(item) {
return (item) && (item.flag) && (item.flag.indexOf('H') >= 0);
};
function abnormal_iqr(item) {
var stat;
if((item) && (stat = component_stats[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);
}
};
function abnormal_iqr_low(item) {
var stat;
if((item) && (stat = component_stats[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;
}
};
function abnormal_iqr_high(item) {
var stat;
if((item) && (stat = component_stats[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;
}
};
function abstract(body) {
let re = /^(\w.*?)[ \t]{2,}\S.*?[ \t]{2,}/gm, m, res = [];
while(m = re.exec(body)) res.push(m[1]);
re = /^\*[ ]+((?:.+?) REPORT) =>/gm;
while(m = re.exec(body)) res.push(m[1]);
return res;
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
const next_offset = data.offset + (loadmore.limit = (factor*loadmore.limit)|0);
const measurements = await get_api_measurements({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + next_offset });
Array.prototype.push.apply(component_items, decorate_measurements(measurements));
const labs = await get_api_labs({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + next_offset });
Array.prototype.push.apply(component_items, decorate_labs(labs));
component_items = component_items; // reactivity hint
data.offset = next_offset;
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
let header;
(async function loadinit(target = 16, requests = 4) {
await tick();
for(let i = 0; (i < requests) && (component_groups.length < target); ++i) await loadmore();
await tick();
if((header) && (component_groups)) header.children[header.children.length - 1].scrollIntoView({ block: 'nearest', inline: 'end' });
})();
/*
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
*/
</script>
<svelte:head>
<title>Flowsheet</title>
</svelte:head>
{#if (selection) && (selection.length > 0)}
<div class="halfpane rightpane">
{#each selection as row, idx}
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</span>
{#if idx == 0}<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>{/if}
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
{/each}
</div>
{/if}
<div class={(selection) && (selection.length > 0) ? 'halfpane leftpane' : ''}>
<div style="font-family: monospace;" role="region" tabindex="0">
<table class="table-sticky table-data">
<thead>
<tr bind:this={header}>
<th class="corner">
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if !loadmore.loading}<span class="badge rounded-pill bg-primary position-absolute top-0 start-100 translate-middle-x" style="cursor: pointer;" on:click={loadmore}>More</span>{/if}
</th>
{#each component_groups as group, idx}<th class="date" class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) }><div class="year">{group.datestr.substring(0, 4)}</div><div class="monthdate">{group.datestr.substring(5)}</div>{#if false}<div class="hourminute daily">{datetime_timestr(group.values[0]._ts)}</div>{/if}</th>{/each}
</tr>
</thead>
{#if pattern}
<tbody>
{#each component_names as name}
{#if filter_test(pattern, name)}
<tr class="match">
<th>{@html pattern ? filter_mark(pattern, escapeHTML(name)) : escapeHTML(name)}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/if}
{/each}
</tbody>
<tbody>
{#each component_names as name}
{#if !filter_test(pattern, name)}
<tr>
<th>{name}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/if}
{/each}
</tbody>
{:else}
<tbody>
{#each component_names as name}
<tr>
<th>{name}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/each}
</tbody>
{/if}
</table>
</div>
</div>
<style>
:global(th mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
[role="region"][tabindex] {
max-height: calc(100vh - 4.5rem);
overflow: auto;
}
table {
width: 100%;
}
table tr.match th:first-child {
background-color: #c6def1;
}
table th, table td {
padding: 0 0.5rem;
white-space: nowrap;
text-align: center;
}
table.table-sticky thead th {
position: sticky;
z-index: 1010;
top: 0;
background-color: #fff;
}
table.table-sticky th:first-child {
position: sticky;
z-index: 1010;
left: 0;
background-color: #fff;
}
table.table-sticky thead th.corner {
padding: 0;
z-index: 1020;
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
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 tbody tr:hover {
border: 1px solid #6c757d;
}
td:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
table.table-sticky tbody th, table.table-sticky th.name {
cursor: default;
font-weight: bold;
text-align: center;
padding-left: 1rem;
padding-right: 1rem;
}
table.table-sticky th.filtered {
background-color: #6c757d;
color: #fff;
}
table.table-sticky th.date {
cursor: default;
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;
}
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;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
left: 0;
z-index: -1;
}
.leftpane [role="region"][tabindex] {
max-height: calc(100vh - 3.5rem);
direction: rtl;
}
.leftpane [role="region"][tabindex] > * {
direction: ltr;
}
.rightpane {
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_labs } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let mrn = params.mrn, offset = 30;
let reports = await get_api_labs({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, reports, offset
};
}

View File

@ -0,0 +1,183 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets_lines } from '$lib/util.js';
import { get_api_labs } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time_collected) }, x));
}
function abstract(body) {
let re = /^(\w.*?)[ \t]{2,}\S.*?[ \t]{2,}/gm, m, res = [];
while(m = re.exec(body)) res.push(m[1]);
re = /^\*[ ]+((?:.+?) REPORT) =>/gm;
while(m = re.exec(body)) res.push(m[1]);
return res;
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_labs({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>Labs</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(selection._ts)}@{datetime_timestr(selection._ts)} {selection.accession} {selection.specimen}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(selection.body)) : escapeHTML(selection.body)}</div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">Labs</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
{@const abs = abstract(row.body)}
<li class="list-group-item" class:active={(selection) && (selection.uid == row.uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</div>
{#if abs.length > 0}<div class="abstract singleline">{abs.join(', ')}</div>{/if}
<div class="snippets">{#each filter_snippets_lines(pattern, escapeHTML(row.body), undefined, 3, 6) as match}<div>{@html match}</div>{/each}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
{@const abs = abstract(row.body)}
<li class="list-group-item" class:active={(selection) && (selection.uid == row.uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</div>
{#if abs.length > 0}<div class="abstract singleline">{abs.join(', ')}</div>{/if}
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.snippets mark, div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_notes } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let mrn = params.mrn, offset = 30;
let reports = await get_api_notes({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, reports, offset
};
}

View File

@ -0,0 +1,173 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets } from '$lib/util.js';
import { get_api_notes } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _uid: x.note_dated + x.local_title + x.standard_title + (x.visit || x.admitted), _ts: new Date(x.note_dated) }, x)).sort((a, b) => b._ts - a._ts);
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_notes({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>Progress notes</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(selection._ts)}@{datetime_timestr(selection._ts)} {selection.local_title}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(selection.body)) : escapeHTML(selection.body)}</div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">Progress notes</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.local_title}</div>
<div class="abstract singleline">{row.visit || row.admitted}</div>
<div class="snippets">{#each filter_snippets(pattern, escapeHTML(row.body), undefined, 3, 6) as match}<div>{@html match}</div>{/each}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.local_title}</div>
<div class="abstract singleline">{row.visit || row.admitted}</div>
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.snippets mark, div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_orders } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let mrn = params.mrn, offset = 30;
let reports = await get_api_orders({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, reports, offset
};
}

View File

@ -0,0 +1,166 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, isInViewport, filter_pattern, filter_test, filter_mark } from '$lib/util.js';
import { get_api_orders } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _uid: x.body, _ts: new Date(x.datetime_entered) }, x));
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_orders({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>Orders</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{selection.text || ''}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid"><dl class="report">{#each Object.entries(selection) as entry}{#if entry[0].charAt(0) != '_'}<dt>{entry[0]}</dt><dd>{entry[1]}</dd>{/if}{/each}</dl></div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">Orders</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.text || ''}</div>
<div class="report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.text || ''}</div>
<div class="report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let clinics = await (await fetch('/api/clinic/list')).json();
clinics.reduce((acc, item) => (acc[item.name] = item, acc), clinics);
let selection = await (await fetch('/api/config/user/clinics')).json();
selection.forEach(x => clinics[x] ? clinics[x].active = true : false);
return {
clinics
};
}

View File

@ -0,0 +1,45 @@
<script>
export let data;
let filter = '';
$: filter = filter.toUpperCase();
$: selection = data.clinics.filter(row => row.active);
$: {
fetch('/api/config/user/clinics', { method: 'PUT', headers: { 'Content-type': 'application/json' }, body: JSON.stringify(selection.map(row => row.name)) });
}
</script>
<svelte:head>
<title>Clinics</title>
</svelte:head>
<h1>Clinics</h1>
<div class="card">
<div class="input-group">
<span class="input-group-text">🔎</span>
<input type="text" class="form-control" placeholder="Clinic" bind:value={filter} />
</div>
{#if filter.length > 0}
<ul class="list-group list-group-flush">
{#each data.clinics as row}{#if (row.name.charAt(0) != 'Z') && (row.name != 'DELETED CLINIC') && (row.name != 'CLINIC DELETED') && (row.name.startsWith(filter))}<li class="list-group-item" class:active={row.active} on:click={evt => row.active = !row.active}>{row.name}</li>{/if}{/each}
</ul>
{/if}
{#if selection.length > 0}
<div class="card-footer">
{#each selection as row}<span class="badge text-bg-primary">{row.name} <span on:click={evt => data.clinics[row.name].active = false}>❌</span></span>{/each}
</div>
{/if}
</div>
<style>
.list-group {
max-height: 50vh;
overflow-y: auto;
}
.list-group-item {
cursor: default;
}
.card-footer .badge:not(:last-child) {
margin-right: 0.25em;
}
</style>

View File

@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
import { get_api_lookup } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ url, fetch }) {
let query = (url.searchParams.get('q') || '').replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
let ordinal = parseInt(url.searchParams.get('ordinal') || '');
let name = (url.searchParams.get('name') || '').replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase();
let items = query ? await get_api_lookup({ fetch, query }) : [];
if(ordinal) items = items.filter(row => row.ordinal == ordinal);
else if(name) items = items.filter(row => row.name.startsWith(name));
let detail, match;
if((items.length == 1) && (url.searchParams.get('rd'))) {
detail = await get_api_lookup({ fetch, query, ordinal: (items[0].ordinal || '0'), force: url.searchParams.get('force') });
if(match = /(^[^\r\n;]+);(?:\([^\)]*?\))? (?:(\d+) )?(\d{3}-\d{2}-\d{4}P?) (.+?)\s*$/m.exec(detail)) {
if(match[2]) throw redirect(302, '/chart/' + match[2]);
if(match[3]) throw redirect(302, '/chart/' + match[3].replace(/[^\dP]/g, ''));
}
}
return {
query, ordinal, name, items, detail
};
}

View File

@ -0,0 +1,62 @@
<script>
import { tick } from 'svelte';
import { page } from '$app/stores';
export let data;
let ref = null;
$: tick().then(() => ref ? ref.focus() : null);
</script>
<svelte:head>
<title>Lookup</title>
</svelte:head>
<h1>Lookup</h1>
<div class="card mb-3 shadow">
<form method="get" action="?">
<div class="input-group">
<span class="input-group-text">🔎</span>
<input type="text" class="form-control" placeholder="Lookup" name="q" bind:value={data.query} bind:this={ref} />
<button type="submit" class="btn btn-primary">Search</button>
</div>
</form>
{#if data.items.length > 0}
<table class="table" data-sveltekit-preload-data="tap">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">DOB</th>
<th scope="col">SSN</th>
<th scope="col"></th>
<th scope="col">Type</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{#each data.items as row}
<tr>
<td><a href="/lookup?q={data.query}&ordinal={row.ordinal || 0}&rd=true">{row.name}{#if row.alias}{' (' + row.alias + ')'}{/if}</a></td>
<td>{row.dob}</td>
<td>{row.ssn}</td>
<td>{row.yesno}</td>
<td>{row.type}</td>
<td>{row.no}</td>
</tr>
{/each}
</tbody>
</table>
{#if (data.items.length) == 1 && (data.detail)}
<div class="card-body">
<pre class="card-text">{data.detail}</pre>
<p class="card-text"><a class="btn btn-danger" href="/lookup?q={data.query}&ordinal={data.items[0].ordinal || 0}&rd=true&force=true">Proceed to {data.items[0].name}</a></p>
</div>
{/if}
{/if}
</div>
<style>
.card table.table {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_rcrs_patients } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let offset = 365;
let reports = await get_api_rcrs_patients({ fetch, alpha: 'T-' + offset, omega: 'N' });
return {
reports, offset
};
}

View File

@ -0,0 +1,491 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, isInViewport, filter_pattern, filter_test, filter_mark } from '$lib/util.js';
import { get_api_rcrs_patients } from '$lib/backend.js';
export let data;
const sitecodes = {
'C00.0': 'External lip upper',
'C00.1': 'External lip lower',
'C00.2': 'External lip NOS',
'C00.3': 'Upper lip, mucosa',
'C00.4': 'Lower lip, mucosa',
'C00.5': 'Mucosa lip, NOS',
'C00.6': 'Commissure lip',
'C00.8': 'Overlapping lesion of lip',
'C00.9': 'Lip, NOS',
'C01.9': 'Base of tongue, NOS',
'C02.0': 'Dorsal surface tongue, NOS',
'C02.1': 'Border of tongue',
'C02.2': 'Ventral surface of tongue NOS',
'C02.3': 'Anterior 2/3 of tongue NOS',
'C02.4': 'Lingual tonsil',
'C02.8': 'Overlapping lesion of tongue',
'C02.9': 'Tongue NOS',
'C03.0': 'Upper gum',
'C03.1': 'Lower gum',
'C03.9': 'Gum NOS',
'C04.0': 'Anterior floor of mouth',
'C04.1': 'Lateral floor of mouth',
'C04.8': 'Overlapping lesion of floor of mouth',
'C04.9': 'Floor of mouth NOS',
'C05.0': 'Hard palate',
'C05.1': 'Soft palate NOS (excludes Nasopharyngcal surface C11.3)',
'C05.2': 'Uvula',
'C05.8': 'Overlapping lesion of palate',
'C05.9': 'Palate NOS',
'C06.0': 'Cheek mucosa',
'C06.1': 'Vestibule of mouth',
'C06.2': 'Retromolar area',
'C06.8': 'Overlapping lesion of other and unspecified parts of mouth',
'C06.9': 'Mouth NOS',
'C07.9': 'Parotid gland',
'C08.0': 'Submaxillary gland',
'C08.1': 'Sublingual gland',
'C08.8': 'Overlapping lesion of major salivary glands',
'C08.9': 'Major salivary gland, NOS',
'C09.0': 'Tonsillar fossa',
'C09.1': 'Tonsillar pillar',
'C09.8': 'Overlapping lesion of tonsil',
'C09.9': 'Tonsil NOS (excludes Lingual tonsil C02.4 and Pharyngeal tonsil C11.1)',
'C10.0': 'Vallecula',
'C10.1': 'Anterior surface of epiglottis',
'C10.2': 'Lateral wall oropharynx',
'C10.3': 'Posterior wall oropharynx',
'C10.4': 'Branchial cleft (site of neoplosm)',
'C10.8': 'Overlapping lesion of oropharynx',
'C10.9': 'Oropharynx NOS',
'C11.0': 'Superior wall of nasopharynx',
'C11.1': 'Posterior wall nasopharynx',
'C11.2': 'Lateral wall nasopharynx',
'C11.3': 'Anterior wall nasopharynx',
'C11.8': 'Overlapping lesion of nasopharynx',
'C11.9': 'Nasopharynx NOS',
'C12.9': 'Pyriform sinus',
'C13.0': 'Postcricoid region',
'C13.1': 'Hypopharyngeal aspect of aryepiglottic fold',
'C13.2': 'Posterior wall hypopharynx',
'C13.8': 'Overlapping lesion of hypopharynx',
'C13.9': 'Hypopharynx, NOS',
'C14.0': 'Pharynx NOS',
'C14.2': 'Waldeyer\'s ring',
'C14.8': 'Overlapping lesion of lip, oral cavity and pharynx',
'C15.0': 'Cervical esophagus',
'C15.1': 'Thoracic esophagus',
'C15.2': 'Abdominal esophagus',
'C15.3': 'Upper third of esophagus',
'C15.4': 'Middle third of esophagus',
'C15.5': 'Esophagus lower third',
'C15.8': 'Overlapping lesion of esophagus',
'C15.9': 'Esophagus NOS',
'C16.0': 'Cardia, NOS',
'C16.1': 'Fundus stomach',
'C16.2': 'Body stomach',
'C16.3': 'Gastric antrum',
'C16.4': 'Pylorus',
'C16.5': 'Lesser curvature of stomach, NOS (not classifiable to C16.1 to C16.4)',
'C16.6': 'Greater curvature of stomach, NOS (not classifiable to C16.0 to C16.4)',
'C16.8': 'Overlapping lesion of stomach',
'C16.9': 'Stomach NOS',
'C17.0': 'Duodenum',
'C17.1': 'Jejunum',
'C17.2': 'Ileum (excludes ileocecal valve C18.0)',
'C17.3': 'Meckel\'s diverticulum (site of neoplasm)',
'C17.8': 'Overlapping lesion of small intestine',
'C17.9': 'Small intestine NOS',
'C18.0': 'Cecum',
'C18.1': 'Appendix',
'C18.2': 'Ascending colon',
'C18.3': 'Hepatic flexure of colon',
'C18.4': 'Transverse colon',
'C18.5': 'Splenic flexure of colon',
'C18.6': 'Descending colon',
'C18.7': 'Sigmoid colon',
'C18.8': 'Overlapping lesion of colon',
'C18.9': 'Colon NOS',
'C19.9': 'Rectosigmoid junction',
'C20.9': 'Rectum, NOS',
'C21.0': 'Anus, NOS (excludes Skin of anus and Perianal skin (C44.5)',
'C21.1': 'Anal canal',
'C21.2': 'Cloacogenic zone',
'C21.8': 'Overlapping lesion of rectum, anus and anal canal',
'C22.0': 'Liver',
'C22.1': 'Intrahepatic bile duct',
'C23.9': 'Gallbladder',
'C24.0': 'Extrahepatic bile duct',
'C24.1': 'Ampulla of Vater',
'C24.8': 'Overlapping lesion of biliary tract',
'C24.9': 'Biliary tract, NOS',
'C25.0': 'Head of pancreas',
'C25.1': 'Body pancreas',
'C25.2': 'Tail pancreas',
'C25.3': 'Pancreatic duct',
'C25.4': 'Islets of Langerhans',
'C25.7': 'Neck of pancreas',
'C25.8': 'Overlapping lesion of pancreas',
'C25.9': 'Pancreas NOS',
'C26.0': 'Intestinal tract, NOS',
'C26.8': 'Overlapping lesion of digestive system',
'C26.9': 'Gastrointestinal tract, NOS',
'C30.0': 'Nasal cavity (excludes Nose, NOS C76.0)',
'C30.1': 'Middle ear',
'C31.0': 'Maxillary sinus',
'C31.1': 'Ethmoid sinus',
'C31.2': 'Frontal sinus',
'C31.3': 'Sphenoid sinus',
'C31.8': 'Overlapping lesion of accessory sinuses',
'C31.9': 'Accessory sinus, NOS',
'C32.0': 'Glottis',
'C32.1': 'Supraglottis',
'C32.2': 'Subglottis',
'C32.3': 'Laryngeal cartilage',
'C32.8': 'Overlapping lesion of larynx',
'C32.9': 'Larynx NOS',
'C33.9': 'Trachea',
'C34.0': 'Main bronchus',
'C34.1': 'Upper lobe, lung',
'C34.2': 'Middle lobe, lung',
'C34.3': 'Lower lobe, lung',
'C34.8': 'Overlapping lesion of lung',
'C34.9': 'Lung NOS',
'C37.9': 'Thymus',
'C38.0': 'Heart',
'C38.1': 'Anterior mediastinum',
'C38.2': 'Posterior mediastinum',
'C38.3': 'Mediastinum NOS',
'C38.4': 'Pleura NOS',
'C38.8': 'Overlapping lesion of heart, mediastinum and pleura',
'C39.0': 'Upper respiratory tract, NOS',
'C39.8': 'Overlapping lesion of respiratory system and intrathoracic organs',
'C39.9': 'Respiratory tract, NOS',
'C40.0': 'Upper limb long bones, joints',
'C40.1': 'Upper limb short bones, joints',
'C40.3': 'Lower limb short bones, joints',
'C40.8': 'Overlapping lesion of bones, joints and articular cartilage of limbs',
'C40.9': 'Bone limb, NOS',
'C41.0': 'Skull and facial bone',
'C41.1': 'Mandible',
'C41.2': 'Vertebral column (excludes Sacrum and Coccyx C41.4)',
'C41.3': 'Rib, sternum, clavicle',
'C41.4': 'Pelvic bone',
'C41.8': 'Overlapping lesion of bones, joints and articular cartilage',
'C41.9': 'Bone NOS',
'C42.0': 'Blood',
'C42.1': 'Bone marrow',
'C42.2': 'Spleen',
'C42.3': 'Reticuloendothelial system, NOS',
'C42.4': 'Hematopoietic system, NOS',
'C44.0': 'Skin lip, NOS',
'C44.1': 'Eyelid NOS',
'C44.2': 'External ear',
'C44.3': 'Skin face',
'C44.4': 'Skin scalp, neck',
'C44.5': 'Skin trunk',
'C44.6': 'Skin limb, upper',
'C44.7': 'Skin limb, lower',
'C47.0': 'Peripheral nerve head, neck',
'C47.1': 'Peripheral nerve shoulder, arm',
'C47.2': 'Peripheral nerve leg',
'C47.3': 'Peripheral nerve thorax (excludes Thymus, Heart and Mediastinum C37. , C38. )',
'C47.4': 'Peripheral nerve abdomen',
'C47.5': 'Peripheral nerve pelvis',
'C47.6': 'Peripheral nerve trunk',
'C47.8': 'Overlapping lesion of peripheral nerves and autonomic nervous system',
'C47.9': 'Autonomic nervous system NOS',
'C48.0': 'Retroperitoneum',
'C48.1': 'Peritoneum',
'C48.2': 'Peritoneum NOS',
'C48.8': 'Overlapping lesion of retroperitoneum and peritoneum',
'C49.0': 'Connective tissue head',
'C49.1': 'Connective tissue arm',
'C49.2': 'Connective tissue leg',
'C49.3': 'Connective tissue thorax (excludes Thymus, Heart and Mediastinum C37. , C38. )',
'C49.4': 'Connective tissue abdomen',
'C49.5': 'Connective tissue pelvis',
'C49.6': 'Connective tissue trunk, NOS',
'C49.8': 'Overlapping lesion of connective, subcutaneous and other soft tissues',
'C49.9': 'Connective tissue NOS',
'C50.0': 'Nipple',
'C50.1': 'Central portion of breast',
'C50.2': 'Upper inner quadrant of breast',
'C50.3': 'Lower inner quadrant of breast',
'C50.4': 'Upper outer quadrant of breast',
'C50.5': 'Lower outer quadrant of breast',
'C50.6': 'Axillary tail of breast',
'C50.8': 'Overlapping lesion of breast',
'C50.9': 'Breast NOS (excludes Skin of breast C44.5)',
'C51.0': 'Labium majus',
'C51.1': 'Labium minus',
'C51.2': 'Clitoris',
'C51.8': 'Overlapping lesion of vulva',
'C51.9': 'Vulva, NOS',
'C52.9': 'Vagina, NOS',
'C53.0': 'Endocervix',
'C53.1': 'Exocervix',
'C53.8': 'Overlapping lesion of cervix uteri',
'C53.9': 'Cervix uteri',
'C54.0': 'Isthmus uteri',
'C54.1': 'Endometrium',
'C54.2': 'Myometrium',
'C54.3': 'Fundus uteri',
'C54.8': 'Overlapping lesion of corpus uteri',
'C54.9': 'Corpus uteri',
'C55.9': 'Uterus NOS',
'C56.9': 'Ovary',
'C57.0': 'Fallopian tube',
'C57.1': 'Broad ligament',
'C57.2': 'Round ligament',
'C57.3': 'Parametrium',
'C57.4': 'Uterine adnexa',
'C57.7': 'Wolffian body',
'C57.8': 'Overlapping lesion of female genital organs',
'C57.9': 'Female genital tract, NOS',
'C60.0': 'Prepuce',
'C60.1': 'Glans penis',
'C60.2': 'Body penis',
'C60.8': 'Overlapping lesion of penis',
'C60.9': 'Penis NOS',
'C61.9': 'Prostate gland',
'C62.0': 'Undescended testis (site of neoplasm)',
'C62.1': 'Descended testis',
'C62.9': 'Testis NOS',
'C63.0': 'Epididymis',
'C63.1': 'Spermatic cord',
'C63.2': 'Scrotum, NOS',
'C63.7': 'Tunica vaginalis',
'C63.8': 'Overlapping lesion of male genital organs',
'C63.9': 'Male genital organs, NOS',
'C64.9': 'Kidney NOS',
'C65.9': 'Renal pelvis',
'C66.9': 'Ureter',
'C67.0': 'Trigone, bladder',
'C67.1': 'Dome, bladder',
'C67.2': 'Lateral wall bladder',
'C67.4': 'Posterior wall bladder',
'C67.6': 'Ureteric orifice',
'C67.7': 'Urachus',
'C67.8': 'Overlapping lesion of bladder',
'C67.9': 'Bladder NOS',
'C68.0': 'Urethra',
'C68.1': 'Paraurethral gland',
'C68.8': 'Overlapping lesion of urinary organs',
'C68.9': 'Urinary system, NOS',
'C69.0': 'Conjunctiva',
'C69.1': 'Cornea, NOS',
'C69.2': 'Retina',
'C69.3': 'Choroid',
'C69.4': 'Ciliary body',
'C69.5': 'Lacrimal gland',
'C69.6': 'Orbit NOS',
'C69.8': 'Overlapping lesion of eye and adnexa',
'C69.9': 'Eye NOS',
'C70.0': 'Cerebral meninges',
'C70.1': 'Spinal meninges',
'C70.9': 'Meninges NOS',
'C71.0': 'Cerebrum',
'C71.1': 'Frontal lobe',
'C71.2': 'Temporal lobe',
'C71.3': 'Parietal lobe',
'C71.4': 'Occipital lobe',
'C71.5': 'Ventricle NOS',
'C71.6': 'Cerebellum, NOS',
'C71.7': 'Brain stem',
'C71.8': 'Overlapping lesion of brain',
'C71.9': 'Brain NOS',
'C72.0': 'Spinal cord',
'C72.1': 'Cauda equina',
'C72.2': 'Olfactory nerve',
'C72.3': 'Optic nerve',
'C72.4': 'Acoustic nerve',
'C72.5': 'Cranial nerve, NOS',
'C72.8': 'Overlapping lesion of brain and central nervous system',
'C72.9': 'Nervous system NOS',
'C73.9': 'Thyroid gland',
'C74.0': 'Adrenal gland cortex',
'C74.1': 'Adrenal gland medulla',
'C74.9': 'Adrenal gland NOS',
'C75.0': 'Parathyroid gland',
'C75.1': 'Pituitary gland',
'C75.2': 'Craniopharyngeal duct',
'C75.3': 'Pineal gland',
'C75.4': 'Carotid body',
'C75.5': 'Aortic body',
'C75.8': 'Overlapping lesion of endocrine glands and related structures',
'C75.9': 'Endocrine gland, NOS',
'C76.0': 'Head, face or neck NOS',
'C76.1': 'Thorax NOS',
'C76.2': 'Abdomen NOS',
'C76.3': 'Pelvis NOS',
'C76.4': 'Upper limb NOS',
'C76.5': 'Lower limb NOS',
'C76.7': 'Other illdefined sites',
'C76.8': 'Overlapping lesion of ill-defined sites',
'C77.0': 'Lymph node face, head ,neck',
'C77.1': 'Intrathoracic lymph node',
'C77.2': 'Intra-abdominal lymph nodes',
'C77.3': 'Lymph node axilla, arm',
'C77.4': 'Lymph node inguinal region, leg',
'C77.5': 'Lymph node pelvic',
'C77.8': 'Lymph nodes of multiple regions',
'C77.9': 'Lymph node NOS',
'C80.9': 'Unknown primary site'
};
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00') + '\x00' + x.tumors.map(y => y.meta.primarySite + '\x00' + sitecodes[y.meta.primarySite]).join('\x00')), _uid: x.last5 + x.name + x.tumors.map(y => y.meta.primarySite).join(' ') }, x));
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_rcrs_patients({ omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>RCRS</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{selection.last5} {selection.name}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid"><dl class="report">{#each Object.entries(selection) as entry}{#if entry[0].charAt(0) != '_'}<dt>{entry[0]}</dt><dd>{typeof entry[1] == 'string' ? entry[1] : JSON.stringify(entry[1])}</dd>{/if}{/each}</dl></div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">RCRS</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.last5} {row.name} {row.tumors.map(x => x.meta.primarySite).join(' ')}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.last5} {row.name} {row.tumors.map(x => x.meta.primarySite).join(' ')}</div>
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,117 @@
<script>
const id_detail = Math.random().toString(36).replace('0.', 'id-');
let innerWidth = 0;
$: wide = innerWidth > 768;
</script>
<svelte:window bind:innerWidth />
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#{id_detail}" aria-controls="{id_detail}">Enable both scrolling & backdrop</button>
<div class="offcanvas" class:offcanvas-end={wide} class:offcanvas-bottom={!wide} data-bs-scroll="true" tabindex="-1" id="{id_detail}" aria-labelledby="{id_detail}-label">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="{id_detail}-label">Backdrop with scrolling</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<p>Try scrolling the rest of the page to see this option in action.</p>
</div>
</div>
<div class="card mb-3 shadow">
<ul class="list-group list-group-flush">
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item"><a data-bs-toggle="offcanvas" href="#{id_detail}">Vestibulum at eros</a></li>
</ul>
</div>
<style>
/*
.card {
position: absolute;
top: 0;
bottom: 0;
margin-top: 8rem;
overflow: auto;
}
*/
</style>

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

10
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({ fallback: '200.html' })
}
};
export default config;

6
frontend/vite.config.js Normal file
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});

183
kvstore.py Normal file
View File

@ -0,0 +1,183 @@
#!/usr/bin/env python3
import re
import uuid
import time
import json
import sqlite3
from typing import Optional, Union, Sequence
class KVStore(object):
"""Provide a generic key-value store backed by SQLite"""
def __init__(self, database: Union[str, sqlite3.Connection]=':memory:'):
self.db = database if isinstance(database, sqlite3.Connection) else sqlite3.connect(database)
def put(self, obj):
raise NotImplementedError
def get(self, uid):
raise KeyError
class KVStoreView(object):
"""Hold a reference to a view"""
def __init__(self, store: KVStore, view_name: str, schema_name: str, attributes: Sequence[str]):
self.store = store
self.view_name = view_name
self.schema_name = schema_name
def put(self, obj, **kw):
"""Insert an object"""
return self.store.put(obj, schema=self.schema_name, **kw)
def get(self, uid, **kw):
"""Retrieve an object, with optional default"""
res = self.store.get(uid, **kw)
if 'schema' in res and res['schema'] != self.schema_name:
raise KeyError(uid)
def delete(self, where: str, params: Sequence):
"""Remove underlying data"""
return self.store.view_delete(self.view_name, where, params)
class KVStoreEAV(KVStore):
"""Provide a key-value store backed by SQLite with EAV pattern"""
def __init__(self, database: Union[str, sqlite3.Connection]=':memory:', table_name: str='eavstore'):
KVStore.__init__(self, database)
self.table_name = table_name
self.table_name_q = quote_identifier(table_name)
self.entity_column_name = 'uid'
self.attribute_column_name = '__attribute__'
self.value_column_name = '__value__'
self.data_attribute_name = '__data__'
self.db.execute(f'CREATE TABLE IF NOT EXISTS {self.table_name_q} ({self.entity_column_name} TEXT, {self.attribute_column_name} TEXT, {self.value_column_name} INTEGER)')
self.db.execute(f'CREATE INDEX IF NOT EXISTS {quote_identifier(f"idx_{table_name}_entity")} on {self.table_name_q} ({self.entity_column_name})')
self.db.execute(f'CREATE UNIQUE INDEX IF NOT EXISTS {quote_identifier(f"idx_{table_name}_attribute")} on {self.table_name_q} ({self.entity_column_name}, {self.attribute_column_name})')
self.db.execute(f'CREATE UNIQUE INDEX IF NOT EXISTS {quote_identifier(f"idx_{table_name}_value")} on {self.table_name_q} ({self.entity_column_name}, {self.attribute_column_name}, {self.value_column_name})')
def put(self, obj, **kw):
"""Insert an object"""
if 'uid' in obj:
self.db.execute(f'DELETE FROM {self.table_name_q} WHERE {self.entity_column_name}=?', (obj['uid'],))
else:
obj['uid'] = uuid.uuid4().hex
if 'ts' not in obj:
obj['ts'] = time.time()
if 'schema' in kw:
obj['schema'] = kw['schema']
uid = obj['uid']
self.db.executemany(f'INSERT INTO {self.table_name_q} ({self.entity_column_name}, {self.attribute_column_name}, {self.value_column_name}) VALUES (?, ?, ?)', ((uid, k, v) for k, v in obj.items() if k != 'uid'))
if self.data_attribute_name not in obj:
self.db.execute(f'INSERT INTO {self.table_name_q} ({self.entity_column_name}, {self.attribute_column_name}, {self.value_column_name}) VALUES (?, ?, ?)', (uid, self.data_attribute_name, json.dumps(obj, default=str, separators=(',', ':'))))
return obj
def get(self, uid, **kw):
"""Retrieve an object, with optional default"""
obj = {k: v for k, v in self.db.execute(f'SELECT {self.attribute_column_name}, {self.value_column_name} FROM {self.table_name_q} WHERE {self.entity_column_name}=?', (uid,))}
if len(obj) > 0:
obj['uid'] = uid
return obj
elif 'default' in kw:
return kw['default']
else:
raise KeyError(uid)
def ensure_view(self, view_name: str, schema_name: str, attributes: Sequence[str], clear: bool=False) -> KVStoreView:
"""Create a view over the key-value schema, optionally removing existing underlying data"""
self.db.execute(f'DROP VIEW IF EXISTS {quote_identifier(view_name)}')
self.db.execute(eav_query_ensure_view(view_name, self.table_name, self.entity_column_name, self.attribute_column_name, self.value_column_name, schema_name, attributes))
if clear:
self.db.execute(f'DELETE FROM {self.table_name_q} WHERE {self.entity_column_name} IN (SELECT {self.entity_column_name} FROM {quote_identifier(view_name)} WHERE schema=?)', (schema_name,))
return KVStoreView(self, view_name, schema_name, attributes)
def view_delete(self, view_name: str, where: str, params: Sequence):
"""Remove underlying data"""
self.db.execute(f'DELETE FROM {self.table_name_q} WHERE {self.entity_column_name} IN (SELECT {self.entity_column_name} FROM {quote_identifier(view_name)} WHERE ' + where + ')', params)
class KVStoreJSON(KVStore):
"""Provide a key-value store backed by SQLite with JSON rows"""
def __init__(self, database: Union[str, sqlite3.Connection]=':memory:', table_name: str='jsonstore'):
KVStore.__init__(self, database)
self.table_name = table_name
self.table_name_q = quote_identifier(table_name)
self.uid_column_name = 'uid'
self.data_column_name = '__data__'
self.db.execute(f'CREATE TABLE IF NOT EXISTS {self.table_name_q} ({self.uid_column_name} TEXT, {self.data_column_name} JSON)')
self.db.execute(f'CREATE UNIQUE INDEX IF NOT EXISTS {quote_identifier(f"idx_{table_name}_uid")} ON {self.table_name_q} ({self.uid_column_name})')
self.db.execute(f'CREATE INDEX IF NOT EXISTS {quote_identifier(f"idx_{table_name}_ts")} ON {self.table_name_q} (JSON_EXTRACT({self.data_column_name}, "$.ts"))')
self.db.execute(f'CREATE INDEX IF NOT EXISTS {quote_identifier(f"idx_{table_name}_schema")} ON {self.table_name_q} (JSON_EXTRACT({self.data_column_name}, "$.schema"))')
def put(self, obj, **kw):
"""Insert an object"""
obj = obj.copy()
if 'uid' not in obj:
obj['uid'] = uuid.uuid4().hex
if 'ts' not in obj:
obj['ts'] = time.time()
if 'schema' in kw:
obj['schema'] = kw['schema']
self.db.execute(f'INSERT INTO {self.table_name_q} (uid, {self.data_column_name}) VALUES (?, JSON(?))', (obj['uid'], json.dumps(obj, default=str, separators=(',', ':'))))
return obj
def get(self, uid, **kw):
"""Retrieve an object, with optional default"""
for row in self.db.execute(f'SELECT {self.data_column_name} FROM {self.table_name_q} WHERE uid=? LIMIT 1', (uid,)):
return json.loads(row[0])
if 'default' in kw:
return kw['default']
else:
raise KeyError(uid)
def ensure_view(self, view_name: str, schema_name: str, attributes: Sequence[str], clear: bool=False) -> KVStoreView:
"""Create a view over the key-value schema, optionally removing existing underlying data"""
self.db.execute(f'DROP VIEW IF EXISTS {quote_identifier(view_name)}')
self.db.execute(json_query_ensure_view(view_name, self.table_name, self.uid_column_name, self.data_column_name, schema_name, attributes))
if clear:
self.db.execute(f'DELETE FROM {self.table_name_q} WHERE JSON_EXTRACT({self.data_column_name}, "$.schema")=?', (schema_name,))
return KVStoreView(self, view_name, schema_name, attributes)
def view_delete(self, view_name: str, where: str, params: Sequence):
"""Remove underlying data"""
self.db.execute(f'DELETE FROM {self.table_name_q} WHERE {self.uid_column_name} IN (SELECT {self.uid_column_name} FROM {quote_identifier(view_name)} WHERE ' + where + ')', params)
def quote_string(s: str) -> str:
"""Make string safe for SQLite"""
return '"' + s.replace('"', '""') + '"'
def quote_identifier(s: str) -> str:
"""Make string safe as SQLite identifier"""
if re.match(r'^[A-Za-z][0-9A-Za-z_]*$', s):
return s
else:
return '"' + s.replace('"', '""') + '"'
def eav_query_pivot(table_name: str, entity_column_name: str, attribute_column_name: str, value_column_name: str, attributes: Sequence[str]) -> str:
"""Group EAV rows into traditional rows"""
if entity_column_name in attributes:
attributes = list(attributes)
attributes.remove(entity_column_name)
q = f'SELECT {quote_identifier(entity_column_name)}\n'
for attr in attributes:
q += f', MAX(CASE WHEN {quote_identifier(attribute_column_name)}={quote_string(attr)} THEN {quote_identifier(value_column_name)} END) {quote_identifier(attr)}\n'
q += f'FROM {quote_identifier(table_name)} GROUP BY {quote_identifier(entity_column_name)}'
return q
def eav_query_ensure_view(view_name: str, table_name: str, entity_column_name: str, attribute_column_name: str, value_column_name: str, schema_name: str, attributes: Sequence[str]) -> str:
"""Generate SQL to create a view over the grouped EAV data"""
if 'schema' not in attributes:
attributes = tuple(attributes) + ('schema',)
if entity_column_name in attributes:
attributes = list(attributes)
attributes.remove(entity_column_name)
return f'CREATE TEMPORARY VIEW IF NOT EXISTS {quote_identifier(view_name)} ({quote_identifier(entity_column_name)}, ' +\
', '.join(quote_identifier(attr) for attr in attributes) + ') AS\n' +\
eav_query_pivot(table_name, entity_column_name, attribute_column_name, value_column_name, attributes) +\
f'\nHAVING schema={quote_string(schema_name)}'
def json_query_pivot(table_name: str, uid_column_name: str, data_column_name: str, attributes: Sequence[str]) -> str:
"""Expand JSON rows into traditional rows"""
if uid_column_name in attributes:
attributes = list(attributes)
attributes.remove(uid_column_name)
return f'SELECT {quote_identifier(uid_column_name)}, ' +\
', '.join(f'JSON_EXTRACT({quote_identifier(data_column_name)}, {quote_identifier("$." + attr)}) AS {quote_identifier(attr)}' for attr in attributes) +\
f' FROM {quote_identifier(table_name)}'
def json_query_ensure_view(view_name: str, table_name: str, uid_column_name: str, data_column_name: str, schema_name: str, attributes: Sequence[str]) -> str:
"""Generate SQL to create a view over the expanded JSON data"""
if 'schema' not in attributes:
attributes = tuple(attributes) + ('schema',)
if uid_column_name in attributes:
attributes = list(attributes)
attributes.remove(uid_column_name)
return f'CREATE TEMPORARY VIEW IF NOT EXISTS {quote_identifier(view_name)} ({quote_identifier(uid_column_name)}, ' +\
', '.join(quote_identifier(attr) for attr in attributes) + ') AS\n' +\
json_query_pivot(table_name, uid_column_name, data_column_name, attributes) +\
f'\nWHERE JSON_EXTRACT({quote_identifier(data_column_name)}, {quote_identifier("$.schema")})={quote_string(schema_name)}'

248
main.py Normal file
View File

@ -0,0 +1,248 @@
#!/usr/bin/env python3
import re
import sys
import getpass
import asyncio
import configparser
import autoproc
import ext_session
import ext_discovery
import ext_scheduling
import ext_patient
import ext_measurement
import ext_lab
import ext_note
import ext_order
import ext_rcrs
from typing import Optional, Union, Generator
import logging
logger = logging.getLogger(__name__)
class Config(configparser.ConfigParser):
def __init__(self, *filenames, **kw):
configparser.ConfigParser.__init__(self, **kw)
self.filenames = filenames
self.reread()
def reread(self, *args, **kw) -> list:
configparser.ConfigParser.read(self, self.filenames, *args, **kw)
return self
def set(self, section: str, key: str, value: str):
target = configparser.ConfigParser()
target.read(self.filenames[-1])
if not target.has_section(section):
target.add_section(section)
target[section][key] = value
with open(self.filenames[-1], 'w+') as f:
target.write(f)
configparser.ConfigParser.read(self, self.filenames[-1])
return self
async def stdin_reader(opts: dict) -> Generator[bytes, None, None]:
"""Read from stdin, filter through input_filter, and write into pipe"""
try:
loop = asyncio.get_event_loop()
while True:
if opts.get('stdin.echo', True): # input with line editor
yield (await loop.run_in_executor(None, sys.stdin.readline)).rstrip('\r\n')
else: # input hiding characters
yield await loop.run_in_executor(None, getpass.getpass, '')
except ConnectionResetError:
logger.info('ConnectionResetError: stdin_reader')
except KeyboardInterrupt:
logger.info('KeyboardInterrupt: stdin_reader')
async def log_writer(proc, buffer):
with autoproc.subscribe(proc) as pipequeue:
while True:
buffer.write(await pipequeue.get())
import sqlite3, time, pickle
def memoize(database: Union[str, sqlite3.Connection]=':memory:', table: str='tempmemo', prefix: Optional[str]=None, ttl: float=86400, persist=False, cast=None):
db = database if isinstance(database, sqlite3.Connection) else sqlite3.connect(database)
db.execute(f'CREATE {"" if persist else "TEMPORARY "}TABLE IF NOT EXISTS {table} (uid TEXT PRIMARY KEY, result BLOB, expiry FLOAT)')
db.execute(f'CREATE UNIQUE INDEX IF NOT EXISTS idx_{table}_uid ON {table} (uid)')
db.execute(f'CREATE INDEX IF NOT EXISTS idx_{table}_expiry ON {table} (expiry)')
def memoizer(func):
if asyncio.iscoroutinefunction(func):
async def wrapper(*args, **kw):
uid = f'{prefix or func.__name__}:{repr(args)}{repr(kw)}'.encode('utf-8')
for row in db.execute(f'SELECT result FROM {table} WHERE uid=? AND expiry>? LIMIT 1', (uid, time.time())):
return cast(pickle.loads(row[0])) if cast else pickle.loads(row[0])
result = await func(*args, **kw)
with db:
db.execute(f'INSERT OR REPLACE INTO {table} (uid, result, expiry) VALUES (?, ?, ?)', (uid, pickle.dumps(result), time.time() + ttl))
return result
else:
def wrapper(*args, **kw):
uid = f'{prefix or func.__name__}:{repr(args)}{repr(kw)}'.encode('utf-8')
for row in db.execute(f'SELECT result FROM {table} WHERE uid=? AND expiry>? LIMIT 1', (uid, time.time())):
return cast(pickle.loads(row[0])) if cast else pickle.loads(row[0])
result = func(*args, **kw)
with db:
db.execute(f'INSERT OR REPLACE INTO {table} (uid, result, expiry) VALUES (?, ?, ?)', (uid, pickle.dumps(result), time.time() + ttl))
return result
wrapper.__name__ = func.__name__
return wrapper
return memoizer
def application(proc, config):
from quart import Quart, request, send_from_directory
app = Quart(__name__)
db = sqlite3.connect('./cache.db')
from io import StringIO
buffer = StringIO()
proc.create_task(log_writer(proc, buffer), name='@task:log')
@app.route('/api/clinic/list')
@memoize(db, table='memo', prefix='clinics', ttl=30*86400, persist=True)
async def http_api_clinic_list():
return [item async for item in ext_discovery.cmd_listclinics(proc)]
@app.route('/api/appointments/<clinics>/<date>')
@memoize(db)
async def http_api_appointments(clinics, date):
clinics = clinics.strip()
return [item async for item in ext_scheduling.cmd_appointments(proc, clinics=clinics.replace('|', '/'), date=date.replace('|', '/'))] if len(clinics) > 0 else []
@app.route('/api/lookup/<query>')
@memoize(db)
async def http_api_lookup(query):
query = re.sub(r'\s+', ' ', query.replace('^', '').strip())
return (await ext_patient.cmd_lookup_patient(proc, query)) if len(query) > 1 else []
@app.route('/api/lookup/<query>/<int:ordinal>')
@app.route('/api/lookup/<query>/<int:ordinal>/<force>')
@memoize(db, cast=tuple)
async def http_api_lookup_ordinal(query, ordinal, force=False):
query = re.sub(r'\s+', ' ', query.replace('^', '').strip())
return (await ext_patient.cmd_lookup_patient_ordinal(proc, query, ordinal, not not force)) if len(query) > 1 else '', 200, { 'Content-type': 'text/plain' }
@app.route('/api/measurements/<mrn>/<alpha>/<omega>')
@memoize(db)
async def http_api_measurements(mrn, alpha, omega):
import util
return [item async for item in ext_measurement.cmd_entries(proc, mrn, util.vista_strptime(alpha), util.vista_strptime(omega))]
@app.route('/api/labs/<mrn>/<alpha>/<omega>')
@memoize(db)
async def http_api_labs(mrn, alpha, omega):
import util
return [item async for item in ext_lab.cmd_reports(proc, mrn, util.vista_strptime(alpha), util.vista_strptime(omega))]
@app.route('/api/notes/<mrn>/<alpha>/<omega>')
@memoize(db)
async def http_api_notes(mrn, alpha, omega):
import util
return [item async for item in ext_note.cmd_reports(proc, mrn, util.vista_strptime(alpha), util.vista_strptime(omega))]
@app.route('/api/orders/<mrn>/<alpha>/<omega>')
@memoize(db)
async def http_api_orders(mrn, alpha, omega):
import util
return [item async for item in ext_order.cmd_entries(proc, mrn, util.vista_strptime(alpha), util.vista_strptime(omega))]
@app.route('/api/rcrs/patients/<alpha>/<omega>')
@memoize(db)
async def http_api_rcrs_patients(alpha, omega):
import util
return [item async for item in ext_rcrs.cmd_patients(proc, util.vista_strptime(alpha), util.vista_strptime(omega))]
@app.route('/api/rcrs/tumors/<alpha>/<omega>')
@memoize(db)
async def http_api_rcrs_tumors(alpha, omega):
import util
return [item async for item in ext_rcrs.cmd_tumors(proc, util.vista_strptime(alpha), util.vista_strptime(omega))]
@app.route('/api/log.txt')
async def http_api_log():
return buffer.getvalue(), 200, { 'Content-type': 'text/plain' }
@app.route('/api/config/<section>/<key>', methods=('GET',))
async def get_api_config_value(section, key):
try:
return config[section][key] or '', 200, { 'Content-type': 'text/plain' }
except KeyError:
return '', 200, { 'Content-type': 'text/plain' }
@app.route('/api/config/<section>/<key>', methods=('PUT',))
async def put_api_config_value(section, key):
config.set(section, key, (await request.get_data()).decode('utf-8'))
return {}
@app.route('/favicon.png')
async def http_favicon():
return await send_from_directory('./frontend/build', 'favicon.png')
@app.route('/_app/<path:path>')
async def http_static(path):
return await send_from_directory('./frontend/build/_app', path)
@app.route('/')
@app.route('/<path:path>')
async def http_spa(path='index.html'):
return await send_from_directory('./frontend/build', '200.html')
return app
async def frontend_build():
import shutil
proc = None
try:
while True:
proc = await asyncio.create_subprocess_exec(shutil.which('npm'), 'run', '--prefix', 'frontend', 'build', '--', '--watch', stdin=None)
await proc.wait()
logger.warning(f'Frontend builder exited: {proc.returncode}')
except asyncio.exceptions.CancelledError:
pass
finally:
if proc is not None and proc.returncode is None:
logger.warning('Terminating frontend builder...')
await (await asyncio.create_subprocess_exec('taskkill', '/pid', str(proc.pid), '/t', '/f', stdin=None)).wait() # proc.terminate()
await proc.wait()
proc._transport.close() # https://github.com/python/cpython/issues/88050
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
async def main_async(opts: dict, dev=True):
import webbrowser, os
config = Config('./config.ini')
if dev:
builder = asyncio.create_task(frontend_build())
await asyncio.sleep(1)
while not os.path.isfile('./frontend/build/200.html'):
logger.warning('Waiting for frontend build...')
await asyncio.sleep(5)
try:
proc = await autoproc.create_instrumented_subprocess_exec('ssh', '-T', '-oStrictHostKeyChecking=no', 'nopvista@vista.northport.med.va.gov', stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, stdin_endl=b'\r')
asyncio.create_task(proc.wait())
if await ext_session.task_smartcard(proc, config): #proc.create_task(ext_session.task_smartcard(proc, config), name='@task:smartcard')
port = get_port()
asyncio.create_task(application(proc, config).run_task(port=port))
webbrowser.open(f'http://localhost:{port}/')
async for data in stdin_reader(opts):
#data = input_filter(data) # filter input, possibly spawning tasks
if proc.returncode is None and data is not None:
proc.sendline(data)
#await pipe.drain()
else:
return
finally:
if dev:
builder.cancel()
await builder
if __name__ == '__main__':
asyncio.run(main_async({}))

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
quart

121
util.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
import re
import datetime
import itertools
from collections import defaultdict
from typing import Any, Union, AsyncGenerator, Iterable
class BucketCache(object):
"""Cache data in buckets, with customizable bucket keys and fetch callback"""
def __init__(self, data: dict=None):
self.data = defaultdict(list, {} if data is None else data)
def add(self, value) -> None:
"""Add one value"""
self.data[bkt := self.bucket(self.bucketkey(value))].append(value)
def update(self, iterable: Iterable) -> None:
"""Add multiple values"""
for value in iterable:
self.add(iterable)
async def retrieve(self, key_start, key_stop, ctx=None) -> AsyncGenerator[Any, None]:
"""Retrieve range, calling fetch callback for fresh data if needed"""
bkt_start = self.bucket(key_start)
bkt_stop = self.bucket(key_stop)
missing = (idx for idx in range(bkt_start, bkt_stop + 1) if idx not in self.data)
for k, g in itertools.groupby(missing, key=(lambda n, c=itertools.count(): n - next(c))): # group values into ranges
async for value in self.fetch(self.unbucket((g := tuple(g))[0]), self.unbucket(g[-1]), ctx):
if bkt_start <= self.bucket(self.bucketkey(value)) <= bkt_stop:
self.add(value)
for idx in range(bkt_start, bkt_stop + 1): # create empty bucket if not exists
for value in self.data[idx]:
if key_start <= self.bucketkey(value) <= key_stop:
yield value
@staticmethod
def bucketkey(value):
"""Get bucket key from value"""
raise NotImplementedError
@staticmethod
def bucket(key) -> int:
"""Get bucket index from bucket key"""
raise NotImplementedError
@staticmethod
def unbucket(idx: int):
raise NotImplementedError
@staticmethod
async def fetch(start, stop, ctx=None) -> AsyncGenerator[Any, None]:
raise NotImplementedError
class TimeSeriesCache(BucketCache):
"""Cache time series data in daily buckets"""
@staticmethod
def bucket(key: datetime.datetime) -> int:
return ((key.date() if isinstance(key, datetime.datetime) else key) - datetime.date(1970, 1, 1)).days
@staticmethod
def unbucket(idx: int) -> datetime.datetime:
return datetime.date(1970, 1, 1) + datetime.timedelta(days=idx)
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{2}|\d{4})\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{2}|\d{4})\s*)' # M/d/yy, M/d/yyyy
re_dt_dMy = r'(?P<dt_dMy>((\d{1,2})[^\w@?]+[A-Z]{3,})[^\w@?]+(\d{2}|\d{4})\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)))
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)
elif m['ext'] == '~':
date = date - datetime.timedelta(microseconds=1)
if date.year < 1800:
raise ValueError('cannot specify year before 1800')
return date
else:
raise ValueError('invalid date/time')
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}'