NHK School Program & Episode Tracker (Multi-Sheet with Auto-Cache)

Scrapes NHK programs/episodes into Excel sheets, tracks watched status, auto-caches.

// ==UserScript==
// @name         NHK School Program & Episode Tracker (Multi-Sheet with Auto-Cache)
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Scrapes NHK programs/episodes into Excel sheets, tracks watched status, auto-caches.
// @author       iniquitousx
// @match        https://www.nhk.or.jp/school/program/
// @match        https://www.nhk.or.jp/school/*/*/
// @match        https://www.nhk.or.jp/school/*/*/*/
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      www.nhk.or.jp
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    const CACHED_EXCEL_DATA_KEY = 'nhkSchoolExcelData_v1_2'; // Version bump for potential cache structure changes
    const PROGRAM_OVERVIEW_SHEET_NAME = "Program Overview";
    let currentExcelData = {};

    // --- Helper Functions ---
    async function fetchData(url) { /* ... (same as before) ... */
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        const parser = new DOMParser();
                        const doc = parser.parseFromString(response.responseText, 'text/html');
                        resolve(doc);
                    } else {
                        reject(new Error(`Failed to fetch ${url}: Status ${response.status}`));
                    }
                },
                onerror: function(error) {
                    reject(new Error(`Network error fetching ${url}: ${error}`));
                }
            });
        });
    }
    function getRubyText(element) { /* ... (same as before) ... */
        if (!element) return '';
        const rbElement = element.querySelector('rb');
        if (rbElement) return rbElement.textContent.trim();
        let text = element.textContent.trim();
        const rtElement = element.querySelector('rt');
        if (rtElement) {
            text = text.replace(rtElement.textContent.trim(), '');
        }
        return text.trim().replace(/\s+/g, ' ');
    }

    function cacheCurrentExcelData() {
        if (Object.keys(currentExcelData).length > 0) {
            GM_setValue(CACHED_EXCEL_DATA_KEY, JSON.stringify(currentExcelData));
            console.log("NHK Tracker: Excel data cached automatically.");
        } else {
            GM_deleteValue(CACHED_EXCEL_DATA_KEY);
            console.log("NHK Tracker: Excel data cache cleared.");
        }
    }

    function loadExcelDataFromCache() {
        const cachedJson = GM_getValue(CACHED_EXCEL_DATA_KEY, null);
        if (cachedJson) {
            try {
                currentExcelData = JSON.parse(cachedJson);
                console.log("NHK Tracker: Data loaded from cache.", Object.keys(currentExcelData).length, "sheets.");
                return true;
            } catch (e) {
                console.error("NHK Tracker: Error parsing cached data:", e);
                GM_deleteValue(CACHED_EXCEL_DATA_KEY); // Clear corrupted cache
                currentExcelData = {};
                return false;
            }
        }
        currentExcelData = {};
        return false;
    }

    function sanitizeSheetName(name) { /* ... (same as before) ... */
        return name.replace(/[:\\/?*[\]]/g, '').substring(0, 31);
    }

    // --- UI Elements and General Functions ---
    const statusDivGlobal = document.createElement('div');
    statusDivGlobal.id = 'nhk-tracker-global-status';
    const statusDivOnProgramPage = document.createElement('div'); // Defined here for broader access
    statusDivOnProgramPage.id = 'nhk-tracker-program-page-status';


    function displayStatus(message, isError = false, onProgramPage = false) {
        const targetDiv = onProgramPage ? statusDivOnProgramPage : statusDivGlobal;
        if(document.getElementById(targetDiv.id)) { // Only update if the div is on the page
            targetDiv.textContent = message;
            targetDiv.style.color = isError ? 'red' : 'black';
        }
        console.log("NHK Tracker Status:", message);
    }

    // --- UI Styling ---
    GM_addStyle(`/* ... (same full CSS as before) ... */
        #nhk-tracker-controls-container {
            position: fixed; top: 10px; right: 10px; z-index: 10000;
            background: #f8f9fa; border: 1px solid #dee2e6; border-radius: .25rem;
            padding: 15px; box-shadow: 0 .5rem 1rem rgba(0,0,0,.15); width: 320px;
            font-family: sans-serif; font-size: 14px;
        }
        .nhk-tracker-btn {
            background-color: #007bff; color: white; border: none; padding: 8px 12px;
            margin: 8px 0; cursor: pointer; border-radius: 4px; font-size: 14px; display: block; width: 100%;
            text-align: center; box-sizing: border-box;
        }
        .nhk-tracker-btn:hover { background-color: #0056b3; }
        .nhk-tracker-btn-success { background-color: #28a745; }
        .nhk-tracker-btn-success:hover { background-color: #1e7e34; }
        .nhk-tracker-btn-info { background-color: #17a2b8; }
        .nhk-tracker-btn-info:hover { background-color: #117a8b; }
        #nhk-tracker-global-status, #nhk-tracker-program-page-status {
            margin-top: 10px; padding: 8px; background-color: #e9ecef; border: 1px solid #ced4da;
            font-size: 13px; min-height: 20px; word-wrap: break-word;
        }
        #excelFileInput { display: none; }
        .nhk-episode-watched-toggle {
            padding: 3px 6px; font-size: 11px; min-width: 90px; margin-left: 8px;
            border: 1px solid #ccc; border-radius: 3px; cursor: pointer;
        }
        .nhk-episode-watched-toggle.watched-true { background-color: #28a745; color: white; }
        .nhk-episode-watched-toggle.watched-false { background-color: #dc3545; color: white; }
        .nhk-episode-watched-toggle.not-tracked { background-color: #ffc107; color: black; }
        .nhk-scan-prompt-div {
            background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba;
            padding: 10px; margin: 10px 0; border-radius: 4px; text-align: center; font-size: 13px;
        }
        .programList .itemList { display: flex; align-items: center; }
        .programList .itemList > a { flex-grow: 1; }
    `);


    // --- Excel Handling ---
    function handleExcelFileLoad(file) {
        const isOnProgramPage = !!document.getElementById('nhk-tracker-program-page-status');
        displayStatus('Reading Excel file...', false, isOnProgramPage);
        // ... (rest of handleExcelFileLoad - ENSURE IT CALLS cacheCurrentExcelData() after successful load)
        const reader = new FileReader();
        reader.onload = function(e) {
            try {
                const data = new Uint8Array(e.target.result);
                const workbook = XLSX.read(data, { type: 'array' });
                let newExcelData = {};
                workbook.SheetNames.forEach(sheetName => {
                    const worksheet = workbook.Sheets[sheetName];
                    newExcelData[sheetName] = XLSX.utils.sheet_to_json(worksheet);
                });
                currentExcelData = newExcelData;
                cacheCurrentExcelData(); // <<<< SAVE TO CACHE AFTER LOAD
                displayStatus(`Excel loaded. ${Object.keys(currentExcelData).length} sheets.`, false, isOnProgramPage);
                if (window.location.pathname.match(/^\/school\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\/($|onair\/$)/)) {
                    addEpisodeButtonsToPage();
                }
            } catch (err) {
                displayStatus(`Error processing Excel: ${err.message}`, true, isOnProgramPage);
            }
        };
        reader.readAsArrayBuffer(file);
    }

    function saveToExcel() { /* ... (same as before, this is an explicit action, doesn't need to re-cache) ... */
        const isOnProgramPage = !!document.getElementById('nhk-tracker-program-page-status');
        if (Object.keys(currentExcelData).length === 0) {
            alert("No data to save.");
            displayStatus("No data to save.", true, isOnProgramPage);
            return;
        }
        const workbook = XLSX.utils.book_new();
        for (const sheetName in currentExcelData) {
            if (currentExcelData.hasOwnProperty(sheetName) && currentExcelData[sheetName].length > 0) {
                const worksheet = XLSX.utils.json_to_sheet(currentExcelData[sheetName]);
                if (sheetName === PROGRAM_OVERVIEW_SHEET_NAME) {
                     worksheet['!cols'] = [ { wch: 25 }, { wch: 40 }, { wch: 60 } ];
                } else {
                     worksheet['!cols'] = [ { wch: 40 }, { wch: 60 }, { wch: 10 } ];
                }
                XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
            } else if (currentExcelData.hasOwnProperty(sheetName)) {
                const emptyWs = XLSX.utils.json_to_sheet([]);
                XLSX.utils.book_append_sheet(workbook, emptyWs, sheetName);
            }
        }
        XLSX.writeFile(workbook, "nhk_school_tracker.xlsx");
        displayStatus("Data exported to 'nhk_school_tracker.xlsx'.", false, isOnProgramPage);
    }


    // --- Logic for Program Overview Page (/school/program/) ---
    function scanProgramOverview() {
        displayStatus('Scanning program overview...');
        let programOverviewData = [];
        // ... (rest of scanProgramOverview as before) ...
        const gradeListSection = document.getElementById('gradeList');
        if (!gradeListSection) {
            displayStatus('Error: Main program section (id="gradeList") not found.', true);
            return;
        }
        const categoryBlocks = gradeListSection.querySelectorAll('div.listWrap[id]');
        if (categoryBlocks.length === 0) {
            displayStatus('No category blocks found.', true);
            return;
        }

        categoryBlocks.forEach(block => {
            const categoryHeader = block.querySelector('h3');
            const categoryRubyElement = categoryHeader ? categoryHeader.querySelector('ruby') : null;
            const categoryName = categoryRubyElement ? getRubyText(categoryRubyElement) : 'Unknown Category';
            const programItems = block.querySelectorAll('div.itemKyouka');
            programItems.forEach(item => {
                const anchorElement = item.querySelector('a');
                if (!anchorElement) return;
                const titleDiv = anchorElement.querySelector('div.title');
                const programName = titleDiv ? titleDiv.textContent.trim().replace(/\s+/g, ' ') : 'Unknown Program';
                let programLink = anchorElement.getAttribute('href');
                if (programLink) {
                    try { programLink = new URL(programLink, window.location.href).href; }
                    catch (e) { programLink = anchorElement.getAttribute('href'); }
                } else { programLink = '#'; }

                if (programName !== 'Unknown Program') {
                    programOverviewData.push({
                        "Category (Subject)": categoryName,
                        "Program Name": programName,
                        "Program Link": programLink
                    });
                }
            });
        });


        if (programOverviewData.length > 0) {
            currentExcelData[PROGRAM_OVERVIEW_SHEET_NAME] = programOverviewData;
            cacheCurrentExcelData(); // <<<< SAVE TO CACHE
            displayStatus(`Program overview updated: ${programOverviewData.length} programs.`);
        } else {
            displayStatus('No programs found in overview scan.', true);
        }
    }

    // --- Logic for Individual Program Pages ---
    async function scanEpisodesForCurrentProgram() {
        const isOnProgramPage = true;
        // ... (rest of scanEpisodesForCurrentProgram as before - ENSURE IT CALLS cacheCurrentExcelData() on success)
        const programNameElement = document.querySelector('#programHeader h2, .program-header__title, .program-detail__title');
        const programNameUnsanitized = programNameElement ? getRubyText(programNameElement) : "Unknown Program";
         if (programNameUnsanitized === "Unknown Program") {
            displayStatus("Could not determine program name from this page.", true, isOnProgramPage);
            return;
        }
        const programName = sanitizeSheetName(programNameUnsanitized); // Use sanitized for sheet name, but display original if needed
        const programSheetName = `${programName} Episodes`;
        const currentPageUrl = window.location.href;

        displayStatus(`Scanning episodes for "${programNameUnsanitized}"...`, false, isOnProgramPage);

        let existingEpisodesInSheet = (currentExcelData[programSheetName] || []).reduce((acc, ep) => {
            if(ep["Episode Link"]) acc[ep["Episode Link"]] = ep["Watched"];
            return acc;
        }, {});

        let newEpisodeListData = [];

        try {
            let docToScrapeEpisodesFrom = document;
            if (!currentPageUrl.includes('/onair/') && !document.querySelector('div.programList div.itemList a')) {
                const navHeader = document.querySelector('nav.nav-header');
                let episodeListPageUrl = null;
                if (navHeader) {
                    const links = navHeader.querySelectorAll('a');
                    for(const link of links){
                        if(link.getAttribute('href') && (link.getAttribute('href').includes('/onair/') || link.textContent.includes('放送リスト'))) {
                            episodeListPageUrl = new URL(link.getAttribute('href'), currentPageUrl).href;
                            break;
                        }
                    }
                }
                if (!episodeListPageUrl) {
                    const commonLink = document.querySelector('.onairLink a[href*="/onair/"], a.cbtn.school[href*="/onair/"]');
                    if (commonLink) episodeListPageUrl = new URL(commonLink.getAttribute('href'), currentPageUrl).href;
                }

                if (episodeListPageUrl && episodeListPageUrl !== currentPageUrl) {
                    displayStatus(`Fetching episode list from ${episodeListPageUrl}...`, false, isOnProgramPage);
                    docToScrapeEpisodesFrom = await fetchData(episodeListPageUrl);
                } else {
                     displayStatus(`Attempting to scrape episodes from current page: ${programNameUnsanitized}`, false, isOnProgramPage);
                }
            }

            const episodeItems = docToScrapeEpisodesFrom.querySelectorAll('div.programList div.itemList a');
            if (episodeItems.length === 0) {
                 displayStatus(`No episode items found for "${programNameUnsanitized}" with the current selectors.`, true, isOnProgramPage);
            }

            episodeItems.forEach(item => {
                const subTitleElement = item.querySelector('div.subTitle');
                const onairDateElement = item.querySelector('div.onair');
                const episodeTitle = (subTitleElement ? subTitleElement.textContent.trim() : (onairDateElement ? onairDateElement.textContent.trim() : 'Unknown Episode')).replace(/\s+/g, ' ');
                let episodeLink = item.getAttribute('href');
                if (episodeLink) {
                    try { episodeLink = new URL(episodeLink, docToScrapeEpisodesFrom.baseURI || currentPageUrl).href; }
                    catch(e) { console.warn("Invalid episode link:", item.getAttribute('href')); return; }
                } else { return; }

                const watchedStatus = existingEpisodesInSheet[episodeLink] || "No";
                newEpisodeListData.push({
                    "Episode Title": episodeTitle,
                    "Episode Link": episodeLink,
                    "Watched": watchedStatus
                });
            });

            currentExcelData[programSheetName] = newEpisodeListData;
            cacheCurrentExcelData(); // <<<< SAVE TO CACHE
            displayStatus(`"${programSheetName}" sheet updated: ${newEpisodeListData.length} episodes.`, false, isOnProgramPage);
            addEpisodeButtonsToPage();
        } catch (err) {
            displayStatus(`Error scanning episodes for "${programNameUnsanitized}": ${err.message}`, true, isOnProgramPage);
            console.error(err);
        }
    }

    function addEpisodeButtonsToPage() {
        const isOnProgramPage = true;
        // ... (rest of addEpisodeButtonsToPage - ENSURE button.onclick CALLS cacheCurrentExcelData())
        const programNameElement = document.querySelector('#programHeader h2, .program-header__title, .program-detail__title');
        let programNameUnsanitized = programNameElement ? getRubyText(programNameElement) : null;

        if (!programNameUnsanitized) {
            const pathParts = window.location.pathname.split('/').filter(Boolean);
            if (pathParts.length >= 3 && pathParts[0] === 'school') {
                let derivedName = pathParts[pathParts.length - (pathParts.includes('onair') ? 2 : 1)];
                const overview = currentExcelData[PROGRAM_OVERVIEW_SHEET_NAME] || [];
                const progEntry = overview.find(p => p["Program Link"] && p["Program Link"].includes(`/${pathParts[1]}/${derivedName}/`));
                programNameUnsanitized = progEntry ? progEntry["Program Name"] : derivedName;
            }
            if (!programNameUnsanitized) { console.warn("NHK Tracker: Could not determine program name for button addition."); return; }
        }
        programNameUnsanitized = programNameUnsanitized.replace(/\s+/g, ' ');
        const programName = sanitizeSheetName(programNameUnsanitized);
        const programSheetName = `${programName} Episodes`;

        const episodeListItems = document.querySelectorAll('div.programList div.itemList');
         if (episodeListItems.length === 0) {
            if(document.querySelector('div.programList')){ // Only show prompt if programList div exists but no items
                 displayStatus(`No episode items found on page for "${programNameUnsanitized}" to add buttons to.`, false, isOnProgramPage);
            }
            return;
        }

        const existingScanPrompt = document.querySelector('.nhk-scan-prompt-div');
        if (existingScanPrompt) existingScanPrompt.remove();

        if ((!currentExcelData[programSheetName] || currentExcelData[programSheetName].length === 0) && episodeListItems.length > 0) {
            const programListDiv = document.querySelector('div.programList');
            if (programListDiv) {
                const scanPromptDiv = document.createElement('div');
                scanPromptDiv.className = 'nhk-scan-prompt-div';
                scanPromptDiv.innerHTML = `Episode data for "<strong>${programNameUnsanitized}</strong>" is not yet tracked. <br>`;
                const scanNowButton = document.createElement('button');
                scanNowButton.textContent = 'Scan Episodes Now';
                scanNowButton.className = 'nhk-tracker-btn nhk-tracker-btn-info';
                scanNowButton.style.width = 'auto'; scanNowButton.style.display = 'inline-block'; scanNowButton.style.padding = '5px 10px';
                scanNowButton.onclick = async () => { await scanEpisodesForCurrentProgram(); scanPromptDiv.remove(); };
                scanPromptDiv.appendChild(scanNowButton);
                programListDiv.parentNode.insertBefore(scanPromptDiv, programListDiv);
            }
            return;
        }

        episodeListItems.forEach(itemDiv => {
            const anchor = itemDiv.querySelector('a');
            if (!anchor) return;
            let episodeLink = anchor.getAttribute('href');
            if (episodeLink) { try { episodeLink = new URL(episodeLink, window.location.href).href; } catch(e) { return; } } else { return; }

            const oldButton = itemDiv.querySelector('.nhk-episode-watched-toggle');
            if (oldButton) oldButton.remove();

            const episodeDataEntry = (currentExcelData[programSheetName] || []).find(ep => ep["Episode Link"] === episodeLink);
            let isWatched = false; let buttonText = "Mark Watched"; let buttonClass = "watched-false";

            if (episodeDataEntry) {
                isWatched = (episodeDataEntry["Watched"] === "Yes");
                buttonText = isWatched ? "Watched" : "Mark Watched";
                buttonClass = isWatched ? "watched-true" : "watched-false";
            } else {
                buttonText = "Not Tracked"; buttonClass = "not-tracked";
            }

            const button = document.createElement('button');
            button.className = `nhk-episode-watched-toggle ${buttonClass}`; button.textContent = buttonText;

            if (episodeDataEntry) {
                button.onclick = () => {
                    if (!currentExcelData[programSheetName]) return;
                    let epEntry = currentExcelData[programSheetName].find(ep => ep["Episode Link"] === episodeLink);
                    if (epEntry) {
                        epEntry["Watched"] = (epEntry["Watched"] === "Yes") ? "No" : "Yes";
                        isWatched = (epEntry["Watched"] === "Yes");
                        button.textContent = isWatched ? "Watched" : "Mark Watched";
                        button.classList.toggle('watched-true', isWatched);
                        button.classList.toggle('watched-false', !isWatched);
                        cacheCurrentExcelData(); // <<<< SAVE TO CACHE
                        displayStatus(`Episode status updated. Save Excel to persist.`, false, isOnProgramPage);
                    }
                };
            } else {
                button.disabled = true; button.title = "Episode not in tracker. Scan episodes.";
            }
            const detailDiv = itemDiv.querySelector('div.detail');
            if(detailDiv) detailDiv.appendChild(button); else itemDiv.appendChild(button);
        });
    }


    // --- Page Specific UI Initialization ---
    function initPageControls() {
        const controlsContainerId = 'nhk-tracker-controls-container';
        let isOnProgramPage = window.location.pathname.match(/^\/school\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\/($|onair\/$)/);

        if (document.getElementById(controlsContainerId)) {
            if (isOnProgramPage) { addEpisodeButtonsToPage(); }
            return;
        }
        // ... (rest of initPageControls as before, ensuring displayStatus targets correctly)
        const controlsContainer = document.createElement('div');
        controlsContainer.id = controlsContainerId;

        const title = document.createElement('h4');
        title.textContent = 'NHK Tracker';
        title.style.textAlign = 'center'; title.style.marginBottom = '10px'; title.style.marginTop = '0';

        const excelFileInput = document.createElement('input');
        excelFileInput.type = 'file'; excelFileInput.id = 'excelFileInput'; excelFileInput.accept = ".xlsx, .xls";
        excelFileInput.onchange = (event) => {
            const file = event.target.files[0];
            if (file) {
                 if (Object.keys(currentExcelData).length > 0) {
                    if (!confirm("Loading this Excel will replace any currently cached data. Continue?")) {
                        event.target.value = null; return;
                    }
                }
                handleExcelFileLoad(file); // This now calls cacheCurrentExcelData
            }
            event.target.value = null;
        };

        const loadExcelButton = document.createElement('button');
        loadExcelButton.textContent = 'Load Data (Excel)';
        loadExcelButton.className = 'nhk-tracker-btn';
        loadExcelButton.onclick = () => excelFileInput.click();

        const saveExcelButton = document.createElement('button');
        saveExcelButton.textContent = 'Save Data (Excel)';
        saveExcelButton.className = 'nhk-tracker-btn nhk-tracker-btn-success';
        saveExcelButton.onclick = saveToExcel;

        controlsContainer.appendChild(title);
        controlsContainer.appendChild(loadExcelButton);
        controlsContainer.appendChild(excelFileInput);
        controlsContainer.appendChild(saveExcelButton);

        let statusDivToUse = statusDivGlobal;

        if (window.location.pathname === '/school/program/') {
            const scanOverviewButton = document.createElement('button');
            scanOverviewButton.textContent = 'Scan/Update Program List';
            scanOverviewButton.className = 'nhk-tracker-btn nhk-tracker-btn-info';
            scanOverviewButton.onclick = scanProgramOverview; // This now calls cacheCurrentExcelData
            controlsContainer.appendChild(scanOverviewButton);
        } else if (isOnProgramPage) {
            const scanEpisodesButton = document.createElement('button');
            scanEpisodesButton.textContent = 'Scan/Update Episodes (This Program)';
            scanEpisodesButton.className = 'nhk-tracker-btn nhk-tracker-btn-info';
            scanEpisodesButton.onclick = scanEpisodesForCurrentProgram; // This now calls cacheCurrentExcelData
            controlsContainer.appendChild(scanEpisodesButton);
            statusDivOnProgramPage.id = 'nhk-tracker-program-page-status';
            statusDivToUse = statusDivOnProgramPage;
        }
        controlsContainer.appendChild(statusDivToUse);
        document.body.appendChild(controlsContainer);

        console.log("NHK Tracker: initPageControls - On program page. Will attempt to add episode buttons after a short delay.");
        setTimeout(() => {
            console.log("NHK Tracker: initPageControls - setTimeout fired. Calling addEpisodeButtonsToPage.");
            addEpisodeButtonsToPage();
        }, 1000);
    }

    // --- Main Execution ---
    loadExcelDataFromCache(); // Load any previously saved data at the very start
    initPageControls();

})();