// ==UserScript==
// @name Pending barcodes (Networked)
// @namespace http://tampermonkey.net/
// @version 3.1.0
// @description Sends barcodes to a local sync server and displays the shared list.
// @author Hamad AlShegifi
// @match *://his.kaauh.org/lab/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @license MIT
// @connect localhost
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const PYTHON_SERVER_URL = 'http://localhost:5678';
const TABLE_BODY_SELECTOR = 'tbody[formarrayname="TubeTypeList"]';
const BARCODE_DISPLAY_SELECTOR = '#barcode-display-box';
const IN_PAGE_TABLE_ID = 'barcode-inpage-container';
const INJECTION_POINT_SELECTOR = '.row.labordertab';
const GRID_CONTAINER_SELECTOR = '.ag-center-cols-container';
// --- State Flags & Cache ---
const collectedBarcodesThisSession = new Set();
let lastCheckedPatientBarcode = null;
let observerDebounceTimer = null;
let isTableUpdating = false;
let sortState = { key: 'timestamp', direction: 'desc' };
let currentBarcodes = []; // Local cache of the list
// --- API Communication ---
function apiRequest(method, endpoint, data = null) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: method,
url: `${PYTHON_SERVER_URL}${endpoint}`,
data: data ? JSON.stringify(data) : null,
headers: { "Content-Type": "application/json" },
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve(JSON.parse(response.responseText));
} else {
reject(`API Error: ${response.status} ${response.statusText}`);
}
},
onerror: function(response) {
reject('Connection Error: Could not connect to the local Python server. Is it running?');
}
});
});
}
// --- Data Collection Logic ---
function startDataObserver() {
const observer = new MutationObserver((mutations, obs) => {
if (observerDebounceTimer) clearTimeout(observerDebounceTimer);
observerDebounceTimer = setTimeout(() => {
// Task 1: Check for a barcode on the main patient page to mark as 'found'
const patientBarcodeBox = document.querySelector(BARCODE_DISPLAY_SELECTOR);
const barcodeOnPage = patientBarcodeBox ? Array.from(patientBarcodeBox.querySelectorAll('div')).find(div => div.textContent.includes('Sample Barcode:'))?.nextElementSibling?.textContent.trim() : null;
if (barcodeOnPage && barcodeOnPage !== lastCheckedPatientBarcode) {
lastCheckedPatientBarcode = barcodeOnPage;
markBarcodeAsFound(barcodeOnPage);
}
// Task 2: Scan for new barcodes in the receiving table
const allBarcodeRows = document.querySelectorAll(`${TABLE_BODY_SELECTOR} tr`);
if (allBarcodeRows.length > 0) {
for (const row of allBarcodeRows) {
const barcodeInput = row.querySelector('input[formcontrolname="Barcode"]');
const workbenchInput = row.querySelector('input[formcontrolname="TestSection"]');
if (barcodeInput && barcodeInput.value) {
const barcode = barcodeInput.value.trim();
const workbench = workbenchInput && workbenchInput.value ? workbenchInput.value.trim() : 'N/A';
saveBarcode(barcode, workbench);
}
}
}
}, 350);
});
observer.observe(document.body, { childList: true, subtree: true });
}
async function saveBarcode(barcode, workbench) {
if (collectedBarcodesThisSession.has(barcode) || currentBarcodes.some(b => b.barcode === barcode)) {
return;
}
const newEntry = {
count: currentBarcodes.length + 1,
barcode: barcode,
workbench: workbench,
timestamp: new Date().toISOString(),
found: false
};
const updatedList = [...currentBarcodes, newEntry];
try {
await apiRequest('POST', '/update_barcodes', updatedList);
collectedBarcodesThisSession.add(barcode);
console.log(`Barcode Collector: Sent new barcode to server - ${barcode}`);
await updateOrInsertBarcodeTable({ forceRender: true }); // Force refresh after adding
} catch (error) {
console.error(error);
}
}
async function markBarcodeAsFound(barcodeToMark) {
let barcodes = [...currentBarcodes];
const entry = barcodes.find(b => b.barcode === barcodeToMark);
if (entry && !entry.found) {
entry.found = true;
try {
await apiRequest('POST', '/update_barcodes', barcodes);
console.log(`Barcode Collector: Marked ${barcodeToMark} as found and updated server.`);
await updateOrInsertBarcodeTable({ forceRender: true });
} catch(error) {
console.error(error);
}
}
}
// --- AG-Grid Interaction ---
function findFloatingFilterInputByHeader(headerText) {
const headerViewport = document.querySelector('.ag-header-viewport');
if (!headerViewport) return null;
const allTitleCells = Array.from(headerViewport.querySelectorAll('.ag-header-row[aria-rowindex="1"] .ag-header-cell'));
if (allTitleCells.length === 0) return null;
let targetColumnIndex = -1;
allTitleCells.forEach((cell, index) => {
const cellTextElement = cell.querySelector('.ag-header-cell-text');
if (cellTextElement && cellTextElement.textContent.trim().toLowerCase() === headerText.toLowerCase()) {
targetColumnIndex = index;
}
});
if (targetColumnIndex === -1) return null;
const filterRow = headerViewport.querySelector('.ag-header-row[aria-rowindex="2"]');
if (!filterRow) return null;
const filterCell = filterRow.children[targetColumnIndex];
if (!filterCell) return null;
return filterCell.querySelector('input.ag-floating-filter-input');
}
function waitForGridUpdateAndClick() {
return new Promise((resolve, reject) => {
const gridContainer = document.querySelector(GRID_CONTAINER_SELECTOR);
if (!gridContainer) return reject("AG-Grid container not found.");
const timeout = setTimeout(() => { observer.disconnect(); reject("Timeout: AG-Grid did not update."); }, 2000);
const observer = new MutationObserver((mutations, obs) => {
const firstRow = gridContainer.querySelector('.ag-row[row-index="0"]');
if (firstRow) {
firstRow.click();
clearTimeout(timeout);
obs.disconnect();
resolve();
}
});
observer.observe(gridContainer, { childList: true, subtree: true });
});
}
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
async function enterBarcodeInFilter(barcode) {
const targetInput = findFloatingFilterInputByHeader('Barcode');
if (!targetInput) { console.error('Barcode Collector: Could not find "Barcode" filter input.'); return; }
try {
console.log(`Barcode Collector: Filtering for barcode "${barcode}"...`);
targetInput.focus(); await sleep(50);
targetInput.value = barcode;
targetInput.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); await sleep(100);
targetInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
console.log("Barcode Collector: Waiting for AG-Grid to update...");
await waitForGridUpdateAndClick();
console.log("Barcode Collector: Clicked the first grid row.");
} catch (error) { console.error("Barcode Collector: Error while filtering/clicking.", error); }
finally { if (targetInput) targetInput.blur(); }
}
// --- UI Rendering and Management ---
function handleSortClick() {
sortState.direction = sortState.direction === 'desc' ? 'asc' : 'desc';
updateOrInsertBarcodeTable({ forceRender: true });
}
async function updateOrInsertBarcodeTable({ forceRender = false } = {}) {
if (isTableUpdating) return;
isTableUpdating = true;
try {
// Step 1: Ensure the UI container exists.
const injectionPoint = document.querySelector(INJECTION_POINT_SELECTOR);
if (!injectionPoint) {
console.log("Barcode Collector: Waiting for the injection point to appear on the page...");
isTableUpdating = false;
return; // Exit silently, the interval will try again.
}
let container = document.getElementById(IN_PAGE_TABLE_ID);
if (!container) {
console.log("Barcode Collector: Injection point found. Creating the UI table for the first time.");
container = document.createElement('div');
container.id = IN_PAGE_TABLE_ID;
container.innerHTML = `
<div class="bc-table-header">
<h2>Pending (Synced)</h2>
<div class="bc-filter-container"><label for="workbench-filter">Workbench:</label><select id="workbench-filter"></select></div>
<div class="bc-button-group">
<button id="delete-completed-btn" class="bc-btn bc-btn-completed">Clear Completed</button>
<button id="clear-all-btn" class="bc-btn bc-btn-clear-all">Clear All</button>
</div>
</div>
<div class="bc-table-body">
<table>
<thead>
<tr>
<th>#</th><th>Barcode</th><th>Workbench</th>
<th id="sort-by-time-header" class="sortable-header">Added <span id="sort-indicator"></span></th>
<th>Pending</th><th>Actions</th>
</tr>
</thead>
<tbody><tr><td colspan="6">Loading...</td></tr></tbody>
</table>
</div>`;
injectionPoint.parentNode.insertBefore(container, injectionPoint.nextSibling);
// Attach event listeners only once
container.querySelector('#clear-all-btn').addEventListener('click', clearAllBarcodes);
container.querySelector('#delete-completed-btn').addEventListener('click', deleteCompletedBarcodes);
container.querySelector('#workbench-filter').addEventListener('change', () => updateOrInsertBarcodeTable({ forceRender: true }));
container.querySelector('#sort-by-time-header').addEventListener('click', handleSortClick);
container.querySelector('tbody').addEventListener('click', handleTableClick);
}
// Step 2: Fetch the latest data.
const barcodesFromServer = await apiRequest('GET', '/get_barcodes');
// Step 3: Check if an update is needed.
if (!forceRender && JSON.stringify(barcodesFromServer) === JSON.stringify(currentBarcodes)) {
isTableUpdating = false;
return; // No changes, no need to re-render.
}
currentBarcodes = barcodesFromServer; // Update local cache
// Step 4: Apply sorting and filtering.
if (sortState.key === 'timestamp') {
currentBarcodes.sort((a, b) => {
const dateA = new Date(a.timestamp);
const dateB = new Date(b.timestamp);
return sortState.direction === 'asc' ? dateA - dateB : dateB - dateA;
});
}
const uniqueWorkbenches = ['All', ...new Set(currentBarcodes.map(b => b.workbench).filter(Boolean))];
const filterDropdown = container.querySelector('#workbench-filter');
const currentFilterValue = filterDropdown.value;
filterDropdown.innerHTML = uniqueWorkbenches.map(wb => `<option value="${wb}">${wb}</option>`).join('');
if (uniqueWorkbenches.includes(currentFilterValue)) filterDropdown.value = currentFilterValue;
const selectedWorkbench = filterDropdown.value;
const filteredBarcodes = selectedWorkbench === 'All' ? currentBarcodes : currentBarcodes.filter(b => b.workbench === selectedWorkbench);
// Step 5: Render the final view.
container.querySelector('#sort-indicator').textContent = sortState.direction === 'asc' ? '▲' : '▼';
const tableBody = container.querySelector('tbody');
const formatTimeSince = (iso) => { const d = new Date(iso), n = new Date(), m = Math.floor((n - d) / 6e4); return m < 1 ? "00:00 ago" : `${String(Math.floor(m/60)).padStart(2,'0')}:${String(m%60).padStart(2,'0')} ago`; };
let tableRows = filteredBarcodes.map(entry => `
<tr data-barcode-row="${entry.barcode}" class="${entry.found ? 'barcode-found' : ''}">
<td>${entry.count}</td><td>${entry.barcode}</td><td>${entry.workbench || 'N/A'}</td>
<td>${new Date(entry.timestamp).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</td>
<td data-timestamp="${entry.timestamp}">${formatTimeSince(entry.timestamp)}</td>
<td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete">×</span></td>
</tr>
`).join('');
if (filteredBarcodes.length === 0) tableRows = '<tr><td colspan="6">No pending barcodes.</td></tr>';
if (tableBody.innerHTML !== tableRows) tableBody.innerHTML = tableRows;
} catch (error) {
console.error(error);
const container = document.getElementById(IN_PAGE_TABLE_ID);
if(container) container.querySelector('tbody').innerHTML = `<tr><td colspan="6" style="color: red; text-align: center;">Error: ${error}</td></tr>`;
} finally {
isTableUpdating = false;
}
}
async function handleTableClick(event) {
const row = event.target.closest('tr');
if (!row || !row.dataset.barcodeRow) return;
if (event.target.classList.contains('delete-barcode-btn')) {
await deleteBarcode(event.target.dataset.barcode);
} else {
await enterBarcodeInFilter(row.dataset.barcodeRow);
}
}
// --- Data Modification Functions ---
async function clearAllBarcodes() {
if (confirm("Are you sure you want to delete ALL synced barcodes? This will affect all computers.")) {
try {
await apiRequest('POST', '/update_barcodes', []);
await updateOrInsertBarcodeTable({ forceRender: true });
} catch(error) { console.error(error); }
}
}
async function deleteCompletedBarcodes() {
if (confirm("Are you sure you want to delete all completed barcodes? This will affect all computers.")) {
let updatedList = currentBarcodes.filter(entry => !entry.found);
updatedList.forEach((entry, index) => { entry.count = index + 1; });
try {
await apiRequest('POST', '/update_barcodes', updatedList);
await updateOrInsertBarcodeTable({ forceRender: true });
} catch(error) { console.error(error); }
}
}
async function deleteBarcode(barcodeToDelete) {
let updatedList = currentBarcodes.filter(entry => entry.barcode !== barcodeToDelete);
updatedList.forEach((entry, index) => { entry.count = index + 1; });
try {
await apiRequest('POST', '/update_barcodes', updatedList);
await updateOrInsertBarcodeTable({ forceRender: true });
} catch(error) { console.error(error); }
}
// --- Styles and Initialization ---
function addStyles() {
GM_addStyle(`
#barcode-inpage-container { width: 30vw !important; float: left !important; margin: 15px 0; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); font-family: Arial, sans-serif; background-color: #fff; display: flex; flex-direction: column; height: 90vh; }
.ag-root-wrapper-body { width: 70vw !important; margin-left: auto !important; margin-right: 0 !important; }
.bc-table-header { padding: 10px 16px; background-color: #f7f7f7; border-bottom: 1px solid #ccc; border-top-left-radius: 8px; border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.bc-table-header h2 { margin: 0; font-size: 1.1em; color: #333; flex-shrink: 0; }
.bc-filter-container { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.bc-button-group { display: flex; gap: 8px; flex-shrink: 0; }
.bc-btn { border: none; padding: 6px 12px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 0.9em; color: white; }
.bc-btn-clear-all { background-color: #ef5350; } .bc-btn-clear-all:hover { background-color: #d32f2f; }
.bc-btn-completed { background-color: #0288d1; } .bc-btn-completed:hover { background-color: #0277bd; }
.bc-table-body { padding: 8px; overflow-y: auto; flex-grow: 1; min-height: 0; }
.bc-table-body table { width: 100%; border-collapse: collapse; }
.bc-table-body th, .bc-table-body td { border: 1px solid #ddd; padding: 4px 8px; text-align: left; font-size: 0.9em; }
.bc-table-body th { background-color: #f2f2f2; }
.bc-table-body .sortable-header { cursor: pointer; }
.bc-table-body .sortable-header:hover { background-color: #e0e0e0; }
#sort-indicator { font-size: 0.8em; margin-left: 4px; }
.bc-table-body tbody tr { cursor: pointer; }
.bc-table-body tbody tr:hover { background-color: #e8eaf6; }
.bc-table-body tbody tr.barcode-found { background-color: #a5d6a7 !important; color: #1b5e20; }
.bc-table-body tbody tr.barcode-found:hover { background-color: #81c784 !important; }
.delete-barcode-btn { cursor: pointer; font-weight: bold; font-size: 18px; color: #ef5350; padding: 0 4px; border-radius: 4px; }
.delete-barcode-btn:hover { color: white; background-color: #d32f2f; }
`);
}
// --- Start the script ---
console.log("Barcode Collector (Networked): Script loading.");
addStyles();
startDataObserver(); // Start watching for barcodes right away.
setInterval(updateOrInsertBarcodeTable, 3000); // Start the main UI loop.
})();