SankakuDLNamer

Help with DL naming

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        SankakuDLNamer
// @namespace   SankakuDLNamer
// @description Help with DL naming
// @author      SlimeySlither, sanchan, Dramorian
// @match       http*://chan.sankakucomplex.com/*posts/*
// @match       https://chan.sankakucomplex.com/en/?tags=*
// @match       http*://idol.sankakucomplex.com/*posts/*
// @match       http*://beta.sankakucomplex.com/*posts/*
// @run-at      document-end
// @version     1.4.4
// @grant       GM_download
// ==/UserScript==

(function() {
	'use strict';

	// Configuration
	const CONFIG = {
		usePostId: false,           // Replace hash with post ID if true
		prefixPostId: false,        // Put post ID in front if true
		maxTagEntries: 4,           // Max tags per category before truncation
		showCopyButton: false,      // Show "Copy Filename" button
		debug: false,               // Enable debug logging
		tagDelimiter: ', ',         // Delimiter between tags in filename
	};

	const SELECTORS = {
		tagSidebar: '#tag-sidebar',
		imageLink: '#highres',
	};

	const TAG_TYPES = {
		character: 'tag-type-character',
		copyright: 'tag-type-copyright',
		artist: 'tag-type-artist',
		general: 'tag-type-general',
	};

	// ==================== Main Entry Point ====================

	/**
	 * Main initialization function
	 */
	function init() {
		try {
			const postId = extractPostId();
			const tags = extractSidebarTags();
			const imageData = extractImageData();
			const filename = generateFilename(tags, imageData, postId);

			log('Post ID:', postId);
			log('Tags:', tags);
			log('Image Data:', imageData);
			log('Generated Filename:', filename);

			renderUI(imageData, filename);
		} catch (error) {
			console.error('[SankakuDLNamer] Initialization failed:', error);
		}
	}

	// ==================== Data Extraction ====================

	/**
	 * Extract post ID from URL pathname
	 * @returns {string} Post ID
	 */
	function extractPostId() {
		const pathname = window.location.pathname.replace(/\/$/, '');
		return pathname.substring(pathname.lastIndexOf('/') + 1);
	}

	/**
	 * Extract and categorize tags from sidebar
	 * @returns {Object<string, string[]>} Tags grouped by category
	 */
	function extractSidebarTags() {
		const sidebar = document.querySelector(SELECTORS.tagSidebar);
		if (!sidebar) {
			throw new Error('Tag sidebar not found');
		}

		const categorizedTags = {};

		for (const listItem of sidebar.querySelectorAll('ul > li')) {
			const tag = extractTagFromListItem(listItem);
			if (!tag) continue;

			const category = cleanText(listItem.className);
			categorizedTags[category] = categorizedTags[category] || [];
			categorizedTags[category].push(tag);
		}

		return categorizedTags;
	}

	/**
	 * Extract tag text from a list item
	 * @param {HTMLElement} listItem
	 * @returns {string|null} Tag text or null
	 */
	function extractTagFromListItem(listItem) {
		const links = listItem.querySelectorAll('a[id]');
		return links.length > 0 ? cleanText(links[0].innerText) : null;
	}

	/**
	 * Extract image URL, hash, and extension
	 * @returns {Object} Image data with url, hash, and extension
	 */
	function extractImageData() {
		const imageLink = document.querySelector(SELECTORS.imageLink);
		if (!imageLink) {
			throw new Error('High-res image link not found');
		}

		const url = new URL(imageLink.getAttribute('href'), document.baseURI);
		const filename = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
		const dotIndex = filename.lastIndexOf('.');
		
		return {
			url,
			hash: filename.substring(0, dotIndex),
			extension: filename.substring(dotIndex),
		};
	}

	// ==================== Filename Generation ====================

	/**
	 * Generate filename from tags and image data
	 * @param {Object} tags - Categorized tags
	 * @param {Object} imageData - Image hash and extension
	 * @param {string} postId - Post ID
	 * @returns {string} Generated filename
	 */
	function generateFilename(tags, imageData, postId) {
		const characters = processCharacterTags(tags[TAG_TYPES.character]);
		const copyrights = tags[TAG_TYPES.copyright];
		const artists = tags[TAG_TYPES.artist];

		truncateTagLists(characters, copyrights, artists);

		const tokens = buildFilenameTokens(characters, copyrights, artists, imageData, postId);
		
		return tokens.join(' ') + imageData.extension;
	}

	/**
	 * Process character tags by removing parenthetical text and deduplicating
	 * @param {string[]} characters - Raw character tags
	 * @returns {string[]} Processed character tags
	 */
	function processCharacterTags(characters) {
		if (!characters || characters.length === 0) return null;

		const processed = characters.map(tag => {
			const parenIndex = tag.indexOf('(');
			if (parenIndex <= 0) return tag;

			// Remove trailing space/underscore before parenthesis
			const trimIndex = [' ', '_'].includes(tag[parenIndex - 1]) 
				? parenIndex - 1 
				: parenIndex;
			return tag.substring(0, trimIndex);
		});

		return [...new Set(processed)]; // Deduplicate
	}

	/**
	 * Sort and truncate tag lists
	 * @param {...string[]|null} tagLists - Variable number of tag arrays
	 */
	function truncateTagLists(...tagLists) {
		for (const tags of tagLists) {
			if (!tags) continue;

			tags.sort();
			if (tags.length > CONFIG.maxTagEntries) {
				tags.splice(CONFIG.maxTagEntries);
				tags.push('...');
			}
		}
	}

	/**
	 * Build filename token array
	 * @param {string[]|null} characters
	 * @param {string[]|null} copyrights
	 * @param {string[]|null} artists
	 * @param {Object} imageData
	 * @param {string} postId
	 * @returns {string[]} Filename tokens
	 */
	function buildFilenameTokens(characters, copyrights, artists, imageData, postId) {
		const tokens = [];

		// Prefix with post ID if configured
		if (CONFIG.usePostId && CONFIG.prefixPostId) {
			tokens.push(postId, '-');
		}

		// Add character tags
		if (characters) {
			tokens.push(characters.join(CONFIG.tagDelimiter));
		}

		// Add copyright tags in parentheses
		if (copyrights) {
			tokens.push(`(${copyrights.join(CONFIG.tagDelimiter)})`);
		}

		// Add artist tags with "drawn by" prefix
		if (artists) {
			tokens.push('drawn by', artists.join(CONFIG.tagDelimiter));
		}

		// Add hash or post ID as identifier
		if (!CONFIG.usePostId) {
			tokens.push(imageData.hash);
		} else if (!CONFIG.prefixPostId) {
			tokens.push(postId);
		}

		// Clean up trailing dash
		if (tokens[tokens.length - 1] === '-') {
			tokens.pop();
		}

		return tokens;
	}

	// ==================== UI Rendering ====================

	/**
	 * Render download and copy buttons
	 * @param {Object} imageData - Image data
	 * @param {string} filename - Generated filename
	 */
	function renderUI(imageData, filename) {
		const downloadButton = createDownloadButton(imageData.url.href, filename);
		insertButtonUnderDetails(downloadButton);

		if (CONFIG.showCopyButton) {
			const copyButton = createCopyFilenameButton(filename);
			insertButtonUnderDetails(copyButton);
		}
	}

	/**
	 * Create download button element
	 * @param {string} url - Image URL
	 * @param {string} filename - Download filename
	 * @returns {HTMLElement} Download button
	 */
	function createDownloadButton(url, filename) {
		const button = document.createElement('a');
		button.href = '#';
		button.innerText = 'Download';
		button.onclick = (e) => {
			e.preventDefault();
			initiateDownload(url, filename);
		};
		return button;
	}

	/**
	 * Create copy filename button element
	 * @param {string} filename - Filename to copy
	 * @returns {HTMLElement} Copy button
	 */
	function createCopyFilenameButton(filename) {
		const button = document.createElement('a');
		button.href = '#';
		button.innerText = 'Copy Filename';
		button.onclick = (e) => {
			e.preventDefault();
			navigator.clipboard.writeText(filename)
				.then(() => console.log('[SankakuDLNamer] Filename copied'))
				.catch(err => console.error('[SankakuDLNamer] Copy failed:', err));
		};
		return button;
	}

	/**
	 * Insert button as list item under image details
	 * @param {HTMLElement} button - Button element to insert
	 */
	function insertButtonUnderDetails(button) {
		const imageLink = document.querySelector(SELECTORS.imageLink);
		if (!imageLink) {
			throw new Error('Image link not found for button insertion');
		}

		const listItem = document.createElement('li');
		listItem.appendChild(button);
		imageLink.parentNode.insertAdjacentElement('afterend', listItem);
	}

	// ==================== Download Logic ====================

	/**
	 * Initiate file download using GM_download
	 * @param {string} url - Download URL
	 * @param {string} filename - Save filename
	 */
	function initiateDownload(url, filename) {
		console.log('[SankakuDLNamer] Starting download:', filename);

		GM_download({
			url,
			name: filename,
			saveAs: true,
			onload: () => console.log('[SankakuDLNamer] Download complete'),
			ontimeout: () => console.error('[SankakuDLNamer] Download timeout'),
			onerror: (error, details) => {
				console.error('[SankakuDLNamer] Download failed:', error, details);
				alert(`Download failed: ${error}`);
			},
			onprogress: () => log('Download progress...'),
		});
	}

	// ==================== Utilities ====================

	/**
	 * Clean text by removing illegal filename characters
	 * @param {string} text - Input text
	 * @returns {string} Cleaned text
	 */
	function cleanText(text) {
		return text.replaceAll(/[/\\?%*:|"<>]/g, '-');
	}

	/**
	 * Debug logging helper
	 * @param {...any} args - Arguments to log
	 */
	function log(...args) {
		if (CONFIG.debug) {
			console.debug('[SankakuDLNamer]', ...args);
		}
	}

	// ==================== Initialization ====================

	if (document.readyState === 'complete' || 
	    document.readyState === 'loaded' || 
	    document.readyState === 'interactive') {
		init();
	} else {
		document.addEventListener('DOMContentLoaded', init, false);
	}

})();