Enhanced Danbooru Tag Exporter

Export multiple tags from Danbooru's page to .txt files with flexible naming options, ordered export(descending/ascending)

// ==UserScript==
// @name         Enhanced Danbooru Tag Exporter
// @namespace    http://tampermonkey.net/
// @version      2.0.1
// @description  Export multiple tags from Danbooru's page to .txt files with flexible naming options, ordered export(descending/ascending)
// @author       iMrdx
// @match        https://danbooru.donmai.us/*
// @grant        GM_download
// @grant        GM.addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @license      Apache License
// @icon         https://cdn.donmai.us/sample/d2/4d/__hatsune_miku_vocaloid_drawn_by_icon_315__sample-d24d819e95764ab46d5cb147294bb941.jpg
// ==/UserScript==

(function () {
    'use strict';

    const DELAY = 2000;
    const THEME_KEY = 'danbooru-exporter-theme';

    // Core functionality
    const escapeBracketsAndFormatTags = (tag) =>
        tag.replace(/\(/g, "\\(").replace(/\)/g, "\\)").replace(/_/g, " ");

    const sortAndFormatTags = (post) => {
        const tags = {
            artist: post.tag_string_artist ? post.tag_string_artist.split(" ") : [],
            copyright: post.tag_string_copyright ? post.tag_string_copyright.split(" ") : [],
            character: post.tag_string_character ? post.tag_string_character.split(" ") : [],
            general: post.tag_string_general ? post.tag_string_general.split(" ") : []
        };

        const formattedSections = Object.entries(tags)
            .filter(([, tagArray]) => tagArray.length > 0)
            .map(([category, tagArray]) => ({
                category,
                formatted: tagArray.map(escapeBracketsAndFormatTags).join(", ")
            }))
            .map(section => section.formatted);

        return formattedSections.join(", ");
    };

    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    // Enhanced fetch function with progress tracking
    async function fetchTagsDescending(startPage, endPage, namingOption, isDescending = true, progressCallback) {
        let currentPage = startPage;
        let postCounter = 1;
        const totalPages = Math.abs(endPage - startPage) + 1;
        let processedPages = 0;

        while (isDescending ? currentPage >= endPage : currentPage <= endPage) {
            console.log(`Fetching page ${currentPage}...`);
            processedPages++;
            progressCallback((processedPages / totalPages) * 100);

            const urlParams = new URLSearchParams(window.location.search);
            urlParams.set("page", currentPage);
            const url = `${window.location.origin}/posts.json?${urlParams.toString()}&limit=20&order=id_${isDescending ? "desc" : "asc"}`;

            try {
                const response = await fetch(url);
                if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
                const posts = await response.json();

                if (posts.length === 0) {
                    console.log("No more posts found.");
                    break;
                }
for (const post of posts) {
                    const postId = post.id;
                    const formattedTags = sortAndFormatTags(post);

                    let fileName;
                    switch (namingOption) {
                        case "Post ID":
                            fileName = `${postId}.txt`;
                            break;
                        case "Post ID with Tags":
                            fileName = `${postId}_${formattedTags.replace(/, /g, "_").replace(/ /g, "_")}.txt`;
                            break;
                        case "Number from Latest":
                            fileName = `(${postCounter}).txt`;
                            break;
                        case "Number from Oldest":
                            const totalPosts = Math.abs(startPage - endPage + 1) * 20;
                            fileName = `(${totalPosts - postCounter + 1}).txt`;
                            break;
                        default:
                            fileName = `post_${postId}.txt`;
                    }

                    GM_download({
                        url: `data:text/plain;charset=utf-8,${encodeURIComponent(formattedTags)}`,
                        name: fileName,
                        saveAs: false
                    });

                    console.log(`Saved tags for post ${postId} to ${fileName}`);
                    postCounter++;
                }

                await sleep(DELAY);
                currentPage += isDescending ? -1 : 1;

            } catch (error) {
                console.error(`Error processing page ${currentPage}:`, error);
                showNotification('error', `Error processing page ${currentPage}: ${error.message}`);
                break;
            }
        }

        showNotification('success', 'Tag export complete!');
        progressCallback(100);
    }

    // Enhanced notification system with new styling
    function showNotification(type, message) {
        const notification = document.createElement('div');
        notification.className = `exporter-notification ${type}`;
        notification.innerHTML = `
            <i class="fa ${type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle'}"></i>
            <span>${message}</span>
        `;
        document.body.appendChild(notification);

        requestAnimationFrame(() => {
            notification.style.transform = 'translateX(0)';
            notification.style.opacity = '1';
        });

        setTimeout(() => {
            notification.style.transform = 'translateX(100%)';
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 300);
        }, 3000);
    }

    // Enhanced theme management with transition effects
    function toggleTheme() {
        const container = document.getElementById('tag-exporter-ui');
        const isDark = container.classList.toggle('dark-theme');
        GM_setValue(THEME_KEY, isDark);
        return isDark;
    }

    // Enhanced UI creation with new layout
    const createUI = () => {
        const container = document.createElement('div');
        container.id = "tag-exporter-ui";
        container.className = GM_getValue(THEME_KEY, false) ? 'dark-theme' : '';

        container.innerHTML = `
            <div id="exporter-header">
                <div class="header-left">
                    <h3><i class="fa fa-tags"></i> Tag Exporter</h3>
                    <span class="version-badge">v2.0.1</span>
                </div>
                <div class="header-right">
                    <button id="theme-toggle" class="icon-button" title="Toggle Theme">
                        <i class="fa fa-moon"></i>
                    </button>
                    <button id="minimize-button" class="icon-button" title="Minimize">
                        <i class="fa fa-minus"></i>
                    </button>
                </div>
            </div>
            <div id="exporter-body">
                <div class="input-group">
                    <label for="start-page">Start Page</label>
                    <input type="number" id="start-page" min="1" placeholder="Enter start page">
                </div>
                <div class="input-group">
                    <label for="end-page">End Page</label>
                    <input type="number" id="end-page" min="1" placeholder="Enter end page">
                </div>
                <div class="input-group">
                    <label for="naming-option">File Naming</label>
                    <select id="naming-option">
                        <option value="Post ID">Post ID</option>
                        <option value="Post ID with Tags">Post ID with Tags</option>
                        <option value="Number from Latest">Number from Latest</option>
                        <option value="Number from Oldest">Number from Oldest</option>
                    </select>
                </div>
                <div class="input-group">
                    <label for="order-option">Order</label>
                    <select id="order-option">
                        <option value="Descending">Descending</option>
                        <option value="Ascending">Ascending</option>
                    </select>
                </div>
                <div class="progress-container hidden">
                    <div class="progress-bar">
                        <div class="progress-fill"></div>
                    </div>
                    <span class="progress-text">0%</span>
                </div>
                <button id="export-button" class="pulse">
                    <i class="fa fa-download"></i>
                    <span>Export Tags</span>
                </button>
            </div>
        `;

        document.body.appendChild(container);
        initializeEventListeners();
    };
// Event listeners initialization
    function initializeEventListeners() {
        const container = document.getElementById('tag-exporter-ui');
        const minimizeButton = document.querySelector('#minimize-button');
        const exporterBody = document.querySelector('#exporter-body');
        const exportButton = document.querySelector('#export-button');
        const themeToggle = document.querySelector('#theme-toggle');
        const header = document.querySelector('#exporter-header');

        minimizeButton.addEventListener('click', () => {
            const isMinimized = exporterBody.classList.toggle('hidden');
            minimizeButton.innerHTML = isMinimized ?
                '<i class="fa fa-plus"></i>' :
                '<i class="fa fa-minus"></i>';
            container.classList.toggle('minimized');
        });

        themeToggle.addEventListener('click', () => {
            const isDark = toggleTheme();
            themeToggle.innerHTML = isDark ?
                '<i class="fa fa-sun"></i>' :
                '<i class="fa fa-moon"></i>';
        });

        // Enhanced drag functionality
        let isDragging = false;
        let currentX;
        let currentY;
        let initialX;
        let initialY;
        let xOffset = 0;
        let yOffset = 0;

        header.addEventListener('mousedown', dragStart);
        document.addEventListener('mousemove', drag);
        document.addEventListener('mouseup', dragEnd);

        function dragStart(e) {
            if (e.target.closest('#exporter-header')) {
                initialX = e.clientX - xOffset;
                initialY = e.clientY - yOffset;
                isDragging = true;
                container.style.transition = 'none';
            }
        }

        function drag(e) {
            if (isDragging) {
                e.preventDefault();
                currentX = e.clientX - initialX;
                currentY = e.clientY - initialY;
                xOffset = currentX;
                yOffset = currentY;
                setTranslate(currentX, currentY, container);
            }
        }

        function dragEnd() {
            initialX = currentX;
            initialY = currentY;
            isDragging = false;
            container.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
        }

        function setTranslate(xPos, yPos, el) {
            el.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`;
        }

        // Export functionality with enhanced feedback
        exportButton.addEventListener('click', async () => {
            const startPage = parseInt(document.querySelector('#start-page').value, 10);
            const endPage = parseInt(document.querySelector('#end-page').value, 10);
            const namingOption = document.querySelector('#naming-option').value;
            const isDescending = document.querySelector('#order-option').value === 'Descending';

            if (isNaN(startPage) || isNaN(endPage) || startPage < 1 || endPage < 1) {
                showNotification('error', 'Please enter valid page numbers.');
                return;
            }

            const progressContainer = document.querySelector('.progress-container');
            const progressBar = document.querySelector('.progress-fill');
            const progressText = document.querySelector('.progress-text');

            progressContainer.classList.remove('hidden');
            exportButton.disabled = true;
            exportButton.classList.remove('pulse');

            try {
                await fetchTagsDescending(startPage, endPage, namingOption, isDescending, (percent) => {
                    progressBar.style.width = `${percent}%`;
                    progressText.textContent = `${Math.round(percent)}%`;
                });
            } catch (error) {
                showNotification('error', 'Export failed: ' + error.message);
            } finally {
                exportButton.disabled = false;
                exportButton.classList.add('pulse');
                setTimeout(() => {
                    progressContainer.classList.add('hidden');
                    progressBar.style.width = '0%';
                    progressText.textContent = '0%';
                }, 2000);
            }
        });
    }

    // Enhanced styles with glassmorphism and animations
    GM.addStyle(`
        @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap');
        @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css');

        #tag-exporter-ui {
            position: fixed;
            top: 20px;
            right: 20px;
            width: 340px;
            background: rgba(255, 255, 255, 0.85);
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            border-radius: 16px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
            font-family: 'Poppins', sans-serif;
            z-index: 10000;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            border: 1px solid rgba(255, 255, 255, 0.18);
        }

        #tag-exporter-ui.dark-theme {
            background: rgba(45, 45, 45, 0.85);
            color: #ffffff;
            border-color: rgba(255, 255, 255, 0.08);
        }

        #tag-exporter-ui.minimized {
            width: 220px;
        }

        #exporter-header {
            background: linear-gradient(135deg, #4CAF50, #45a049);
            padding: 16px 20px;
            border-radius: 16px 16px 0 0;
            cursor: move;
            display: flex;
            justify-content: space-between;
            align-items: center;
            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
        }

        .dark-theme #exporter-header {
            background: linear-gradient(135deg, #388E3C, #2E7D32);
        }

        .header-left {
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .header-left h3 {
            color: white;
            font-size: 16px;
            font-weight: 600;
            margin: 0;
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .version-badge {
            background: rgba(255, 255, 255, 0.2);
            padding: 2px 6px;
            border-radius: 4px;
            font-size: 10px;
            color: white;
        }

        #exporter-body {
            padding: 20px;
            display: flex;
            flex-direction: column;
            gap: 16px;
        }

        .input-group {
            display: flex;
            flex-direction: column;
            gap: 6px;
        }

        .input-group label {
            color: #555;
            font-size: 13px;
            font-weight: 500;
            margin-left: 2px;
        }

        .dark-theme .input-group label {
            color: #ddd;
        }

        input, select {
            padding: 10px 14px;
            border: 1px solid rgba(0, 0, 0, 0.1);
            border-radius: 8px;
            font-size: 14px;
            font-family: 'Poppins', sans-serif;
            background: rgba(255, 255, 255, 0.9);
            color: #333;
            transition: all 0.2s;
        }

        .dark-theme input,
        .dark-theme select {
            background: rgba(60, 60, 60, 0.9);
            border-color: rgba(255, 255, 255, 0.1);
            color: #fff;
        }

        input:focus, select:focus {
            border-color: #4CAF50;
            box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.2);
            outline: none;
        }

        .icon-button {
            background: rgba(255, 255, 255, 0.2);
            border: none;
            width: 32px;
            height: 32px;
            border-radius: 8px;
            color: white;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.2s;
        }

        .icon-button:hover {
            background: rgba(255, 255, 255, 0.3);
            transform: translateY(-1px);
        }

        #export-button {
            background: linear-gradient(135deg, #4CAF50, #45a049);
            color: white;
            border: none;
            padding: 12px;
            border-radius: 8px;
            cursor: pointer;
            font-weight: 500;
            font-family: 'Poppins', sans-serif;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            transition: all 0.2s;
        }

        #export-button:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(76, 175, 80, 0.2);
        }

        #export-button:disabled {
            background: linear-gradient(135deg, #ccc, #bbb);
            cursor: not-allowed;
            transform: none;
            box-shadow: none;
        }

        .dark-theme #export-button:disabled {
            background: linear-gradient(135deg, #555, #444);
        }

        .progress-container {
            background: rgba(245, 245, 245, 0.9);
            border-radius: 8px;
            padding: 2px;
            position: relative;
            height: 24px;
            transition: all 0.3s;
        }

        .dark-theme .progress-container {
            background: rgba(60, 60, 60, 0.9);
        }

        .progress-bar {
            width: 100%;
            height: 100%;
            background: rgba(240, 240, 240, 0.9);
            border-radius: 6px;
            overflow: hidden;
        }

        .progress-fill {
            height: 100%;
            background: linear-gradient(135deg, #4CAF50, #45a049);
            transition: width 0.3s ease;
            border-radius: 6px;
        }

        .progress-text {
            position: absolute;
            right: 10px;
            top: 50%;
            transform: translateY(-50%);
            font-size: 12px;
            font-weight: 500;
            color: #666;
        }

        .dark-theme .progress-text {
            color: #fff;
        }

        .exporter-notification {
            position: fixed;
            bottom: 20px;
            right: 20px;
            padding: 14px 20px;
            border-radius: 12px;
            color: white;
            font-size: 14px;
            z-index: 10001;
            transform: translateX(100%);
            opacity: 0;
            transition: all 0.3s ease;
            display: flex;
            align-items: center;
            gap: 10px;
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
            font-family: 'Poppins', sans-serif;
        }

        .exporter-notification.success {
            background: rgba(76, 175, 80, 0.95);
        }

        .exporter-notification.error {
            background: rgba(244, 67, 54, 0.95);
        }

        @keyframes pulse {
            0% {
                box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
            }
            70% {
                box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
            }
            100% {
                box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
            }
        }

        .pulse {
            animation: pulse 2s infinite;
        }

        .hidden {
            display: none !important;
        }
    `);

    // Initialize the UI
    createUI();
})();