EXIF Info

Displays EXIF information for images

// ==UserScript==
// @name         EXIF Info
// @namespace    https://x.com/anpho
// @version      1.0
// @description  Displays EXIF information for images
// @author       MerrickZ
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      *
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==

(function() {
    'use strict';

    // Inject styles using GM_addStyle for better performance
    GM_addStyle(`
        .exif-icon {
            position: absolute;
            width: 16px;
            height: 16px;
            background-color: rgba(0, 0, 0, 0.7);
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-size: 12px;
            cursor: pointer;
            z-index: 10000;
            top: 5px;
            right: 5px;
        }
        .exif-tooltip {
            position: absolute;
            background-color: rgba(0, 0, 0, 0.9);
            color: white;
            padding: 8px;
            border-radius: 4px;
            font-size: 12px;
            max-width: 300px;
            z-index: 10001;
            display: none;
            pointer-events: none;
            white-space: nowrap;
            top: 25px;
            right: 0;
        }
        .exif-modal {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
            z-index: 10002;
            max-width: 80vw;
            max-height: 80vh;
            overflow: auto;
            display: none;
        }
        .exif-modal-overlay {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: rgba(0, 0, 0, 0.5);
            z-index: 10001;
            display: none;
        }
        .exif-modal table {
            border-collapse: collapse;
            width: 100%;
            table-layout: fixed;
            border: 1px solid #ddd;
        }
        .exif-modal table tr:before,
        .exif-modal table tr:after,
        .exif-modal table td:before,
        .exif-modal table td:after,
        .exif-modal table th:before,
        .exif-modal table th:after {
            display: none !important;
            content: none !important;
        }
        .exif-modal table td:first-child {
            width: 40%;
        }
        .exif-modal table td:last-child {
            width: 60%;
        }
        .exif-modal th, .exif-modal td {
            border: 1px solid #ddd;
            padding: 8px;
            text-align: left;
            word-break: break-word;
        }
        .exif-modal th {
            background-color: #f5f5f5;
            font-weight: bold;
        }
        .exif-modal-close {
            position: absolute;
            right: 10px;
            top: 10px;
            cursor: pointer;
            font-size: 20px;
        }
        .copy-button {
            margin-top: 10px;
            padding: 5px 10px;
            background-color: #4CAF50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        .copy-button:hover {
            background-color: #45a049;
        }
    `);

    // Create modal once
    const modalOverlay = document.createElement('div');
    modalOverlay.className = 'exif-modal-overlay';
    document.body.appendChild(modalOverlay);

    const modal = document.createElement('div');
    modal.className = 'exif-modal';
    modal.innerHTML = '<span class="exif-modal-close">&times;</span><div class="exif-modal-content"></div>';
    document.body.appendChild(modal);

    // Close modal handlers
    function closeModal() {
        modal.style.display = 'none';
        modalOverlay.style.display = 'none';
    }

    modalOverlay.addEventListener('click', closeModal);
    modal.querySelector('.exif-modal-close').addEventListener('click', closeModal);

    // Stop propagation of clicks inside modal
    modal.addEventListener('click', (e) => {
        e.stopPropagation();
    });

    function getExifData(img) {
        return new Promise((resolve) => {
            // Skip small thumbnails and icons
            if (img.width < 50 || img.height < 50) {
                resolve({});
                return;
            }

            // For Flickr: try to get the original image URL
            let imgUrl = img.src;
            if (imgUrl.includes('staticflickr.com')) {
                // Convert to larger size by replacing _t, _m, _n, etc with _b or _o
                imgUrl = imgUrl.replace(/_(t|m|n|s|q|sq)\./i, '_b.');
            }

            // Load image data
            const tempImg = new Image();
            tempImg.crossOrigin = 'Anonymous';
            tempImg.onload = function() {
                EXIF.getData(this, function() {
                    const exifData = EXIF.getAllTags(this) || {};
                    resolve(exifData);
                });
            };
            tempImg.onerror = function() {
                console.log('Failed to load image:', imgUrl);
                resolve({});
            };
            tempImg.src = imgUrl;
        });
    }

    function formatExifValue(key, value) {
        // Skip null, undefined, or empty values
        if (value === null || value === undefined || value === '') return null;
        
        // Skip thumbnail data
        if (key.toLowerCase().includes('thumbnail')) return null;
        
        // Handle arrays
        if (Array.isArray(value)) {
            // If array contains objects or is empty, skip it
            if (value.length === 0 || value.some(item => typeof item === 'object')) return null;
            return value.join(', ');
        }
        
        // Skip if value is an object
        if (typeof value === 'object') return null;
        
        // Convert to string and handle special cases
        if (key === 'ExposureTime') {
            // Format exposure time as fraction (e.g., 1/250)
            return `1/${Math.round(1/value)}`;
        }
        if (key === 'FNumber') {
            return `f/${value}`;
        }
        if (key === 'ISOSpeedRatings') {
            return `ISO ${value}`;
        }
        if (key === 'Flash') {
            return value === 1 ? 'Yes' : 'No';
        }
        
        // Skip if value is just zeros or seems like binary data
        if (/^[0\s]+$/.test(String(value))) return null;
        
        return String(value);
    }

    function createTooltipContent(exifData) {
        // Filter out unwanted keys
        const skipKeys = ['componentsconfiguration', 'filesource', 'scenetype', 'thumbnail'];
        
        return Object.entries(exifData)
            .filter(([key]) => !skipKeys.some(skip => key.toLowerCase().includes(skip)))
            .map(([key, value]) => {
                const formatted = formatExifValue(key, value);
                return formatted ? `${key}: ${formatted}` : null;
            })
            .filter(item => item !== null)
            .join('<br>');
    }

    function createTableContent(exifData) {
        // Filter out unwanted keys
        const skipKeys = ['componentsconfiguration', 'filesource', 'scenetype', 'thumbnail'];
        
        const rows = Object.entries(exifData)
            .filter(([key]) => !skipKeys.some(skip => key.toLowerCase().includes(skip)))
            .map(([key, value]) => {
                const formatted = formatExifValue(key, value);
                if (!formatted) return null;
                return `<tr><td>${key}</td><td>${formatted}</td></tr>`;
            })
            .filter(row => row !== null)
            .join('');
            
        return `<table cellspacing="0" cellpadding="0">
            <thead>
                <tr>
                    <th>Property</th>
                    <th>Value</th>
                </tr>
            </thead>
            <tbody>
                ${rows}
            </tbody>
        </table>`;
    }

    async function processImage(img) {
        // Skip if already processed
        if (img.dataset.exifProcessed) return;
        img.dataset.exifProcessed = 'true';

        try {
            const exifData = await getExifData(img);
            if (!exifData || Object.keys(exifData).length === 0) return;

            const container = document.createElement('div');
            container.style.position = 'relative';
            container.style.display = 'inline-block';
            container.style.width = img.width + 'px';
            container.style.height = img.height + 'px';
            img.parentNode.insertBefore(container, img);
            container.appendChild(img);

            const icon = document.createElement('div');
            icon.className = 'exif-icon';
            icon.innerHTML = 'i';
            container.appendChild(icon);

            const tooltip = document.createElement('div');
            tooltip.className = 'exif-tooltip';
            const tooltipContent = createTooltipContent(exifData);
            if (tooltipContent) {
                tooltip.innerHTML = tooltipContent;
                icon.appendChild(tooltip);

                icon.onmouseenter = () => tooltip.style.display = 'block';
                icon.onmouseleave = () => tooltip.style.display = 'none';

                icon.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const modalContent = modal.querySelector('.exif-modal-content');
                    modalContent.innerHTML = createTableContent(exifData);

                    const copyButton = document.createElement('button');
                    copyButton.className = 'copy-button';
                    copyButton.textContent = 'Copy to Clipboard';
                    copyButton.onclick = () => {
                        const tableText = Object.entries(exifData)
                            .filter(([key]) => !skipKeys.some(skip => key.toLowerCase().includes(skip)))
                            .map(([key, value]) => {
                                const formatted = formatExifValue(key, value);
                                return formatted ? `${key}: ${formatted}` : null;
                            })
                            .filter(item => item !== null)
                            .join('\n');
                        navigator.clipboard.writeText(tableText)
                            .then(() => {
                                copyButton.textContent = 'Copied!';
                                setTimeout(() => copyButton.textContent = 'Copy to Clipboard', 2000);
                            });
                    };
                    modalContent.appendChild(copyButton);
                    
                    modal.style.display = 'block';
                    modalOverlay.style.display = 'block';
                });
            }
        } catch (error) {
            // Silently fail for individual images
        }
    }

    // Process images in batches to avoid blocking
    function processBatch(images, index = 0, batchSize = 5) {
        const end = Math.min(index + batchSize, images.length);
        for (let i = index; i < end; i++) {
            const img = images[i];
            // Skip very small images and already processed ones
            if (img.width < 100 || img.height < 100 || img.dataset.exifProcessed) {
                continue;
            }
            processImage(img);
        }
        if (end < images.length) {
            setTimeout(() => processBatch(images, end, batchSize), 100);
        }
    }

    // Initial processing with delay to ensure images are loaded
    setTimeout(() => {
        const images = Array.from(document.getElementsByTagName('img'));
        processBatch(images);
    }, 1000);

    // Watch for new images
    const observer = new MutationObserver((mutations) => {
        const newImages = [];
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeName === 'IMG' && node.width >= 100 && node.height >= 100) {
                    newImages.push(node);
                }
                if (node.getElementsByTagName) {
                    const imgs = Array.from(node.getElementsByTagName('img'))
                        .filter(img => img.width >= 100 && img.height >= 100);
                    newImages.push(...imgs);
                }
            });
        });
        if (newImages.length) {
            setTimeout(() => processBatch(newImages), 500);
        }
    });

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