// ==UserScript==
// @name AG-Grid Data Extractor - Complete (Modified)
// @namespace http://tampermonkey.net/
// @version 2.5 // Increased version number
// @description Collects unique barcodes with clinic info, 50 rows per page, sorted by last modified date, truncates clinic names, improved SPA support - Buttons next to Reset
// @author Your Name
// @match https://his.kaauh.org/lab/*
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
const dateColumnIds = ['orderCreatedOnEpoch', 'createdDate', 'orderDate'];
const lastModifiedColumnId = 'orderLastModifiedOnEpoch';
const clinicColumnId = 'clinic';
const barcodeColumnId = 'barcode';
const testDescriptionColumnId = 'testDescription';
const agGridBodyViewportSelector = '.ag-body-viewport.ag-layout-normal';
const agGridRowSelector = '.ag-row[role="row"]';
const ROWS_PER_PAGE = 500;
const CLINIC_TRUNCATE_LENGTH = 50;
GM_addStyle(`
.userscript-container {
position: fixed;
top: 20px;
left: 20px;
background: #fff;
padding: 20px;
border: 1px solid #bbb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
min-width: 400px;
max-width: 95%;
font-family: sans-serif;
}
.userscript-title {
margin-top: 0;
margin-bottom: 15px;
color: #333;
font-size: 1.4em;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.userscript-table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
}
.userscript-table th,
.userscript-table td {
border: 1px solid #ddd;
padding: 4px 6px;
text-align: left;
vertical-align: top;
word-break: break-word;
}
.userscript-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.userscript-table th {
background-color: #007bff;
color: white;
font-weight: bold;
border-color: #007bff;
font-size: 11px;
padding: 6px;
}
.userscript-table tr:nth-child(even) {
background-color: #f8f9fa;
}
.userscript-pagination {
display: flex;
justify-content: center;
margin-top: 10px;
gap: 5px;
}
.userscript-page-btn {
padding: 2px 6px;
font-size: 12px;
cursor: pointer;
border: 1px solid #ddd;
background: white;
border-radius: 3px;
}
.userscript-active-page {
font-weight: bold;
background: #007bff;
color: white;
border-color: #007bff;
}
.userscript-button-container {
margin-top: 15px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.userscript-button {
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.userscript-button-primary {
background-color: #007bff;
color: white;
}
.userscript-button-primary:hover {
background-color: #0069d9;
}
.userscript-button-danger {
background-color: #dc3545;
color: white;
}
.userscript-button-danger:hover {
background-color: #c82333;
}
.userscript-scroll-message {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: #e0f7fa;
padding: 10px 15px;
border: 1px solid #00bcd4;
border-radius: 5px;
z-index: 9998;
font-size: 0.9em;
}
`);
let collectedDataMap = new Map();
let gridObserver = null;
let observerForButtonContainer = null;
let activeCreationDateColumn = null;
let uiElements = {
extractButton: null,
stopButton: null,
scrollMessage: null
};
function parseDateFromCell(text) {
if (!text) return 0;
if (/^\d{10,}$/.test(text)) return parseInt(text);
const date = new Date(text);
return isNaN(date) ? 0 : date.getTime();
}
function formatDate(timestamp) {
return timestamp ? new Date(timestamp).toLocaleString() : 'N/A';
}
function escapeCsv(text) {
const strText = String(text || '')
.replace(/"/g, '""')
.replace(/^[=+@-]/, "'$&");
return `"${strText}"`;
}
function truncateText(text, length) {
const strText = String(text || '');
if (strText.length > length) {
return strText.substring(0, length) + '...';
}
return strText;
}
function waitForElement(selector, options = {}) {
const { timeout = 10000, pollInterval = 250 } = options;
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) return resolve(element);
const startTime = Date.now();
const poll = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
clearInterval(poll);
resolve(element);
} else if (Date.now() - startTime >= timeout) {
clearInterval(poll);
resolve(null);
}
}, pollInterval);
});
}
function waitForGridData() {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const poll = setInterval(() => {
const rows = document.querySelectorAll(agGridRowSelector);
if (rows.length > 0) {
clearInterval(poll);
setTimeout(resolve, 1000);
} else if (Date.now() - startTime >= 30000) {
clearInterval(poll);
reject(new Error('No grid rows detected within timeout'));
}
}, 500);
});
}
function showTemporaryMessage(message, type = 'info', duration = 5000) {
const colors = {
info: { bg: '#e0f7fa', border: '#00bcd4', text: '#006064' },
success: { bg: '#d4edda', border: '#c3e6cb', text: '#155724' },
error: { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' }
};
const messageDiv = document.createElement('div');
messageDiv.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 15px 20px;
border-radius: 5px;
z-index: 10000;
font-weight: bold;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
max-width: 80%;
text-align: center;
background-color: ${colors[type].bg};
border: 1px solid ${colors[type].border};
color: ${colors[type].text};
`;
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
setTimeout(() => messageDiv.remove(), type === 'error' ? 10000 : duration);
}
function processRowElement(rowElement) {
const rowData = {};
const cells = rowElement.querySelectorAll('.ag-cell');
if (!activeCreationDateColumn) {
for (const colId of dateColumnIds) {
if (rowElement.querySelector(`[col-id="${colId}"]`)) {
activeCreationDateColumn = colId;
console.log(`Detected active creation date column: ${activeCreationDateColumn}`);
break;
}
}
if (!activeCreationDateColumn) {
console.warn('No known date column found in this row.');
}
}
cells.forEach(cell => {
const colId = cell.getAttribute('col-id') || cell.parentElement?.getAttribute('col-id');
if (colId) rowData[colId] = cell.textContent.trim();
});
if (rowData[barcodeColumnId] && rowData[testDescriptionColumnId] && activeCreationDateColumn && rowData[lastModifiedColumnId]) {
if (!collectedDataMap.has(rowData[barcodeColumnId])) {
collectedDataMap.set(rowData[barcodeColumnId], {
testDescription: rowData[testDescriptionColumnId],
barcode: rowData[barcodeColumnId],
clinic: rowData[clinicColumnId] || 'N/A',
orderCreationDate: rowData[activeCreationDateColumn],
orderLastModified: rowData[lastModifiedColumnId],
creationTimestamp: parseDateFromCell(rowData[activeCreationDateColumn]),
modifiedTimestamp: parseDateFromCell(rowData[lastModifiedColumnId])
});
}
} else {
if (!rowData[barcodeColumnId]) console.warn('Skipping row due to missing barcode.');
if (!rowData[testDescriptionColumnId]) console.warn('Skipping row due to missing test description.');
if (!activeCreationDateColumn) console.warn('Skipping row due to unknown creation date column.');
if (!rowData[lastModifiedColumnId]) console.warn('Skipping row due to missing last modified date.');
}
}
function createPagination(totalPages, currentPage, onPageChange) {
const container = document.createElement('div');
container.className = 'userscript-pagination';
if (currentPage > 1) {
const prevBtn = document.createElement('button');
prevBtn.className = 'userscript-page-btn';
prevBtn.textContent = '←';
prevBtn.onclick = () => onPageChange(currentPage - 1);
container.appendChild(prevBtn);
}
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) {
const firstBtn = document.createElement('button');
firstBtn.className = 'userscript-page-btn';
firstBtn.textContent = '1';
firstBtn.onclick = () => onPageChange(1);
container.appendChild(firstBtn);
if (startPage > 2) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.style.padding = '0 5px';
container.appendChild(ellipsis);
}
}
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement('button');
btn.className = `userscript-page-btn ${i === currentPage ? 'userscript-active-page' : ''}`;
btn.textContent = i;
btn.onclick = () => onPageChange(i);
container.appendChild(btn);
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.style.padding = '0 5px';
container.appendChild(ellipsis);
}
const lastBtn = document.createElement('button');
lastBtn.className = 'userscript-page-btn';
lastBtn.textContent = totalPages;
lastBtn.onclick = () => onPageChange(totalPages);
container.appendChild(lastBtn);
}
if (currentPage < totalPages) {
const nextBtn = document.createElement('button');
nextBtn.className = 'userscript-page-btn';
nextBtn.textContent = '→';
nextBtn.onclick = () => onPageChange(currentPage + 1);
container.appendChild(nextBtn);
}
return container;
}
function displayData(data) {
document.querySelectorAll('.userscript-container').forEach(el => el.remove());
if (data.length === 0) {
showTemporaryMessage('No data collected matching the criteria', 'error');
return;
}
const sortedData = [...data].sort((a, b) => b.modifiedTimestamp - a.modifiedTimestamp);
const totalPages = Math.ceil(sortedData.length / ROWS_PER_PAGE);
let currentPage = 1;
const container = document.createElement('div');
container.className = 'userscript-container';
const title = document.createElement('h2');
title.className = 'userscript-title';
title.textContent = `PENDING TESTS (${sortedData.length} records)`;
container.appendChild(title);
const renderPage = (page) => {
const startIdx = (page - 1) * ROWS_PER_PAGE;
const endIdx = startIdx + ROWS_PER_PAGE;
const pageData = sortedData.slice(startIdx, endIdx);
const oldTable = container.querySelector('.userscript-table-container');
if (oldTable) oldTable.remove();
const tableContainer = document.createElement('div');
tableContainer.className = 'userscript-table-container';
const table = document.createElement('table');
table.className = 'userscript-table';
table.setAttribute('role', 'grid');
const headers = ['NO.', 'Test Description', 'Barcode', 'Clinic', 'Created', 'Last Modified'];
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
headers.forEach((text, i) => {
const th = document.createElement('th');
th.textContent = text;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
pageData.forEach((item, idx) => {
const row = document.createElement('tr');
const absoluteIdx = startIdx + idx + 1;
[
absoluteIdx,
item.testDescription,
item.barcode,
truncateText(item.clinic, CLINIC_TRUNCATE_LENGTH),
formatDate(item.creationTimestamp),
formatDate(item.modifiedTimestamp)
].forEach(content => {
const td = document.createElement('td');
td.textContent = content;
row.appendChild(td);
});
tbody.appendChild(row);
});
table.appendChild(tbody);
tableContainer.appendChild(table);
const oldPagination = container.querySelector('.userscript-pagination');
if (oldPagination) oldPagination.remove();
tableContainer.appendChild(createPagination(totalPages, page, (newPage) => {
currentPage = newPage;
renderPage(newPage);
}));
const buttonContainerElement = container.querySelector('.userscript-button-container');
if (buttonContainerElement) {
container.insertBefore(tableContainer, buttonContainerElement);
} else {
container.appendChild(tableContainer);
}
};
let buttonContainer = container.querySelector('.userscript-button-container');
if (!buttonContainer) {
buttonContainer = document.createElement('div');
buttonContainer.className = 'userscript-button-container';
container.appendChild(buttonContainer);
}
const printBtn = document.createElement('button');
printBtn.className = 'userscript-button userscript-button-primary';
printBtn.textContent = 'Print All';
printBtn.onclick = () => {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Test Descriptions and Barcodes</title>
<style>
body { font-family: sans-serif; margin: 10mm; }
h2 { margin-top: 0; }
table { border-collapse: collapse; width: 100%; font-size: 10px; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 3px 5px; }
th { background-color: #f2f2f2; font-weight: bold; }
.page-break { page-break-after: always; }
@page { size: auto; margin: 10mm; }
td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<h2>PENDING TESTS (${sortedData.length} records)</h2>
`);
for (let i = 0; i < sortedData.length; i += ROWS_PER_PAGE) {
const pageData = sortedData.slice(i, i + ROWS_PER_PAGE);
printWindow.document.write(`
<table>
<thead>
<tr>
<th>NO.</th>
<th>Test Description</th>
<th>Barcode</th>
<th>Clinic</th>
<th>Created</th>
<th>Last Modified</th>
</tr>
</thead>
<tbody>
`);
pageData.forEach((item, idx) => {
printWindow.document.write(`
<tr>
<td>${i + idx + 1}</td>
<td>${item.testDescription}</td>
<td>${item.barcode}</td>
<td>${truncateText(item.clinic, CLINIC_TRUNCATE_LENGTH)}</td>
<td>${formatDate(item.creationTimestamp)}</td>
<td>${formatDate(item.modifiedTimestamp)}</td>
</tr>
`);
});
printWindow.document.write('</tbody></table>');
if (i + ROWS_PER_PAGE < sortedData.length) {
printWindow.document.write('<div class="page-break"></div>');
}
}
printWindow.document.write(`
<script>
setTimeout(function() {
window.print();
window.close();
}, 200);
</script>
</body>
</html>
`);
printWindow.document.close();
};
buttonContainer.appendChild(printBtn);
const downloadBtn = document.createElement('button');
downloadBtn.className = 'userscript-button userscript-button-primary';
downloadBtn.textContent = 'Download CSV';
downloadBtn.onclick = () => {
const headers = ['NO.', 'Test Description', 'Barcode', 'Clinic', 'Created', 'Last Modified'];
let csv = headers.map(escapeCsv).join(',') + '\n';
sortedData.forEach((item, index) => {
csv += [
index + 1,
item.testDescription,
item.barcode,
item.clinic,
formatDate(item.creationTimestamp),
formatDate(item.modifiedTimestamp)
].map(escapeCsv).join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `test_data_${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(url), 100);
};
buttonContainer.appendChild(downloadBtn);
const closeBtn = document.createElement('button');
closeBtn.className = 'userscript-button userscript-button-danger';
closeBtn.textContent = 'Close';
closeBtn.onclick = () => container.remove();
buttonContainer.appendChild(closeBtn);
document.body.appendChild(container);
renderPage(1);
}
async function startExtraction() {
if (gridObserver) {
showTemporaryMessage('Extraction already in progress', 'info');
return;
}
collectedDataMap.clear();
activeCreationDateColumn = null;
if (uiElements.extractButton) uiElements.extractButton.style.display = 'none';
if (uiElements.stopButton) uiElements.stopButton.style.display = 'inline-block';
showTemporaryMessage('Preparing extraction...', 'info');
try {
const gridBodyViewport = await waitForElement(agGridBodyViewportSelector, { timeout: 30000 });
if (!gridBodyViewport) {
throw new Error('AG-Grid viewport not found within timeout.');
}
await waitForGridData();
gridBodyViewport.querySelectorAll(agGridRowSelector).forEach(row => {
row.dataset.processed = 'true';
processRowElement(row);
});
if (!uiElements.scrollMessage) {
uiElements.scrollMessage = document.createElement('div');
uiElements.scrollMessage.className = 'userscript-scroll-message';
document.body.appendChild(uiElements.scrollMessage);
}
uiElements.scrollMessage.textContent = `Collected ${collectedDataMap.size} records. Scroll to load more...`;
uiElements.scrollMessage.style.display = 'block';
gridObserver = new MutationObserver(() => {
clearTimeout(gridObserver._debounce);
gridObserver._debounce = setTimeout(() => {
const newRows = Array.from(gridBodyViewport.querySelectorAll(agGridRowSelector))
.filter(row => !row.dataset.processed);
newRows.forEach(row => {
row.dataset.processed = 'true';
processRowElement(row);
});
if (uiElements.scrollMessage) {
uiElements.scrollMessage.textContent = `Collected ${collectedDataMap.size} records`;
}
console.log(`Processed ${newRows.length} new rows. Total collected: ${collectedDataMap.size}`);
}, 150);
});
gridObserver.observe(gridBodyViewport, { childList: true, subtree: true });
showTemporaryMessage('Extraction started. Scroll down the grid to collect more data.', 'success');
} catch (error) {
console.error('Extraction error:', error);
cleanup();
showTemporaryMessage(
error.message.includes('Timeout') ?
'Grid loading timeout or no rows found.' :
'Failed to start extraction.',
'error',
10000
);
}
}
function stopExtractionAndDisplay() {
if (!gridObserver) {
showTemporaryMessage('No active extraction to stop', 'info');
return;
}
cleanup();
const finalData = Array.from(collectedDataMap.values());
if (finalData.length === 0) {
showTemporaryMessage(
'No data collected. Possible issues:\n1. No matching columns found.\n2. Grid structure changed.\n3. No rows were loaded during extraction.',
'error',
10000
);
return;
}
showTemporaryMessage(`Collected ${finalData.length} records. Generating report...`, 'success', 3000);
setTimeout(() => displayData(finalData), 500);
}
function addButtonsToPage() {
if (uiElements.extractButton && document.body.contains(uiElements.extractButton)) {
return;
}
const resetButton = document.querySelector('.nova-btn.nova-btn--ghost.nova-btn--md[translateid="lab-order-list.Reset"]');
if (resetButton && resetButton.parentElement) {
uiElements.extractButton = document.createElement('button');
uiElements.extractButton.textContent = 'PRINT';
uiElements.extractButton.className = 'userscript-button nova-btn nova-btn--primary nova-btn--md';
uiElements.extractButton.style.marginLeft = '10px';
uiElements.extractButton.onclick = startExtraction;
uiElements.stopButton = document.createElement('button');
uiElements.stopButton.textContent = 'Stop & Generate Report';
uiElements.stopButton.className = 'userscript-button nova-btn nova-btn--danger nova-btn--md';
uiElements.stopButton.style.marginLeft = '10px';
uiElements.stopButton.style.display = 'none';
uiElements.stopButton.onclick = stopExtractionAndDisplay;
resetButton.parentElement.insertBefore(uiElements.extractButton, resetButton.nextSibling);
uiElements.extractButton.parentElement.insertBefore(uiElements.stopButton, uiElements.extractButton.nextSibling);
console.log('AG-Grid Extractor buttons added next to Reset button.');
} else {
console.log('Reset button not found, skipping adding extractor buttons.');
}
}
function cleanup() {
if (observerForButtonContainer) {
observerForButtonContainer.disconnect();
observerForButtonContainer = null;
console.log('AG-Grid Extractor button container observer disconnected.');
}
if (gridObserver) {
gridObserver.disconnect();
clearTimeout(gridObserver._debounce);
gridObserver = null;
console.log('AG-Grid data observer disconnected.');
}
if (uiElements.extractButton && uiElements.extractButton.parentElement) {
uiElements.extractButton.parentElement.removeChild(uiElements.extractButton);
uiElements.extractButton = null;
}
if (uiElements.stopButton && uiElements.stopButton.parentElement) {
uiElements.stopButton.parentElement.removeChild(uiElements.stopButton);
uiElements.stopButton = null;
}
if (uiElements.scrollMessage && uiElements.scrollMessage.parentElement) {
uiElements.scrollMessage.parentElement.removeChild(uiElements.scrollMessage);
uiElements.scrollMessage = null;
}
console.log('AG-Grid Extractor UI elements removed.');
}
function initialize() {
console.log('AG-Grid Data Extractor script initializing...');
addButtonsToPage();
const targetNode = document.body;
const config = { childList: true, subtree: true };
observerForButtonContainer = new MutationObserver((mutationsList, observer) => {
addButtonsToPage();
});
observerForButtonContainer.observe(targetNode, config);
console.log('MutationObserver started to watch for button container.');
window.addEventListener('beforeunload', cleanup);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();