MLTSHP Image Saver

Adds download buttons to save MLTSHP images with their post titles as filenames

// ==UserScript==
// @name         MLTSHP Image Saver
// @namespace    https://mltshp.com/
// @version      0.1.1
// @description  Adds download buttons to save MLTSHP images with their post titles as filenames
// @author       You
// @match        https://mltshp.com/*
// @match        https://mltshp-cdn.com/*
// @exclude      https://mltshp-cdn.com/r/*
// @icon         https://mltshp.com/static/images/apple-touch-icon.png
// @grant        GM_registerMenuCommand
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // DEBUG MODE - set to true for verbose console logging
    const DEBUG = false;

    // Store image-to-title mappings
    const IMAGE_MAP = new Map();
    
    // Store processed images to prevent duplicates
    const PROCESSED_IMAGES = new Set();

    // Log function that only outputs when debug is enabled
    function log(...args) {
        if (DEBUG) {
            console.log('[MLTSHP Saver]', ...args);
        }
    }

    // Add CSS for the download button - using standard DOM methods instead of GM_addStyle
    function addStyles() {
        const styleElement = document.createElement('style');
        styleElement.textContent = `
            .mltshp-download-btn {
                background-color: green;
                color: white;
                padding: 6px 10px;
                border: 0px solid #000;
                border-radius: 4px;
                cursor: pointer;
                font-size: 14px;
                font-weight: normal;
                margin-left: 8px;
                display: none; /* Hide by default */
                text-decoration: none;
                transition: all 0.3s;
                vertical-align: middle;
                position: relative;
                z-index: 9999;
                box-shadow: 0 2px 5px rgba(0,0,0,0.3);
            }
            
            /* Show button on image hover */
            .mltshp-image-container:hover .mltshp-download-btn {
                display: inline-block;
            }
            
            .mltshp-image-container {
                position: relative;
                display: inline-block;
            }
            
            @keyframes pulse-attention {
                0% { transform: scale(1); }
                50% { transform: scale(1.1); }
                100% { transform: scale(1); }
            }
            
            .mltshp-download-btn:hover {
                background-color: #ff7043;
                transform: scale(1.05);
            }
            
            .mltshp-download-btn:active {
                background-color: #e64a19;
                transform: scale(0.95);
            }
            
            /* For debugging: Highlight images that have been processed */
            .mltshp-processed-image {
                outline: ${DEBUG ? '1px solid #ff5722' : 'none'};
            }
            
            /* For debugging: Highlight title elements */
            .mltshp-title-element {
                outline: ${DEBUG ? '1px dashed #2196F3' : 'none'};
            }
            
            /* Floating button as a fallback */
            .mltshp-floating-btn {
                position: fixed;
                top: -80px; /* Moved 50px higher */
                right: 20px;
                z-index: 10000;
                padding: 8px 15px;
                background-color: #ff5722;
                color: white;
                font-weight: bold;
                border: none;
                border-radius: 4px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.4);
                cursor: pointer;
            }
            
            /* Floating debug info panel */
            .mltshp-debug-panel {
                position: fixed;
                bottom: 10px;
                right: 10px;
                background: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 10px;
                border-radius: 5px;
                font-size: 12px;
                z-index: 10001;
                max-width: 300px;
                max-height: 200px;
                overflow: auto;
                display: ${DEBUG ? 'block' : 'none'};
            }
        `;
        document.head.appendChild(styleElement);
    }
    
    // Add styles immediately
    addStyles();

    // Create a floating debug button
    function addFloatingButton() {
        const button = document.createElement('button');
        button.className = 'mltshp-floating-btn';
        button.textContent = 'Force Add Buttons';
        button.addEventListener('click', function() {
            scanPageAndAddButtons(true);
        });
        document.body.appendChild(button);
        
        if (DEBUG) {
            // Add debug panel
            const debugPanel = document.createElement('div');
            debugPanel.className = 'mltshp-debug-panel';
            debugPanel.innerHTML = '<h4>MLTSHP Image Saver Debug</h4><div id="mltshp-debug-content"></div>';
            document.body.appendChild(debugPanel);
        }
    }
    
    // Helper to update debug panel
    function updateDebugPanel(message) {
        if (!DEBUG) return;
        
        const panel = document.getElementById('mltshp-debug-content');
        if (panel) {
            panel.innerHTML = message + panel.innerHTML;
            if (panel.children.length > 10) {
                panel.removeChild(panel.lastChild);
            }
        }
    }
    
    // Function to check if an image should be skipped
    function shouldSkipImage(img) {
        // Skip logo-compact.svg and any header logos
        if (img.src.includes('logo-compact.svg') || 
            img.src.includes('logo') || 
            img.classList.contains('logo') || 
            (img.id && img.id.includes('logo')) ||
            (img.parentElement && 
             (img.parentElement.classList.contains('header') || 
              (img.parentElement.id && img.parentElement.id.includes('header'))))) {
            log('Skipping header/logo image:', img.src);
            return true;
        }
        
        // Skip very small images (likely icons)
        if (img.width < 50 || img.height < 50) {
            log('Skipping small image:', img.src);
            return true;
        }
        
        // Skip if image is not from MLTSHP domains
        if (!img.src.includes('mltshp.com') && !img.src.includes('mltshp-cdn.com')) {
            log('Skipping non-MLTSHP image:', img.src);
            return true;
        }
        
        return false;
    }
    
    // Function to get the full-size image URL (remove width and other parameters)
    function getFullSizeImageUrl(url) {
        // If the URL contains query parameters, remove them to get the original image
        if (url.includes('?')) {
            return url.split('?')[0];
        }
        return url;
    }

    // Function to sanitize filenames by removing special characters
    function sanitizeFilename(filename) {
        // Replace special characters with dashes, keep spaces
        return filename
            .replace(/[\\/:*?"<>|]/g, '-') // Replace Windows illegal characters
            .replace(/\s{2,}/g, ' ')       // Replace multiple spaces with single space
            .replace(/^\s+|\s+$/g, '')     // Trim leading/trailing spaces
            .replace(/[^\w\-\. ]/g, '-')   // Replace other non-word chars (keep hyphens, periods and spaces)
            .substring(0, 100);            // Limit filename length
    }

    // NEW: Function to detect all posts and create image -> title mappings
    function buildImageTitleMap() {
        log('Building comprehensive image-title mapping...');
        
        // Clear any existing mappings
        IMAGE_MAP.clear();
        
        // 1. First identify all post containers on the page
        const postSelectors = [
            '.post', '.shakeshingle', 'article', 'li', '.content div', '.item', 
            '.span-6', '.span-8', '.image-content', '.the-image'
        ];
        
        const allPotentialContainers = [];
        postSelectors.forEach(selector => {
            document.querySelectorAll(selector).forEach(container => {
                allPotentialContainers.push(container);
            });
        });
        
        log(`Found ${allPotentialContainers.length} potential post containers`);
        
        // 2. Process each container to find image and title pairs
        allPotentialContainers.forEach((container, index) => {
            // Find images in this container
            const images = container.querySelectorAll('img');
            if (images.length === 0) return;
            
            // Find title elements in this container
            const h3Elements = container.querySelectorAll('h3');
            const titleElements = [...h3Elements];
            
            // Also look for other potential title elements
            const otherTitleElements = container.querySelectorAll('.description, .caption, p, h1, h2, h4');
            titleElements.push(...otherTitleElements);
            
            if (titleElements.length === 0) return;
            
            log(`Container ${index} has ${images.length} images and ${titleElements.length} potential title elements`);
            
            // Map each image to its most likely title
            images.forEach(img => {
                // Skip unwanted images
                if (shouldSkipImage(img)) return;
                
                const imageUrl = getFullSizeImageUrl(img.src);
                
                // If we already have this image mapped, skip
                if (IMAGE_MAP.has(imageUrl)) return;
                
                // Find the best title match for this image
                let bestTitleElement = null;
                let shortestDistance = Infinity;
                
                // Get image position
                const imgRect = img.getBoundingClientRect();
                
                // Calculate distance to each title element
                titleElements.forEach(titleEl => {
                    // Skip empty title elements
                    if (!titleEl.textContent || titleEl.textContent.trim().length < 3) return;
                    
                    const titleRect = titleEl.getBoundingClientRect();
                    
                    // Prefer title elements above the image
                    let distance;
                    if (titleRect.bottom <= imgRect.top) {
                        // Title is above image (preferred)
                        distance = imgRect.top - titleRect.bottom;
                    } else if (titleRect.top >= imgRect.bottom) {
                        // Title is below image (less preferred)
                        distance = titleRect.top - imgRect.bottom + 1000; // Add penalty
                    } else {
                        // Title overlaps image (least preferred)
                        distance = Math.abs(titleRect.top - imgRect.top) + 2000; // Add larger penalty
                    }
                    
                    // If this title is closer than our current best, update
                    if (distance < shortestDistance) {
                        shortestDistance = distance;
                        bestTitleElement = titleEl;
                    }
                });
                
                // If we found a title, map this image to it
                if (bestTitleElement) {
                    const title = bestTitleElement.textContent.trim();
                    log(`Mapping image ${imageUrl} to title "${title}"`);
                    
                    // Store the mapping
                    IMAGE_MAP.set(imageUrl, {
                        title: title,
                        titleElement: bestTitleElement,
                        distance: shortestDistance
                    });
                    
                    // Mark the title element for debugging
                    if (DEBUG) {
                        bestTitleElement.classList.add('mltshp-title-element');
                        bestTitleElement.title = `Mapped to image: ${imageUrl}`;
                    }
                } else {
                    // Try fallback methods for finding a title
                    
                    // Check alt text
                    if (img.alt && img.alt.trim() && img.alt !== 'alt text') {
                        IMAGE_MAP.set(imageUrl, {
                            title: img.alt.trim(),
                            titleElement: img,
                            source: 'alt-text'
                        });
                        log(`Using alt text for ${imageUrl}: "${img.alt.trim()}"`);
                        return;
                    }
                    
                    // Check for post URL ID
                    const postIdMatch = imageUrl.match(/\/([A-Za-z0-9]+)$/);
                    if (postIdMatch && postIdMatch[1]) {
                        const postId = postIdMatch[1];
                        const title = `MLTSHP Post ${postId}`;
                        IMAGE_MAP.set(imageUrl, {
                            title: title,
                            titleElement: img.parentElement,
                            source: 'post-id'
                        });
                        log(`Using post ID for ${imageUrl}: "${title}"`);
                        return;
                    }
                    
                    // Final fallback: create unique name
                    const uniqueId = Math.floor(Math.random() * 10000);
                    const fallbackTitle = `MLTSHP Image ${uniqueId} ${new Date().toISOString().slice(0, 10)}`;
                    IMAGE_MAP.set(imageUrl, {
                        title: fallbackTitle,
                        titleElement: img.parentElement,
                        source: 'fallback'
                    });
                    log(`Using fallback title for ${imageUrl}: "${fallbackTitle}"`);
                }
            });
        });
        
        // 3. Special case for single post pages
        if (window.location.pathname.includes('/p/')) {
            const mainImage = document.querySelector('.image-content img, .the-image img');
            const mainHeading = document.querySelector('h1, h2, h3');
            
            if (mainImage && mainHeading && mainHeading.textContent.trim()) {
                const imageUrl = getFullSizeImageUrl(mainImage.src);
                const title = mainHeading.textContent.trim();
                
                IMAGE_MAP.set(imageUrl, {
                    title: title,
                    titleElement: mainHeading,
                    source: 'single-post'
                });
                
                log(`Single post page: Mapped main image to title "${title}"`);
                
                if (DEBUG) {
                    mainHeading.classList.add('mltshp-title-element');
                }
            }
        }
        
        // Log summary
        log(`Completed mapping with ${IMAGE_MAP.size} image-title pairs`);
        
        // Debug output of all mappings
        if (DEBUG) {
            let debugMessage = '<strong>Image-Title Mappings:</strong><br>';
            IMAGE_MAP.forEach((value, key) => {
                debugMessage += `• ${key.substring(key.lastIndexOf('/') + 1)}: "${value.title.substring(0, 20)}${value.title.length > 20 ? '...' : ''}"<br>`;
            });
            updateDebugPanel(debugMessage);
        }
        
        return IMAGE_MAP.size;
    }
    
    // Function to get the title for a specific image
    function getTitleForImage(img) {
        const imageUrl = getFullSizeImageUrl(img.src);
        
        // Check if we have this image mapped
        if (IMAGE_MAP.has(imageUrl)) {
            return IMAGE_MAP.get(imageUrl);
        }
        
        // If not mapped, create a fallback title
        const uniqueId = Math.floor(Math.random() * 10000);
        const fallbackTitle = `MLTSHP Image ${uniqueId} ${new Date().toISOString().slice(0, 10)}`;
        return {
            title: fallbackTitle,
            titleElement: img.parentElement,
            source: 'dynamic-fallback'
        };
    }
    
    // Function to add a download button to an image
    function addButtonToImage(img) {
        // Skip if already processed
        if (PROCESSED_IMAGES.has(img.src)) {
            return false;
        }
        
        // Mark as processed
        PROCESSED_IMAGES.add(img.src);
        
        // Get the image info
        const imageUrl = getFullSizeImageUrl(img.src);
        const imageInfo = getTitleForImage(img);
        
        // For debugging
        img.classList.add('mltshp-processed-image');
        
        // Add image URL as data attribute
        img.setAttribute('data-mltshp-url', imageUrl);
        
        // Try to find the save-this-link button near this image
        let saveThisButton = null;
        let container = img.closest('article, .post, li, .content div, .item');
        
        if (container) {
            saveThisButton = container.querySelector('.save-this-link');
        }
        
        // If we found the save button, insert our download button before it
        if (saveThisButton) {
            // Check if we already added a button
            if (saveThisButton.parentNode.querySelector('.mltshp-download-btn')) {
                return false;
            }
            
            // Create our button with similar styling to the site button
            const button = document.createElement('button');
            button.textContent = 'Download';
            button.className = 'mltshp-download-btn btn btn-primary btn-small';
            button.style.marginRight = '5px';
            
            // Add data attributes for debugging
            button.setAttribute('data-title', imageInfo.title);
            button.setAttribute('data-source', imageInfo.source || 'mapped');
            
            // Add click event listener
            button.addEventListener('click', function(e) {
                e.preventDefault();
                e.stopPropagation();
                downloadImage(imageUrl, imageInfo.title, button);
            });
            
            // Insert the button before the save button
            saveThisButton.parentNode.insertBefore(button, saveThisButton);
            
            log(`Added button next to save button for: "${imageInfo.title}"`);
            return true;
        }
        
        // Fallback to the old method of adding button on the image if we can't find the save button
        // Create container for the button
        const buttonContainer = document.createElement('div');
        buttonContainer.className = 'mltshp-image-container';
        buttonContainer.style.position = 'relative';
        buttonContainer.style.display = 'inline-block';
        
        // Create the button
        const button = document.createElement('button');
        button.textContent = 'Download';
        button.className = 'mltshp-download-btn';
        button.style.position = 'absolute';
        button.style.bottom = '5px'; 
        button.style.right = '5px';
        
        // Add data attributes for debugging
        button.setAttribute('data-title', imageInfo.title);
        button.setAttribute('data-source', imageInfo.source || 'mapped');
        
        // Add click event listener
        button.addEventListener('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            downloadImage(imageUrl, imageInfo.title, button);
        });
        
        // Insert the button
        const parent = img.parentElement;
        if (parent) {
            parent.insertBefore(buttonContainer, img);
            buttonContainer.appendChild(img);
            buttonContainer.appendChild(button);
            
            log(`Added button to image ${imageUrl} with title: "${imageInfo.title}"`);
            return true;
        }
        
        return false;
    }
    
    // Main function to scan the page and add buttons
    function scanPageAndAddButtons(forceRebuild = false) {
        log('Scanning page for images...');
        
        // Build the image-title map if needed or forced
        if (IMAGE_MAP.size === 0 || forceRebuild) {
            const mappedCount = buildImageTitleMap();
            log(`Built image map with ${mappedCount} entries`);
        }
        
        // Get all images on the page
        const allImages = document.querySelectorAll('img');
        log(`Found ${allImages.length} total images on page`);
        
        let addedButtonCount = 0;
        
        // Process each image
        allImages.forEach((img) => {
            // Skip unwanted images
            if (shouldSkipImage(img)) {
                return;
            }
            
            // Add button directly on the image
            if (addButtonToImage(img)) {
                addedButtonCount++;
            }
        });
        
        log(`Added ${addedButtonCount} image buttons`);
        
        // Update debug panel
        updateDebugPanel(`<strong>Added Buttons:</strong><br>• ${addedButtonCount} image buttons<br>`);
        
        return addedButtonCount;
    }

    // Function to get file extension from image URL
    function getFileExtension(url) {
        // Extract the base URL without query parameters
        const baseUrl = url.split('?')[0];
        
        const regex = /\.([a-zA-Z0-9]+)$/;
        const match = baseUrl.match(regex);
        
        // If no extension found in the URL, attempt to determine by checking last path segment
        if (!match) {
            // Extract ID from URL pattern like https://mltshp-cdn.com/r/1QZ0Z
            const idMatch = baseUrl.match(/\/([A-Za-z0-9]+)$/);
            if (idMatch) {
                log('No extension found, using jpg as default');
                return 'jpg';
            }
        }
        
        return match ? match[1].toLowerCase() : 'jpg';
    }

    // Function to download image with title as filename
    function downloadImage(imageUrl, title, button) {
        // Get file extension
        const extension = getFileExtension(imageUrl);
        
        // Create a sanitized filename
        const filename = sanitizeFilename(title) + '.' + extension;
        
        log('Downloading image with filename:', filename);
        
        // Save original button text
        const originalText = button.textContent;
        
        // Update button to show loading
        button.textContent = 'Downloading...';
        button.disabled = true;
        
        // Force binary download using the Fetch API
        fetchAndSaveImage(imageUrl, filename, button, originalText);
    }
    
    // Function to fetch and save image using fetch API
    function fetchAndSaveImage(imageUrl, filename, button, originalText) {
        log(`Fetching image from ${imageUrl} as ${filename}`);
        
        try {
            // Use GM_xmlhttpRequest if available for cross-origin support
            if (typeof GM_xmlhttpRequest !== 'undefined') {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: imageUrl,
                    responseType: 'blob',
                    onload: function(response) {
                        saveBlob(response.response, filename, button, originalText);
                    },
                    onerror: function(error) {
                        console.error('Error fetching image:', error);
                        handleDownloadError(button, originalText);
                    }
                });
            } else {
                // Fallback to fetch API
                fetch(imageUrl)
                    .then(response => {
                        if (!response.ok) {
                            throw new Error('Network response was not ok');
                        }
                        return response.blob();
                    })
                    .then(blob => {
                        saveBlob(blob, filename, button, originalText);
                    })
                    .catch(error => {
                        console.error('Error fetching image:', error);
                        handleDownloadError(button, originalText);
                    });
            }
        } catch (error) {
            console.error('Error in fetchAndSaveImage:', error);
            handleDownloadError(button, originalText);
        }
    }
    
    // Function to save a blob to disk
    function saveBlob(blob, filename, button, originalText) {
        try {
            // Create a Blob URL
            const url = URL.createObjectURL(blob);
            
            // Create an anchor element to trigger the download
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            a.style.display = 'none';
            
            // Add to document, click to trigger download, then remove
            document.body.appendChild(a);
            a.click();
            
            // Clean up by removing the element and revoking the object URL
            setTimeout(() => {
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
                
                // Show success message
                button.textContent = 'Downloaded!';
                button.style.backgroundColor = '#2196F3';
                
                // Reset button after a delay
                setTimeout(() => {
                    button.textContent = originalText;
                    button.style.backgroundColor = '';
                    button.disabled = false;
                }, 2000);
            }, 100);
            
        } catch (error) {
            console.error('Error saving blob:', error);
            handleDownloadError(button, originalText);
        }
    }
    
    // Function to handle download errors
    function handleDownloadError(button, originalText) {
        button.textContent = 'Error!';
        button.style.backgroundColor = '#f44336';
        
        setTimeout(() => {
            button.textContent = originalText;
            button.style.backgroundColor = '';
            button.disabled = false;
        }, 2000);
    }
    
    // Wait for document to be ready
    function onReady(fn) {
        if (document.readyState !== 'loading') {
            fn();
        } else {
            document.addEventListener('DOMContentLoaded', fn);
        }
    }
    
    // Run initialization once the document is ready
    onReady(function() {
        log('MLTSHP Image Saver: Document ready, initializing...');
        
        // Build the initial image-title map
        setTimeout(() => {
            buildImageTitleMap();
            // Add buttons after mapping is complete
            setTimeout(() => scanPageAndAddButtons(), 500);
        }, 500);
        
        // Add floating button
        setTimeout(addFloatingButton, 1000);
        
        // Set up MutationObserver for dynamic content
        const observer = new MutationObserver(function(mutations) {
            // Only run if we detect new images
            let hasNewImages = false;
            
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1) { // Element node
                            if (node.tagName === 'IMG' || node.querySelector('img')) {
                                hasNewImages = true;
                            }
                        }
                    });
                }
            });
            
            if (hasNewImages) {
                setTimeout(() => scanPageAndAddButtons(), 500);
            }
        });
        
        observer.observe(document.body, { 
            childList: true, 
            subtree: true,
            attributes: true,
            attributeFilter: ['src']
        });
        
        // Also run periodically for any missed content
        setInterval(() => scanPageAndAddButtons(), 10000);
        
        // Show notification
        showNotification();
    });
    
    // Add a menu command to enable/disable script
    let isEnabled = true;
    
    function toggleScript() {
        isEnabled = !isEnabled;
        
        const buttons = document.querySelectorAll('.mltshp-download-btn');
        
        if (isEnabled) {
            buttons.forEach(btn => btn.style.display = 'inline-block');
            // Re-scan when enabling
            scanPageAndAddButtons();
            alert('MLTSHP Image Saver is now enabled');
        } else {
            buttons.forEach(btn => btn.style.display = 'none');
            alert('MLTSHP Image Saver is now disabled');
        }
    }
    
    // Register menu command if available
    if (typeof GM_registerMenuCommand !== 'undefined') {
        GM_registerMenuCommand('Toggle MLTSHP Image Saver', toggleScript);
    }
    
    // Show notification that script is running
    function showNotification() {
        const notification = document.createElement('div');
        notification.style.position = 'fixed';
        notification.style.bottom = '0px'; 
        notification.style.left = '10px';
        notification.style.padding = '8px 15px';
        notification.style.background = 'rgba(0, 0, 0, 0.7)';
        notification.style.color = 'white';
        notification.style.borderRadius = '4px';
        notification.style.fontSize = '14px';
        notification.style.zIndex = '9999';
        notification.style.opacity = '0';
        notification.style.transition = 'opacity 0.3s ease-in-out';
        notification.innerHTML = `
            <div>✅ MLTSHP Image Saver Active</div>
            <div style="font-size:12px;margin-top:3px;">Mapped ${IMAGE_MAP.size} images to unique titles</div>
        `;
        
        document.body.appendChild(notification);
        
        // Show notification
        setTimeout(() => {
            notification.style.opacity = '1';
            setTimeout(() => {
                notification.style.opacity = '0';
                setTimeout(() => {
                    notification.remove();
                }, 500);
            }, 5000);
        }, 1000);
    }
    
    log('MLTSHP Image Saver: Script initialized');
})();