// ==UserScript==
// @name Discord Image Downloader
// @namespace http://tampermonkey.net/
// @version 1.06
// @description Adds a download button to images and GIFs in Discord.
// @author Yukiteru
// @match https://discord.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=discord.com
// @grant GM_download
// @grant GM_log
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
GM_log('Discord Universal Image Downloader script started.');
// --- Configuration ---
const MESSAGE_LI_SELECTOR = 'li[id^="chat-messages-"]';
const MESSAGE_ACCESSORIES_SELECTOR = 'div[id^="message-accessories-"]';
// Selectors for different types of media containers within accessories
const MOSAIC_ITEM_SELECTOR = 'div[class^="mosaicItem"]'; // Grid items (attachments, some embeds)
const SPOILER_CONTENT_SELECTOR = 'div[class^="spoilerContent"]'; // Spoiler overlay (often inside mosaicItem)
const INLINE_MEDIA_EMBED_SELECTOR = 'div[class^="inlineMediaEmbed"]';// Simple inline embeds (like the new example)
// Combined selector for any top-level distinct media block
const ANY_MEDIA_BLOCK_SELECTOR = `${MOSAIC_ITEM_SELECTOR}, ${INLINE_MEDIA_EMBED_SELECTOR}`;
// Selectors for elements *within* a media block
const IMAGE_WRAPPER_SELECTOR = 'div[class^="imageWrapper"]'; // Actual image container (consistent across types)
const ORIGINAL_LINK_SELECTOR = 'a[class^="originalLink"]'; // Link with source URL (consistent)
const HOVER_BUTTON_GROUP_SELECTOR = 'div[class^="hoverButtonGroup"]';// Target container for buttons (may need creation)
// const IMAGE_CONTENT_SELECTOR = 'div[class^="imageContent"]'; // Common parent, less specific now
// Markers
const IMAGE_PROCESSED_MARKER = 'data-dl-img-processed';
const SPOILER_LISTENER_MARKER = 'data-dl-spoiler-listener-added';
const DOWNLOAD_BUTTON_CLASS = 'discord-native-dl-button';
// Native Button Styling
const NATIVE_ANCHOR_CLASSES_PREFIXES = ['anchor_', 'anchorUnderlineOnHover_', 'hoverButton_'];
const DOWNLOAD_SVG_HTML = `
<svg class="downloadHoverButtonIcon__6c706" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z" class=""></path>
</svg>`;
// --- Dynamic Class Name Cache ---
const classCache = {};
function findActualClassName(scope, prefix) {
if (classCache[prefix]) return classCache[prefix];
const searchScope = scope || document.body;
try {
const element = searchScope.querySelector(`[class^="${prefix}"]`);
if (element) {
const classList = element.classList;
for (let i = 0; i < classList.length; i++) {
if (classList[i].startsWith(prefix)) {
classCache[prefix] = classList[i];
return classList[i];
}
}
}
} catch (e) { GM_log(`Error finding class with prefix ${prefix}: ${e}`); }
return null;
}
function generateFilename(originalUrl, imageWrapper) {
let dateStamp = '0000-00-00';
let messageId = 'unknownMsgId';
let fileIndexStr = '';
const messageLi = imageWrapper.closest(MESSAGE_LI_SELECTOR);
if (messageLi) {
// Get date from message timestamp
const timeElement = messageLi.querySelector('time[datetime]');
if (timeElement && timeElement.dateTime) {
try {
const messageDate = new Date(timeElement.dateTime);
if (!isNaN(messageDate.getTime())) {
const year = messageDate.getFullYear();
const month = String(messageDate.getMonth() + 1).padStart(2, '0');
const day = String(messageDate.getDate()).padStart(2, '0');
dateStamp = `${year}-${month}-${day}`;
} else { GM_log(`Filename Date: Failed parse: ${timeElement.dateTime}`); }
} catch (e) { GM_log(`Filename Date: Error parsing: ${e}`); }
} else { GM_log(`Filename Date: No time[datetime] found in msg ${messageLi.id}`); }
// Get Message ID
if (messageLi.id) {
const idParts = messageLi.id.split('-');
if (idParts.length > 0) messageId = idParts[idParts.length - 1];
}
// Calculate Index - UPDATED to count diverse media blocks
const accessoriesContainer = messageLi.querySelector(MESSAGE_ACCESSORIES_SELECTOR);
if (accessoriesContainer) {
// --- FIX: Count all distinct top-level media blocks ---
const allMediaBlocks = accessoriesContainer.querySelectorAll(ANY_MEDIA_BLOCK_SELECTOR);
const totalCount = allMediaBlocks.length;
// GM_log(`Filename Index: Found ${totalCount} media blocks in msg ${messageId}`);
if (totalCount > 1) {
// --- FIX: Find the current media block (mosaic or inline embed) ---
const currentBlock = imageWrapper.closest(ANY_MEDIA_BLOCK_SELECTOR);
if (currentBlock) {
const index = Array.from(allMediaBlocks).indexOf(currentBlock) + 1;
if (index > 0) {
fileIndexStr = `_${index}`;
// GM_log(`Filename Index: Assigned index ${index} for msg ${messageId}`);
} else { GM_log(`Filename Index: Could not find current block in allMediaBlocks list for msg ${messageId}.`); }
} else { GM_log(`Filename Index: Could not find parent media block for imageWrapper in msg ${messageId}`); }
}
} else { GM_log(`Filename Index: Could not find accessories container in msg ${messageId}`); }
} else { GM_log(`Filename: Could not find parent message LI.`); }
let extension = 'dat'; // Default fallback
try {
const url = new URL(originalUrl);
const pathname = url.pathname;
// 1. Try extracting from the path
const lastDotIndex = pathname.lastIndexOf('.');
const lastSlashIndex = pathname.lastIndexOf('/'); // Ensure dot is in the filename part
if (lastDotIndex > lastSlashIndex) {
const extFromPath = pathname.substring(lastDotIndex + 1);
// Basic validation: check if it looks like a typical extension (alphanumeric, reasonable length)
if (/^[a-z0-9]{2,5}$/i.test(extFromPath)) {
extension = extFromPath.toLowerCase();
// GM_log(`Extracted extension from path: ${extension}`); // Debug log
}
}
// 2. If path didn't yield a valid extension, try 'format' query parameter
if (extension === 'dat') { // Only check format if path failed
const formatParam = url.searchParams.get('format');
if (formatParam && /^[a-z0-9]{2,5}$/i.test(formatParam)) {
extension = formatParam.toLowerCase();
// GM_log(`Extracted extension from format param: ${extension}`); // Debug log
}
}
} catch (e) {
GM_log(`Error parsing URL for extension: ${originalUrl}. Error: ${e}`);
// Keep the default 'dat' on error
}
return `${dateStamp}_${messageId}${fileIndexStr}.${extension}`;
}
/**
* Finds or creates the container where the download button should be placed.
* Works for mosaic items, spoilers, and inline embeds.
* @param {Element} imageWrapper - The image wrapper element.
* @returns {Element|null} The container element (usually hoverButtonGroup) or null if creation fails.
*/
function findButtonTargetContainer(imageWrapper) {
// --- FIX: Find the parent media block (mosaic, inline embed, or spoiler) ---
// For spoilers, we actually want the container *inside* the spoiler if possible,
// or the spoiler itself as the fallback parent for the hover group.
const parentSpoiler = imageWrapper.closest(SPOILER_CONTENT_SELECTOR);
const parentMediaBlock = parentSpoiler || imageWrapper.closest(ANY_MEDIA_BLOCK_SELECTOR); // Prefer spoiler if present
if (!parentMediaBlock) {
GM_log('findButtonTargetContainer: Could not find parent media block (mosaic, embed, or spoiler).');
return null;
}
// GM_log('findButtonTargetContainer: Found parent block:', parentMediaBlock);
// 1. Try to find an EXISTING hoverButtonGroup within the parent block
// Need to search carefully, could be direct child or deeper (e.g., inside imageContainer for inline)
const existingGroup = parentMediaBlock.querySelector(HOVER_BUTTON_GROUP_SELECTOR);
if (existingGroup) {
// GM_log('findButtonTargetContainer: Found existing hoverButtonGroup.');
return existingGroup;
}
// 2. If no existing group, CREATE one
const newGroup = document.createElement('div');
newGroup.classList.add('custom-dl-hover-group'); // Custom marker
// Try to append it next to where other buttons might be, or as a direct child.
// For inline embeds, inside the 'imageContainer__' seems appropriate if it exists.
let appendTarget = parentMediaBlock; // Default target
const imageContainer = imageWrapper.closest('div[class^="imageContainer"]');
if (imageContainer && parentMediaBlock.contains(imageContainer)) {
// If an imageContainer exists within our block, append the group there.
// This handles the inline embed case better.
appendTarget = imageContainer;
// GM_log('findButtonTargetContainer: Appending new group to imageContainer.');
} else {
// GM_log('findButtonTargetContainer: Appending new group directly to parentMediaBlock.');
}
appendTarget.appendChild(newGroup);
// GM_log('findButtonTargetContainer: Created and appended new hover group.');
return newGroup;
}
/**
* Extract correct image/video url from the anchor element.
* @param {Element} linkElement - The image anchor element.
*/
function getImageUrl(linkElement) {
const href = linkElement.href;
const dataSafeSrc = linkElement.getAttribute('data-safe-src');
try {
const pathname = new URL(href).pathname;
const ext = pathname.slice((pathname.lastIndexOf(".") - 1 >>> 0) + 2);
return ext ? href : dataSafeSrc;
} catch(e) {
return dataSafeSrc;
}
}
/**
* Attempts to add a download button to a given imageWrapper. Checks markers and existing buttons.
* @param {Element} imageWrapper - The image wrapper element.
* @param {boolean} forceCheck - If true, bypasses the IMAGE_PROCESSED_MARKER check (used after spoiler click).
*/
function addDownloadButton(imageWrapper, forceCheck = false) {
if (!imageWrapper || (!forceCheck && imageWrapper.hasAttribute(IMAGE_PROCESSED_MARKER))) {
return;
}
imageWrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true');
const originalLinkElement = imageWrapper.querySelector(ORIGINAL_LINK_SELECTOR);
if (!originalLinkElement || !originalLinkElement.href) {
return;
}
const imageUrl = getImageUrl(originalLinkElement);
const targetContainer = findButtonTargetContainer(imageWrapper); // Should now find/create container
if (!targetContainer) {
// Log updated message
GM_log(`AddButton: Failed to find or create a suitable hover buttons container for image: ${imageUrl}`);
return; // Cannot proceed without a container
}
// Check if OUR button already exists in the found/created container
if (targetContainer.querySelector(`.${DOWNLOAD_BUTTON_CLASS}`)) {
// GM_log('AddButton: Download button already exists in target container.');
return;
}
const filename = generateFilename(imageUrl, imageWrapper);
// GM_log(`AddButton: Preparing button for ${filename} in`, targetContainer);
const downloadButton = document.createElement('a');
downloadButton.href = imageUrl;
downloadButton.target = "_blank";
downloadButton.rel = "noreferrer noopener";
downloadButton.setAttribute('role', 'button');
downloadButton.setAttribute('aria-label', 'Download Image');
downloadButton.title = `Download ${filename}`;
downloadButton.tabIndex = 0;
NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => {
const actualClass = findActualClassName(document.body, prefix);
if (actualClass) downloadButton.classList.add(actualClass);
});
downloadButton.classList.add(DOWNLOAD_BUTTON_CLASS);
downloadButton.innerHTML = DOWNLOAD_SVG_HTML;
downloadButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
GM_log(`Attempting GM_download: ${imageUrl} as ${filename}`);
try {
GM_download({ url: imageUrl, name: filename, onerror: (err) => GM_log(`Download error: ${JSON.stringify(err)}`) });
} catch (e) { GM_log(`Error initiating GM_download: ${e}`); }
});
targetContainer.appendChild(downloadButton);
GM_log(`AddButton: Added button for ${filename}`);
}
/**
* Attaches a click listener to a spoiler element if it hasn't been done yet.
* The listener reveals the image and then calls addDownloadButton.
* @param {Element} spoilerElement - The spoiler content element.
*/
function handleSpoiler(spoilerElement) {
if (!spoilerElement || spoilerElement.hasAttribute(SPOILER_LISTENER_MARKER)) {
return;
}
const imageWrapperInside = spoilerElement.querySelector(IMAGE_WRAPPER_SELECTOR);
if (!imageWrapperInside) {
spoilerElement.setAttribute(SPOILER_LISTENER_MARKER, 'true'); // Mark anyway
return;
}
spoilerElement.setAttribute(SPOILER_LISTENER_MARKER, 'true');
spoilerElement.addEventListener('click', () => {
setTimeout(() => {
const revealedImageWrapper = spoilerElement.querySelector(IMAGE_WRAPPER_SELECTOR);
if (revealedImageWrapper) {
addDownloadButton(revealedImageWrapper, true); // Force check after reveal
} else { GM_log("HandleSpoiler: Could not find revealed image wrapper post-click."); }
}, 200);
}, { once: true });
}
/**
* Processes a node to find image wrappers or spoilers containing images.
* Handles regular images, spoiled images, and inline embeds.
* @param {Node} node - The node to process.
*/
function processNode(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Find all image wrappers within this node that haven't been processed
const imageWrappers = node.querySelectorAll(`${IMAGE_WRAPPER_SELECTOR}:not([${IMAGE_PROCESSED_MARKER}])`);
imageWrappers.forEach(wrapper => {
// If it's inside a spoiler, let the spoiler handler deal with it upon click
if (wrapper.closest(SPOILER_CONTENT_SELECTOR)) {
wrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true'); // Mark now, handle later
} else {
// Process directly (regular image or inline embed) with slight delay
setTimeout(() => addDownloadButton(wrapper), 150);
}
});
// Also check if the node itself is a non-spoiled wrapper
if (node.matches(IMAGE_WRAPPER_SELECTOR) && !node.hasAttribute(IMAGE_PROCESSED_MARKER) && !node.closest(SPOILER_CONTENT_SELECTOR)) {
setTimeout(() => addDownloadButton(node), 150);
}
// Find spoiler elements containing images that need listeners
const spoilerElements = node.querySelectorAll(`${SPOILER_CONTENT_SELECTOR}:not([${SPOILER_LISTENER_MARKER}]):has(${IMAGE_WRAPPER_SELECTOR})`);
spoilerElements.forEach(spoiler => {
setTimeout(() => handleSpoiler(spoiler), 150);
});
// Also check if the node itself is a spoiler needing handling
if (node.matches(`${SPOILER_CONTENT_SELECTOR}:has(${IMAGE_WRAPPER_SELECTOR})`) && !node.hasAttribute(SPOILER_LISTENER_MARKER)) {
setTimeout(() => handleSpoiler(node), 150);
}
}
}
// --- Observer ---
const observer = new MutationObserver((mutationsList) => {
NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => findActualClassName(document.body, prefix)); // Ensure classes cached
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(processNode);
}
}
});
// --- Initialization ---
function initialize() {
GM_log("Initializing Universal Downloader...");
GM_log("Fetching initial dynamic class names...");
NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => findActualClassName(document.body, prefix));
GM_log("Starting MutationObserver.");
observer.observe(document.body, { childList: true, subtree: true });
GM_log("Performing initial scan...");
// Scan for non-spoiled images/embeds
document.querySelectorAll(`${IMAGE_WRAPPER_SELECTOR}:not([${IMAGE_PROCESSED_MARKER}])`).forEach(wrapper => {
if (!wrapper.closest(SPOILER_CONTENT_SELECTOR)) {
addDownloadButton(wrapper);
} else {
wrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true'); // Mark spoiled ones
}
});
// Scan for spoilers needing listeners
document.querySelectorAll(`${SPOILER_CONTENT_SELECTOR}:not([${SPOILER_LISTENER_MARKER}]):has(${IMAGE_WRAPPER_SELECTOR})`).forEach(handleSpoiler);
}
setTimeout(initialize, 2000);
})();