Pending barcodes

Collects barcodes automatically from a table, timestamps them, and provides a viewing interface.

当前为 2025-10-02 提交的版本,查看 最新版本

// ==UserScript==
// @name         Pending barcodes
// @namespace    http://tampermonkey.net/
// @version      1.2.1
// @description  Collects barcodes automatically from a table, timestamps them, and provides a viewing interface.
// @author       Hamad AlShegifi
// @match        *://his.kaauh.org/lab/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const TABLE_BODY_SELECTOR = 'tbody[formarrayname="TubeTypeList"]';
    const MENU_INJECTION_SELECTOR = 'span.csi-menu-text[title="Documents"]';
    const STORAGE_KEY = 'collectedBarcodes_storage';

    // --- State Flags ---
    let isTableObserverAttached = false;
    let isMenuViewAdded = false;

    // --- In-memory cache for duplicate checking ---
    const collectedBarcodesThisSession = new Set();


    // --- Main Logic ---

    // Function to initialize the script and set up observers
    function initialize() {
        console.log("Barcode Collector: Script started. Looking for elements...");

        // Use a MutationObserver to watch for dynamically loaded elements
        const observer = new MutationObserver((mutations, obs) => {
            const tableBody = document.querySelector(TABLE_BODY_SELECTOR);
            const documentsMenu = document.querySelector(MENU_INJECTION_SELECTOR);

            // Setup observer for the barcode table
            if (tableBody && !isTableObserverAttached) {
                console.log("Barcode Collector: Table body found. Attaching observer.");
                scanForExistingBarcodes(tableBody); // Scan for any barcodes already on the page
                setupTableObserver(tableBody); // Observe for new barcodes being added
                isTableObserverAttached = true;
            }

            // Add the custom menu item
            if (documentsMenu && !isMenuViewAdded) {
                console.log("Barcode Collector: 'Documents' menu found. Adding custom view menu.");
                addMenuView(documentsMenu);
                isMenuViewAdded = true;
            }

            // Stop observing if both elements are found and processed
            if (isTableObserverAttached && isMenuViewAdded) {
                console.log("Barcode Collector: Initialization complete. Disconnecting observer.");
                obs.disconnect();
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // Scan for barcodes that might already be on the page when the script loads
    function scanForExistingBarcodes(tableBody) {
        const rows = tableBody.querySelectorAll('tr');
        rows.forEach(row => {
            const barcodeInput = row.querySelector('input[formcontrolname="Barcode"]');
            if (barcodeInput && barcodeInput.value) {
                saveBarcode(barcodeInput.value.trim());
            }
        });
        console.log(`Barcode Collector: Scanned and processed ${rows.length} existing rows.`);
    }

    // Observe the table for newly added rows
    function setupTableObserver(tableBody) {
        const tableObserver = new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    // Ensure we are only looking at element nodes (like TRs)
                    if (node.nodeType === 1) {
                        const barcodeInput = node.querySelector('input[formcontrolname="Barcode"]');
                        if (barcodeInput && barcodeInput.value) {
                            saveBarcode(barcodeInput.value.trim());
                        }
                    }
                });
            });
        });

        tableObserver.observe(tableBody, {
            childList: true,
            subtree: false // We only care about direct children (TRs) being added to the TBODY
        });
    }

    // Function to save a new barcode to storage, with duplicate checking
    async function saveBarcode(barcode) {
        // Use the session set to prevent saving duplicates from rapid re-renders
        if (collectedBarcodesThisSession.has(barcode)) {
            return;
        }

        let barcodes = await GM_getValue(STORAGE_KEY, []);

        // Also check persistent storage to be absolutely sure
        if (barcodes.some(entry => entry.barcode === barcode)) {
             collectedBarcodesThisSession.add(barcode); // Add to session cache to avoid future checks
             return;
        }

        const newEntry = {
            count: barcodes.length + 1,
            barcode: barcode,
            timestamp: new Date().toISOString()
        };
        barcodes.push(newEntry);
        await GM_setValue(STORAGE_KEY, barcodes); // Save the updated array
        collectedBarcodesThisSession.add(barcode); // Add to session cache
        console.log(`Barcode Collector: Saved new barcode - ${barcode}`);
    }

    // Function to create and inject the "View Barcodes" menu item
    function addMenuView(targetMenuElement) {
        // Find the main container of the "Documents" menu item to ensure proper alignment
        const documentsContainer = targetMenuElement.closest('csi-main-menu')?.parentElement?.parentElement;

        if (!documentsContainer) {
            console.error("Barcode Collector: Could not find the main menu item container to inject into. The page structure may have changed.");
            return;
        }

        // Create a new container that is the same type as the one we found.
        const newContainer = document.createElement(documentsContainer.tagName);

        // Set the inner HTML to replicate the structure of the existing menu items,
        // ensuring it has an icon holder and a text wrapper.
        newContainer.innerHTML = `
            <div class="${documentsContainer.firstElementChild.className}">
                 <csi-main-menu class="${targetMenuElement.closest('csi-main-menu').className}">
                    <a style="cursor: pointer;">
                        <span class="icon-holder csi-menu-icon">
                            <i class="fa fa-list-ul" aria-hidden="true"></i>
                        </span>
                        <div class="csi-menu-text-wrapper">
                            <span class="csi-menu-text sidemenu-title" title="Collected Barcodes">Collected Barcodes</span>
                        </div>
                    </a>
                </csi-main-menu>
            </div>
        `;
        // Add a click listener to the `<a>` tag within our newly created element.
        newContainer.querySelector('a').addEventListener('click', showBarcodesModal);

        // Insert the new menu item into the DOM right after the "Documents" menu item.
        documentsContainer.parentNode.insertBefore(newContainer, documentsContainer.nextSibling);
    }

    // --- Helper Function for Time Formatting ---
    function formatTimeSince(isoTimestamp) {
        const date = new Date(isoTimestamp);
        const now = new Date();
        const seconds = Math.floor((now - date) / 1000);

        if (seconds < 60) return `${seconds} seconds ago`;

        const minutes = Math.floor(seconds / 60);
        if (minutes < 60) return `${minutes} minutes ago`;

        const hours = Math.floor(minutes / 60);
        if (hours < 24) return `${hours} hours ago`;

        const days = Math.floor(hours / 24);
        return `${days} days ago`;
    }


    // Function to display the modal with collected barcodes
    async function showBarcodesModal() {
        const existingModal = document.getElementById('barcode-viewer-modal');
        if (existingModal) existingModal.remove();

        const barcodes = await GM_getValue(STORAGE_KEY, []);
        // Sort by count ascending, so the earliest collected are first
        barcodes.sort((a, b) => a.count - b.count);

        const modal = document.createElement('div');
        modal.id = 'barcode-viewer-modal';

        let tableRows = barcodes.map(entry => `
            <tr data-barcode-row="${entry.barcode}">
                <td>${entry.count}</td>
                <td>${entry.barcode}</td>
                <td>${new Date(entry.timestamp).toLocaleString()}</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 this barcode">&times;</span></td>
            </tr>
        `).join('');

        if (barcodes.length === 0) {
            tableRows = '<tr><td colspan="5">No barcodes have been collected yet.</td></tr>';
        }

        modal.innerHTML = `
            <div class="modal-content-bc">
                <div class="modal-header-bc">
                    <h2>Collected Barcodes</h2>
                    <span class="close-button-bc">&times;</span>
                </div>
                <div class="modal-body-bc">
                    <table>
                        <thead>
                            <tr>
                                <th>Count</th>
                                <th>Barcode</th>
                                <th>Timestamp</th>
                                <th>Time Since Added</th>
                                <th>Actions</th>
                            </tr>
                        </thead>
                        <tbody>${tableRows}</tbody>
                    </table>
                </div>
                <div class="modal-footer-bc">
                    <button id="clear-barcodes-btn">Clear All Barcodes</button>
                </div>
            </div>
        `;

        document.body.appendChild(modal);

        // --- Event Listener for Deleting Individual Barcodes ---
        const tableBody = modal.querySelector('tbody');
        tableBody.addEventListener('click', async function(event) {
            if (event.target.classList.contains('delete-barcode-btn')) {
                const barcodeToDelete = event.target.dataset.barcode;
                await deleteBarcode(barcodeToDelete);
                // Refresh the modal content without closing it
                const updatedBarcodes = await GM_getValue(STORAGE_KEY, []);
                updatedBarcodes.sort((a, b) => a.count - b.count); // Re-sort
                 let newTableRows = updatedBarcodes.map(entry => `
                    <tr data-barcode-row="${entry.barcode}">
                        <td>${entry.count}</td>
                        <td>${entry.barcode}</td>
                        <td>${new Date(entry.timestamp).toLocaleString()}</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 this barcode">&times;</span></td>
                    </tr>
                `).join('');
                 if (updatedBarcodes.length === 0) {
                    newTableRows = '<tr><td colspan="5">No barcodes have been collected yet.</td></tr>';
                }
                tableBody.innerHTML = newTableRows;
            }
        });


        // Auto-update the "Time Since Added" column every 5 seconds
        const timeSinceInterval = setInterval(() => {
            modal.querySelectorAll('td[data-timestamp]').forEach(cell => {
                cell.textContent = formatTimeSince(cell.dataset.timestamp);
            });
        }, 5000);


        modal.querySelector('.close-button-bc').addEventListener('click', () => {
            clearInterval(timeSinceInterval); // Stop the auto-update
            modal.remove();
        });

        const clearButton = modal.querySelector('#clear-barcodes-btn');
        clearButton.addEventListener('click', () => {
            clearInterval(timeSinceInterval); // Stop the auto-update
            showClearConfirmation(modal);
        });
    }

    // --- New Function to Delete a Single Barcode ---
    async function deleteBarcode(barcodeToDelete) {
        let barcodes = await GM_getValue(STORAGE_KEY, []);
        // Filter out the barcode to delete
        let updatedBarcodes = barcodes.filter(entry => entry.barcode !== barcodeToDelete);

        // Re-calculate the 'count' for the remaining items
        updatedBarcodes.forEach((entry, index) => {
            entry.count = index + 1;
        });

        await GM_setValue(STORAGE_KEY, updatedBarcodes);
        console.log(`Barcode Collector: Deleted barcode - ${barcodeToDelete}`);
    }


    // Function to show a confirmation dialog inside the modal
    function showClearConfirmation(modal) {
        const modalBody = modal.querySelector('.modal-body-bc');
        const modalFooter = modal.querySelector('.modal-footer-bc');

        modalBody.innerHTML = `
            <p class="confirm-text-bc">
                Are you sure you want to delete all collected barcodes?<br>This action cannot be undone.
            </p>`;

        modalFooter.innerHTML = `
            <button id="confirm-clear-btn" class="modal-btn-bc confirm-btn-bc">Yes, Clear All</button>
            <button id="cancel-clear-btn" class="modal-btn-bc cancel-btn-bc">Cancel</button>`;

        modal.querySelector('#confirm-clear-btn').addEventListener('click', async () => {
            await GM_setValue(STORAGE_KEY, []);
            modal.remove();
            showBarcodesModal();
        });

        modal.querySelector('#cancel-clear-btn').addEventListener('click', () => {
            modal.remove();
            showBarcodesModal();
        });
    }

    // --- Styling for the Modal ---
    GM_addStyle(`
        #barcode-viewer-modal {
            position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%;
            background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center;
            font-family: Arial, sans-serif;
        }
        .modal-content-bc {
            background-color: #fefefe; border-radius: 8px; box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
            width: 80%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column;
        }
        .modal-header-bc {
            padding: 10px 16px; background-color: #5c6bc0; color: white; border-top-left-radius: 8px;
            border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center;
        }
        .modal-header-bc h2 { margin: 0; font-size: 1.25em; }
        .close-button-bc { color: #fff; font-size: 28px; font-weight: bold; cursor: pointer; }
        .close-button-bc:hover { color: #f1f1f1; }
        .modal-body-bc { padding: 16px; overflow-y: auto; }
        .modal-body-bc table { width: 100%; border-collapse: collapse; }
        .modal-body-bc th, .modal-body-bc td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        .modal-body-bc th { background-color: #f2f2f2; }
        .modal-footer-bc {
            padding: 10px 16px; text-align: right; border-top: 1px solid #ddd;
            display: flex; justify-content: flex-end; gap: 10px;
        }
        #clear-barcodes-btn {
            background-color: #ef5350; color: white; border: none; padding: 10px 15px;
            border-radius: 5px; cursor: pointer; font-weight: bold;
        }
        #clear-barcodes-btn:hover { background-color: #d32f2f; }
        .confirm-text-bc { text-align: center; font-size: 1.1em; margin: 20px; }
        .modal-btn-bc { border: none; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-weight: bold; }
        .confirm-btn-bc { background-color: #ef5350; color: white; }
        .confirm-btn-bc:hover { background-color: #d32f2f; }
        .cancel-btn-bc { background-color: #e0e0e0; color: #333; }
        .cancel-btn-bc:hover { background-color: #bdbdbd; }
        .action-cell-bc { text-align: center !important; }
        .delete-barcode-btn {
            cursor: pointer;
            font-weight: bold;
            font-size: 20px;
            color: #ef5350;
            padding: 0 5px;
            border-radius: 4px;
        }
        .delete-barcode-btn:hover {
            color: white;
            background-color: #d32f2f;
        }
    `);

    // --- Start the script ---
    initialize();

})();