AnnaUploader (Roblox Multi-File Uploader)

allows you to Upload multiple T-Shirts/Decals easily with AnnaUploader

As of 04.05.2025. See ბოლო ვერსია.

// ==UserScript==
// @name         AnnaUploader (Roblox Multi-File Uploader)
// @namespace    https://www.guilded.gg/u/AnnaBlox
// @version      4.4
// @description  allows you to Upload multiple T-Shirts/Decals easily with AnnaUploader
// @match        https://create.roblox.com/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const ROBLOX_UPLOAD_URL  = "https://apis.roblox.com/assets/user-auth/v1/assets";
    const ASSET_TYPE_TSHIRT  = 11;
    const ASSET_TYPE_DECAL   = 13;
    const UPLOAD_RETRY_DELAY = 0;
    const MAX_RETRIES        = 50;
    const FORCED_NAME_ON_MOD = "Uploaded Using AnnaUploader";

    let USER_ID        = GM_getValue('userId', null);
    let uploadQueue    = [];
    let isUploading    = false;
    let csrfToken      = null;
    let batchTotal     = 0;
    let completedCount = 0;
    let statusElement  = null;

    async function fetchCSRFToken() {
        try {
            const response = await fetch(ROBLOX_UPLOAD_URL, {
                method: 'POST',
                credentials: 'include',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({})
            });
            if (response.status === 403) {
                const token = response.headers.get('x-csrf-token');
                if (token) {
                    console.log('[CSRF] Token fetched:', token);
                    csrfToken = token;
                    return token;
                }
            }
            throw new Error('Failed to fetch CSRF token');
        } catch (error) {
            console.error('[CSRF] Fetch error:', error);
            throw error;
        }
    }

    async function uploadFile(file, assetType, retries = 0, forceName = false) {
        if (!csrfToken) {
            await fetchCSRFToken();
        }

        const displayName = forceName
            ? FORCED_NAME_ON_MOD
            : file.name.split('.')[0];

        const formData = new FormData();
        formData.append("fileContent", file, file.name);
        formData.append("request", JSON.stringify({
            displayName: displayName,
            description: "Uploaded Using AnnaUploader",
            assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
            creationContext: {
                creator: { userId: USER_ID },
                expectedPrice: 0
            }
        }));

        try {
            const response = await fetch(ROBLOX_UPLOAD_URL, {
                method: "POST",
                credentials: "include",
                headers: { "x-csrf-token": csrfToken },
                body: formData
            });

            if (response.ok) {
                console.log(`✅ Uploaded (${assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal"}): ${file.name}`);
                return;
            }

            const status = response.status;
            const text = await response.text();
            let json;
            try { json = JSON.parse(text); } catch {}

            const isModeratedName = status === 400 && json?.code === "INVALID_ARGUMENT" && json?.message?.includes("fully moderated");
            const isInvalidNameLength = status === 400 && json?.code === "INVALID_ARGUMENT" && json?.message?.includes("name length is invalid");

            if ((isModeratedName || isInvalidNameLength) && retries < MAX_RETRIES && !forceName) {
                console.warn(`⚠️ Invalid name for ${file.name}: retrying with forced name...`);
                await new Promise(res => setTimeout(res, UPLOAD_RETRY_DELAY));
                return await uploadFile(file, assetType, retries + 1, true);
            }

            if (status === 403 && retries < MAX_RETRIES) {
                console.warn(`🔄 CSRF expired for ${file.name}: fetching new token and retrying...`);
                csrfToken = null;
                await new Promise(res => setTimeout(res, UPLOAD_RETRY_DELAY));
                return await uploadFile(file, assetType, retries + 1, forceName);
            }

            console.error(`❌ Upload failed for ${file.name}: [${status}]`, text);
            throw new Error(`Failed to upload ${file.name} after ${retries} retries.`);
        } catch (error) {
            console.error(`Upload error for ${file.name}:`, error);
            throw error;
        }
    }

    async function processUploadQueue() {
        if (isUploading || uploadQueue.length === 0) return;
        isUploading = true;
        const { file, assetType } = uploadQueue.shift();
        try {
            await uploadFile(file, assetType);
            completedCount++;
            updateStatus();
        } catch (e) {}
        finally {
            isUploading = false;
            processUploadQueue();
        }
    }

    function updateStatus() {
        if (!statusElement) return;
        if (batchTotal > 0) {
            statusElement.textContent = `${completedCount} of ${batchTotal} files uploaded successfully`;
        } else {
            statusElement.textContent = '';
        }
    }

    function handleFileSelect(files, assetType, uploadBoth = false) {
        if (!files || files.length === 0) {
            console.warn('No files selected.');
            return;
        }
        batchTotal = uploadBoth ? files.length * 2 : files.length;
        completedCount = 0;
        updateStatus();

        for (let file of files) {
            if (uploadBoth) {
                uploadQueue.push({ file, assetType: ASSET_TYPE_TSHIRT });
                uploadQueue.push({ file, assetType: ASSET_TYPE_DECAL });
                console.log(`Queued (Both): ${file.name}`);
            } else {
                uploadQueue.push({ file, assetType });
                console.log(`Queued (${assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal"}): ${file.name}`);
            }
        }
        processUploadQueue();
    }

    function createUploaderUI() {
        const container = document.createElement('div');
        Object.assign(container.style, {
            position: 'fixed',
            top: '10px',
            right: '10px',
            backgroundColor: '#fff',
            border: '2px solid #000',
            padding: '15px',
            zIndex: '10000',
            borderRadius: '8px',
            boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
            display: 'flex',
            flexDirection: 'column',
            gap: '10px',
            fontFamily: 'Arial, sans-serif',
            width: '200px'
        });

        const closeBtn = document.createElement('button');
        closeBtn.textContent = '×';
        Object.assign(closeBtn.style, {
            position: 'absolute',
            top: '5px',
            right: '8px',
            background: 'transparent',
            border: 'none',
            fontSize: '16px',
            cursor: 'pointer',
            lineHeight: '1'
        });
        closeBtn.title = 'Close uploader';
        closeBtn.addEventListener('click', () => container.remove());
        container.appendChild(closeBtn);

        const title = document.createElement('h3');
        title.textContent = 'AnnaUploader';
        title.style.margin = '0 0 5px 0';
        title.style.fontSize = '16px';
        container.appendChild(title);

        const makeBtn = (text, onClick) => {
            const btn = document.createElement('button');
            btn.textContent = text;
            Object.assign(btn.style, { padding: '8px', cursor: 'pointer' });
            btn.addEventListener('click', onClick);
            return btn;
        };

        container.appendChild(makeBtn('Upload T-Shirts', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
            input.addEventListener('change', e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT));
            input.click();
        }));
        container.appendChild(makeBtn('Upload Decals', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
            input.addEventListener('change', e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL));
            input.click();
        }));
        container.appendChild(makeBtn('Upload Both', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
            input.addEventListener('change', e => handleFileSelect(e.target.files, null, true));
            input.click();
        }));
        container.appendChild(makeBtn('Change ID', () => {
            const input = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
            if (!input) return;
            const urlMatch = input.match(/roblox\.com\/users\/(\d+)\/profile/i);
            let newId = urlMatch ? urlMatch[1] : (!isNaN(input.trim()) ? input.trim() : null);
            if (newId) {
                USER_ID = Number(newId);
                GM_setValue('userId', USER_ID);
                alert(`User ID updated to ${USER_ID}`);
            } else {
                alert("Invalid input. Please enter a numeric ID or a valid profile URL.");
            }
        }));

        const pasteHint = document.createElement('div');
        pasteHint.textContent = 'Paste images (Ctrl+V) to upload—name & type it first!';
        pasteHint.style.fontSize = '12px';
        pasteHint.style.color = '#555';
        container.appendChild(pasteHint);

        statusElement = document.createElement('div');
        statusElement.style.fontSize = '12px';
        statusElement.style.color = '#000';
        statusElement.textContent = '';
        container.appendChild(statusElement);

        document.body.appendChild(container);
    }

    function handlePaste(event) {
        const items = event.clipboardData?.items;
        if (!items) return;
        for (let item of items) {
            if (item.type.indexOf('image') === 0) {
                event.preventDefault();
                const blob = item.getAsFile();
                const now = new Date();
                const defaultBase = `pasted_image_${now.toISOString().replace(/[^a-z0-9]/gi, '_')}`;
                
                // 1) Name prompt
                let nameInput = prompt("Enter a name for the pasted image (no extension):", defaultBase);
                if (nameInput === null) return; // cancelled
                nameInput = nameInput.trim() || defaultBase;
                const filename = nameInput.endsWith('.png') ? nameInput : `${nameInput}.png`;

                // 2) Type prompt
                let typeInput = prompt(
                    "Upload as:\n  T = T-Shirt\n  D = Decal\n  C = Cancel",
                    "D"
                );
                if (!typeInput) return;
                typeInput = typeInput.trim().toUpperCase();
                let chosenType = null;
                if (typeInput === 'T') chosenType = ASSET_TYPE_TSHIRT;
                else if (typeInput === 'D') chosenType = ASSET_TYPE_DECAL;
                else return; // Cancel or invalid

                const file = new File([blob], filename, { type: blob.type });
                handleFileSelect([file], chosenType);
                break;
            }
        }
    }

    function init() {
        createUploaderUI();
        document.addEventListener('paste', handlePaste);
        console.log('[Uploader] Initialized with User ID:', USER_ID);
    }

    window.addEventListener('load', init);
})();