Greasy Fork

Greasy Fork is available in English.

Nitan Airport Flair

Enhances webpage content by identifying airport IATA codes, styling them, and adding interactive tooltips and hyperlinks, with an ignore list and local data resource.

当前为 2025-05-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         Nitan Airport Flair
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Enhances webpage content by identifying airport IATA codes, styling them, and adding interactive tooltips and hyperlinks, with an ignore list and local data resource.
// @author       s5kf
// @license      CC BY-NC-ND 4.0; https://creativecommons.org/licenses/by-nc-nd/4.0/
// @match        *://www.uscardforum.com/*
// @resource     airportJsonData https://raw.githubusercontent.com/s5kf/airport_flair/main/airports_filtered.json
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-idle
// @supportURL   https://github.com/s5kf/airport_flair/issues 
// ==/UserScript==

(function() {
    'use strict';

    let airportData = {}; // To store airport data keyed by IATA code
    let observer = null; // To store the MutationObserver instance
    let ignoredAirportCodes = []; // To store user-ignored IATA codes

    // --- Constants (moved up for early initialization) ---
    const iataRegex = /\b([A-Za-z]{3})\b/g;
    const processedMark = 'airport-flair-processed';
    const flairTag = 'airport-flair-tag'; // Class for the flair span itself
    const potentialFlairClass = 'potential-airport-code'; // New class for potential flairs

    // --- Preference Storage (localStorage) ---
    function loadIgnoredCodes() {
        try {
            const storedCodes = localStorage.getItem('airportFlair_ignoredCodes');
            return storedCodes ? JSON.parse(storedCodes) : [];
        } catch (e) {
            console.error("Error loading ignored codes from localStorage:", e);
            return [];
        }
    }

    function saveIgnoredCodes(codesArray) {
        try {
            localStorage.setItem('airportFlair_ignoredCodes', JSON.stringify(codesArray));
        } catch (e) {
            console.error("Error saving ignored codes to localStorage:", e);
        }
    }

    // Load ignored codes at script start
    ignoredAirportCodes = loadIgnoredCodes();

    // --- Load Airport Data from @resource ---
    function initializeAirportData() {
        try {
            console.log("Attempting to load airport data from @resource...");
            const jsonDataString = GM_getResourceText("airportJsonData");
            if (!jsonDataString) {
                console.error("Failed to get airport data from @resource. GM_getResourceText returned empty.");
                return false;
            }

            // Pre-process the JSON string to handle non-standard values like Infinity and NaN
            const sanitizedJsonDataString = jsonDataString
                .replace(/: Infinity,/g, ": null,")
                .replace(/: Infinity}/g, ": null}")
                .replace(/: NaN,/g, ": null,")
                .replace(/: NaN}/g, ": null}");

            const data = JSON.parse(sanitizedJsonDataString);
            data.forEach(airport => {
                if (airport.iata_code) { // This will correctly skip null iata_codes
                    airportData[airport.iata_code.toUpperCase()] = airport;
                }
            });
            console.log("Airport data loaded and processed from @resource:", Object.keys(airportData).length, "entries");
            return true;
        } catch (e) {
            console.error("Error loading or parsing airport data from @resource:", e);
            if (jsonDataString) { // Log snippet if resource was read but parsing failed
                 console.error("Original resource text snippet (first 500 chars if error persists):", jsonDataString.substring(0,500));
            }
            return false;
        }
    }

    // Initialize data and then process the page
    if (initializeAirportData()) {
        // Call processPage directly as data loading is now synchronous relative to script execution start
        // It might still be deferred by @run-at document-idle, so DOM might be ready.
        // Ensure processPage handles the case where the DOM isn't fully ready if run too early,
        // but with document-idle, it should be fine.
        processPage();
    } else {
        console.error("Airport Flair script will not run due to data loading issues.");
    }

    // --- CSS Styles ---
    function getDynamicStyles() {
        const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
        const htmlElement = document.documentElement;
        const bodyElement = document.body;

        let isLikelyDarkMode = prefersDark ||
                               htmlElement.classList.contains('dark') ||
                               bodyElement.classList.contains('dark') ||
                               htmlElement.classList.contains('dark-mode') || // Added common alternative
                               bodyElement.classList.contains('dark-mode');   // Added common alternative

        // Get computed background colors
        const htmlBgColor = getComputedStyle(htmlElement).backgroundColor;
        const bodyBgColor = getComputedStyle(bodyElement).backgroundColor;

        // Function to check if a CSS color string represents a dark color
        function isColorDark(colorString) {
            if (!colorString || colorString === 'transparent' || colorString === 'rgba(0, 0, 0, 0)') return false;
            if (colorString === 'rgb(0, 0, 0)' || colorString === '#000000') return true; // Pure black
            try {
                const rgbMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
                if (rgbMatch) {
                    const r = parseInt(rgbMatch[1], 10);
                    const g = parseInt(rgbMatch[2], 10);
                    const b = parseInt(rgbMatch[3], 10);
                    // Heuristic: if average intensity is low, or all components are relatively low
                    if ((r + g + b) / 3 < 75 || (r < 100 && g < 100 && b < 100)) {
                        return true;
                    }
                }
            } catch (e) {
                // Ignore parsing errors, default to not dark for this check
            }
            return false;
        }

        if (!isLikelyDarkMode) {
            // If no class or media query indicated dark mode, check computed background colors
            if (isColorDark(bodyBgColor) || isColorDark(htmlBgColor)) {
                isLikelyDarkMode = true;
            }
        }

        let flairBgColor = '#eff3f4'; // Default light mode

        if (isLikelyDarkMode) {
            // Now, differentiate between dim and lights out if we've determined it's dark
            // Prioritize body background for "lights out" check, then html
            if (bodyBgColor === 'rgb(0, 0, 0)' || bodyBgColor === '#000000' ||
                htmlBgColor === 'rgb(0, 0, 0)' || htmlBgColor === '#000000') {
                flairBgColor = '#202327'; // Lights out
            } else {
                flairBgColor = '#273440'; // Dim mode (default dark)
            }
        }

        return `
            .airport-flair {
                display: inline-flex;
                align-items: center;
                vertical-align: baseline;
                font-family: 'Fira Code', 'Roboto Mono', monospace;
                padding: 1px 4px; /* Reduced padding */
                color: #e6c07b; /* Light gold text */
                background-color: ${flairBgColor};
                border-radius: 3px;
                text-decoration: none; /* For the anchor tag */
                margin: 0 1px; /* Small margin to prevent touching adjacent text */
                position: relative; /* For absolute positioning of the dismiss button */
            }
            .airport-flair img.country-flag {
                width: 16px;
                height: 12px;
                margin-left: 4px; /* Switched from margin-right and slightly increased for balance */
                vertical-align: middle;
            }
            .airport-flair .dismiss-flair {
                position: absolute;
                top: -6px;
                right: -6px;
                width: 16px;
                height: 16px;
                line-height: 16px;
                text-align: center;
                font-size: 14px;
                font-weight: bold;
                background-color: rgba(74, 74, 74, 0.6); /* Translucent background */
                color: #ffffff;
                border-radius: 50%;
                cursor: pointer;
                opacity: 0;
                visibility: hidden;
                transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out, background-color 0.15s ease-in-out;
                z-index: 10;
            }
            .airport-flair:hover .dismiss-flair {
                opacity: 0.7; /* Slightly more see-through on flair hover */
                visibility: visible;
            }
            .airport-flair .dismiss-flair:hover {
                opacity: 0.9; /* Still a bit see-through on button hover */
                background-color: rgba(51, 51, 51, 0.75); /* Darker translucent background on hover */
            }
            .${potentialFlairClass} {
                text-decoration: underline dotted rgba(128, 128, 128, 0.7);
                cursor: help;
                /* Ensure it doesn't pick up parent link styles if it's inside an <a> not yet processed */
                color: inherit; 
            }
            .${potentialFlairClass}:hover {
                text-decoration-color: rgba(100, 100, 100, 1); /* Darker underline on hover */
                background-color: rgba(200, 200, 200, 0.1); /* Very subtle hover background */
            }
            /* No specific hover title CSS needed if using default browser title attribute */
        `;
    }

    function injectStyles() {
        // Remove existing styles if they exist to update dynamic ones (like dark mode)
        const existingStyleId = 'airport-flair-styles';
        let existingStyleElement = document.getElementById(existingStyleId);
        if (existingStyleElement) {
            existingStyleElement.remove();
        }
        GM_addStyle(getDynamicStyles());
        // Add an ID to the style element for easy removal/update if needed
        const styleElements = document.head.getElementsByTagName('style');
        if(styleElements.length > 0) {
             // Assume the last style element added by GM_addStyle is ours if we don't have a direct way to ID it from GM_addStyle
            styleElements[styleElements.length - 1].id = existingStyleId;
        }
    }

    // --- DOM Interaction & Manipulation ---
    function createFlairElement(code, airportInfo) {
        const anchor = document.createElement('a');
        anchor.href = `https://www.google.com/search?q=airport+${code}`;
        anchor.target = '_blank';
        anchor.rel = 'noopener noreferrer';
        anchor.classList.add('airport-flair');
        anchor.classList.add(processedMark); // Mark the anchor itself

        // Tooltip text
        let titleText = `${airportInfo.name} (${code})`;
        if (airportInfo.municipality) titleText += `, ${airportInfo.municipality}`;
        if (airportInfo.iso_country) titleText += `, ${airportInfo.iso_country}`;
        anchor.title = titleText;

        // Append IATA code text first
        const codeTextNode = document.createTextNode(code);
        anchor.appendChild(codeTextNode);

        // Then append flag image if country is available
        if (airportInfo.iso_country) {
            const flagImg = document.createElement('img');
            flagImg.src = `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/4.1.5/flags/4x3/${airportInfo.iso_country.toLowerCase()}.svg`;
            flagImg.alt = `${airportInfo.iso_country} flag`;
            flagImg.classList.add('country-flag');
            anchor.appendChild(flagImg);
        }

        // Add Dismiss Button (appended last, its position is absolute)
        const dismissBtn = document.createElement('span');
        dismissBtn.classList.add('dismiss-flair');
        dismissBtn.innerHTML = '&times;'; // HTML entity for '×'
        dismissBtn.title = `Dismiss ${code} (don't show flair for this code)`;
        dismissBtn.dataset.code = code; // Store the code for the event listener

        dismissBtn.addEventListener('click', function(event) {
            event.preventDefault();  // Stop link navigation
            event.stopPropagation(); // Stop event from bubbling up

            const codeToDismiss = event.target.dataset.code;
            if (codeToDismiss && !ignoredAirportCodes.includes(codeToDismiss.toUpperCase())) {
                ignoredAirportCodes.push(codeToDismiss.toUpperCase());
                saveIgnoredCodes(ignoredAirportCodes);

                // Replace flair with plain text
                const currentFlairElement = event.target.closest('.airport-flair');
                if (currentFlairElement && currentFlairElement.parentNode) {
                    currentFlairElement.parentNode.replaceChild(document.createTextNode(codeToDismiss), currentFlairElement);
                }
            }
        });
        anchor.appendChild(dismissBtn);

        return anchor;
    }

    // --- Function to create a span for potential, non-all-caps airport codes ---
    function createPotentialFlairElement(originalCode, airportInfo) {
        const span = document.createElement('span');
        span.classList.add(potentialFlairClass);
        span.classList.add(processedMark); // Mark as processed to avoid re-evaluation by observer/processNode
        span.textContent = originalCode;
        
        let titleText = `Recognize ${originalCode.toUpperCase()} as an airport?`;
        if (airportInfo && airportInfo.name) {
            titleText += ` (${airportInfo.name})`;
        }
        span.title = titleText;

        // Store necessary info for conversion
        span.dataset.originalCode = originalCode;

        const clickListener = function(event) {
            event.preventDefault();
            event.stopPropagation();

            const codeToConvert = event.target.dataset.originalCode;
            const uppercaseCode = codeToConvert.toUpperCase();
            const currentAirportInfo = airportData[uppercaseCode]; // Re-fetch, though already have airportInfo

            if (currentAirportInfo) {
                const fullFlairElement = createFlairElement(uppercaseCode, currentAirportInfo);
                if (event.target.parentNode) {
                    event.target.parentNode.replaceChild(fullFlairElement, event.target);
                }
            }
            // Listener is automatically removed as the element it was attached to is replaced.
        };

        span.addEventListener('click', clickListener, { once: true }); // { once: true } ensures it runs only once

        return span;
    }

    function replaceTextWithFlair(textNode) {
        if (!textNode.parentNode || textNode.parentNode.classList.contains(processedMark) || textNode.parentNode.closest('a, script, style, input, textarea, [contenteditable="true"]')) {
            return; // Already processed or in an excluded element
        }

        const text = textNode.nodeValue;
        let match;
        let lastIndex = 0;
        const fragment = document.createDocumentFragment();
        let foundMatch = false;

        while ((match = iataRegex.exec(text)) !== null) {
            const originalCode = match[1]; // e.g., "sjc", "DCA", "LAX"
            const uppercaseCode = originalCode.toUpperCase();

            // Check if the uppercase version is in the ignored list
            if (ignoredAirportCodes.includes(uppercaseCode)) {
                continue; // Skip this match if it's an ignored code
            }

            const airportInfo = airportData[uppercaseCode];

            if (airportInfo) {
                foundMatch = true;
                // Text before the match
                if (match.index > lastIndex) {
                    fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
                }

                // If original code is already all uppercase, create full flair
                // Otherwise, create a potential flair span
                if (originalCode === uppercaseCode) {
                    const flairElement = createFlairElement(uppercaseCode, airportInfo);
                    fragment.appendChild(flairElement);
                } else {
                    const potentialElement = createPotentialFlairElement(originalCode, airportInfo);
                    fragment.appendChild(potentialElement);
                }
                lastIndex = iataRegex.lastIndex;
            }
        }

        if (foundMatch) {
            // Text after the last match
            if (lastIndex < text.length) {
                fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
            }
            // Replace the original text node with the fragment
            textNode.parentNode.replaceChild(fragment, textNode);
            // Mark parent to avoid re-processing (more robust than just the flair itself having the mark for parent checks)
            // However, this might be too broad. Marking the flair element itself (done in createFlairElement) and checking for that class is safer.
        }
    }

    function processNode(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            replaceTextWithFlair(node);
        }
        // Check if the node itself or its parent has been processed or is an excluded type
        else if (node.nodeType === Node.ELEMENT_NODE && 
                 !node.classList.contains(processedMark) && // Check for the generic processed mark
                 !node.closest('a, script, style, input, textarea, [contenteditable="true"], .' + flairTag + ', .' + potentialFlairClass)) {
            // If it's an element node, and not one of our flairs (full or potential), process its children
             Array.from(node.childNodes).forEach(child => processNode(child));
        }
    }

    function processAddedNodes(mutationList) {
        for (const mutation of mutationList) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(node => {
                    // Check if the node itself has already been processed (e.g. if it's part of a flair we just added)
                    if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains(processedMark)) {
                        return;
                    }
                    processNode(node);
                });
            }
        }
        // After processing mutations, it's good to re-evaluate dynamic styles like dark mode
        injectStyles();
    }

    function observeDOMChanges() {
        if (observer) observer.disconnect(); // Disconnect previous observer if any

        observer = new MutationObserver(processAddedNodes);
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
        console.log("MutationObserver started.");
    }

    function processPage() {
        console.log("Processing page for airport codes...");
        injectStyles();
        // Initial scan of the entire body
        processNode(document.body);
        observeDOMChanges();
    }

    // --- Cleanup ---
    window.addEventListener('unload', () => {
        if (observer) {
            observer.disconnect();
            console.log("Disconnected MutationObserver.");
        }
    });

})();