AnnaUploader (Roblox Multi-File Uploader)

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

// ==UserScript==
// @name         AnnaUploader (Roblox Multi-File Uploader)
// @namespace    https://www.guilded.gg/u/AnnaBlox
// @version      5.6
// @description  allows you to upload multiple T-Shirts/Decals easily with AnnaUploader
// @match        https://create.roblox.com/*
// @match        https://www.roblox.com/users/*/profile*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.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 FORCED_NAME        = "Uploaded Using AnnaUploader";

    const STORAGE_KEY = 'annaUploaderAssetLog';
    const SCAN_INTERVAL_MS = 10_000;

    let USER_ID = GM_getValue('userId', null);
    let useForcedName = false;
    let useMakeUnique = false;
    let uniqueCopies = 1;
    let useDownload = false;

    let massMode = false;
    let massQueue = [];
    let batchTotal = 0;
    let completed = 0;

    let csrfToken = null;
    let statusEl, toggleBtn, startBtn, copiesInput, downloadBtn;

    // Utility: extract base name without extension
    function baseName(filename) {
        return filename.replace(/\.[^/.]+$/, '');
    }

    function loadLog() {
        const raw = GM_getValue(STORAGE_KEY, '{}');
        try { return JSON.parse(raw); }
        catch { return {}; }
    }

    function saveLog(log) {
        GM_setValue(STORAGE_KEY, JSON.stringify(log));
    }

    function logAsset(id, imageURL, name) {
        const log = loadLog();
        log[id] = {
            date: new Date().toISOString(),
            image: imageURL || log[id]?.image || null,
            name: name || log[id]?.name || '(unknown)'
        };
        saveLog(log);
        console.log(`[AssetLogger] logged asset ${id} at ${log[id].date}, name: ${log[id].name}, image: ${log[id].image || "none"}`);
    }

    function scanForAssets() {
        console.log('[AssetLogger] scanning for assets…');
        document.querySelectorAll('[href]').forEach(el => {
            let m = el.href.match(/(?:https?:\/\/create\.roblox\.com)?\/store\/asset\/(\d+)/)
                 || el.href.match(/\/dashboard\/creations\/store\/(\d+)\/configure/);
            if (m) {
                const id = m[1];
                let image = null;
                const container = el.closest('*');
                const img = container?.querySelector('img');
                if (img?.src) image = img.src;
                let name = null;
                const nameEl = container?.querySelector('span.MuiTypography-root');
                if (nameEl) name = nameEl.textContent.trim();
                logAsset(id, image, name);
            }
        });
    }
    setInterval(scanForAssets, SCAN_INTERVAL_MS);

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

    function updateStatus() {
        if (!statusEl) return;
        if (batchTotal > 0) {
            statusEl.textContent = `${completed} of ${batchTotal} processed`;
        } else {
            statusEl.textContent = massMode ? `${massQueue.length} queued` : '';
        }
    }

    async function uploadFile(file, assetType, retries = 0, forceName = false) {
        if (!csrfToken) await fetchCSRFToken();
        const displayName = forceName ? FORCED_NAME : baseName(file.name);
        const fd = new FormData();
        fd.append('fileContent', file, file.name);
        fd.append('request', JSON.stringify({
            displayName,
            description: FORCED_NAME,
            assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
            creationContext: { creator: { userId: USER_ID }, expectedPrice: 0 }
        }));
        try {
            const resp = await fetch(ROBLOX_UPLOAD_URL, {
                method: 'POST', credentials: 'include',
                headers: { 'x-csrf-token': csrfToken },
                body: fd
            });
            const txt = await resp.text();
            let json; try { json = JSON.parse(txt); } catch {}
            if (resp.ok && json.assetId) {
                logAsset(json.assetId, null, displayName);
                completed++;
                updateStatus();
                return;
            }
            // If name-too-long error, retry with default name
            if (json?.message === 'Asset name length is invalid.' && !forceName && retries < 5) {
                console.warn('[Upload] name too long, retrying with default name');
                return uploadFile(file, assetType, retries + 1, true);
            }
            // Moderation error: try forced name
            if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) {
                return uploadFile(file, assetType, retries + 1, true);
            }
            // CSRF expired: retry
            if (resp.status === 403 && retries < 5) {
                csrfToken = null;
                return uploadFile(file, assetType, retries + 1, forceName);
            }
            console.error(`[Upload] failed ${file.name} [${resp.status}]`, txt);
        } catch (e) {
            console.error('[Upload] error', e);
        } finally {
            if (completed < batchTotal) {
                completed++;
                updateStatus();
            }
        }
    }

    function makeUniqueFile(file, origBase, copyIndex) {
        return new Promise(resolve => {
            const img = new Image();
            img.onload = () => {
                const canvas = document.createElement('canvas');
                canvas.width = img.width;
                canvas.height = img.height;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0);
                const x = Math.floor(Math.random() * canvas.width);
                const y = Math.floor(Math.random() * canvas.height);
                ctx.fillStyle = `rgba(${Math.random()*255|0},${Math.random()*255|0},${Math.random()*255|0},1)`;
                ctx.fillRect(x, y, 1, 1);
                canvas.toBlob(blob => {
                    const ext = file.name.split('.').pop();
                    const newName = `${origBase}_${copyIndex}.${ext}`;
                    resolve(new File([blob], newName, { type: file.type }));
                }, file.type);
            };
            img.src = URL.createObjectURL(file);
        });
    }

    async function handleFileSelect(files, assetType, both = false) {
        if (!files?.length) return;

        const downloadsMap = {};
        const copies = useMakeUnique ? uniqueCopies : 1;
        batchTotal = files.length * (both ? 2 : 1) * copies;
        completed = 0;
        updateStatus();

        const tasks = [];

        for (const original of files) {
            const origBase = baseName(original.name);
            downloadsMap[origBase] = [];

            for (let i = 1; i <= copies; i++) {
                const filePromise = useMakeUnique
                    ? makeUniqueFile(original, origBase, i)
                    : Promise.resolve(original);

                const fileTask = filePromise.then(toUse => {
                    if (useMakeUnique && useDownload) downloadsMap[origBase].push(toUse);
                    if (both) {
                        tasks.push(uploadFile(toUse, ASSET_TYPE_TSHIRT, 0, useForcedName));
                        tasks.push(uploadFile(toUse, ASSET_TYPE_DECAL,   0, useForcedName));
                    } else {
                        tasks.push(uploadFile(toUse, assetType, 0, useForcedName));
                    }
                });

                await fileTask;
            }
        }

        Promise.all(tasks).then(() => {
            console.log('[Uploader] batch done');
            scanForAssets();
            if (useMakeUnique && useDownload) {
                for (const [origBase, fileList] of Object.entries(downloadsMap)) {
                    if (!fileList.length) continue;
                    const zip = new JSZip();
                    fileList.forEach(f => zip.file(f.name, f));
                    zip.generateAsync({ type: 'blob' }).then(blob => {
                        const url = URL.createObjectURL(blob);
                        const a = document.createElement('a');
                        a.href = url;
                        a.download = `${origBase}.zip`;
                        document.body.appendChild(a);
                        a.click();
                        document.body.removeChild(a);
                        URL.revokeObjectURL(url);
                    });
                }
            }
        });
    }

    function startMassUpload() {
        if (!massQueue.length) return alert('Nothing queued!');
        batchTotal = massQueue.length;
        completed = 0;
        updateStatus();

        const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, useForcedName));
        massQueue = [];

        Promise.all(tasks).then(() => {
            alert('Mass upload complete!');
            massMode = false;
            toggleBtn.textContent = 'Enable Mass Upload';
            startBtn.style.display = 'none';
            scanForAssets();
        });
    }

    function createUI() {
        const c = document.createElement('div');
        Object.assign(c.style, {
            position:'fixed', top:'10px', right:'10px', width:'260px',
            background:'#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:'8px', fontFamily:'Arial'
        });

        function btn(text, fn) {
            const b = document.createElement('button');
            b.textContent = text;
            Object.assign(b.style, { padding:'8px', cursor:'pointer' });
            b.onclick = fn;
            return b;
        }

        const close = btn('×', () => c.remove());
        Object.assign(close.style, {
            position:'absolute', top:'5px', right:'8px',
            background:'transparent', border:'none', fontSize:'16px'
        });
        close.title = 'Close';
        c.appendChild(close);

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

        c.appendChild(btn('Upload T-Shirts', () => {
            const i = document.createElement('input');
            i.type='file'; i.accept='image/*'; i.multiple=true;
            i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
            i.click();
        }));
        c.appendChild(btn('Upload Decals', () => {
            const i = document.createElement('input');
            i.type='file'; i.accept='image/*'; i.multiple=true;
            i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
            i.click();
        }));
        c.appendChild(btn('Upload Both', () => {
            const i = document.createElement('input');
            i.type='file'; i.accept='image/*'; i.multiple=true;
            i.onchange = e => handleFileSelect(e.target.files, null, true);
            i.click();
        }));

        toggleBtn = btn('Enable Mass Upload', () => {
            massMode = !massMode;
            toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
            startBtn.style.display = massMode ? 'block' : 'none';
            massQueue = []; batchTotal = completed = 0; updateStatus();
        });
        c.appendChild(toggleBtn);

        startBtn = btn('Start Mass Upload', startMassUpload);
        startBtn.style.display = 'none';
        c.appendChild(startBtn);

        const nameBtn = btn('Use default Name: Off', () => {
            useForcedName = !useForcedName;
            nameBtn.textContent = `Use default Name: ${useForcedName?'On':'Off'}`;
        });
        c.appendChild(nameBtn);

        const slipBtn = btn('Slip Mode: Off', () => {
            useMakeUnique = !useMakeUnique;
            slipBtn.textContent = `Slip Mode: ${useMakeUnique?'On':'Off'}`;
            copiesInput.style.display = useMakeUnique ? 'block' : 'none';
            downloadBtn.style.display = useMakeUnique ? 'block' : 'none';
            if (!useMakeUnique) {
                useDownload = false;
                downloadBtn.textContent = 'Download Images: Off';
            }
        });
        c.appendChild(slipBtn);

        copiesInput = document.createElement('input');
        copiesInput.type='number'; copiesInput.min='1'; copiesInput.value=uniqueCopies;
        copiesInput.style.width='100%'; copiesInput.style.boxSizing='border-box';
        copiesInput.style.display='none';
        copiesInput.onchange = e => {
            const v = parseInt(e.target.value,10);
            if (v>0) uniqueCopies = v;
            else e.target.value = uniqueCopies;
        };
        c.appendChild(copiesInput);

        downloadBtn = btn('Download Images: Off', () => {
            useDownload = !useDownload;
            downloadBtn.textContent = `Download Images: ${useDownload?'On':'Off'}`;
        });
        downloadBtn.style.display = 'none';
        c.appendChild(downloadBtn);

        c.appendChild(btn('Change ID', () => {
            const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID||'');
            if (!inp) return;
            const m = inp.match(/users\/(\d+)/);
            const id = m ? m[1] : inp.trim();
            if (!isNaN(id)) {
                USER_ID = Number(id);
                GM_setValue('userId', USER_ID);
                alert(`User ID set to ${USER_ID}`);
            } else alert('Invalid input.');
        }));

        const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
        if (pm) {
            c.appendChild(btn('Use This Profile as ID', () => {
                USER_ID = Number(pm[1]);
                GM_setValue('userId', USER_ID);
                alert(`User ID set to ${USER_ID}`);
            }));
        }

        c.appendChild(btn('Show Logged Assets', () => {
            const log = loadLog();
            const entries = Object.entries(log);
            const w = window.open('', '_blank');
            w.document.write(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Logged Assets</title>
<style>
body { font-family:Arial; padding:20px; background:#fff; color:#000; transition:background 0.3s, color 0.3s; }
h1 { margin-bottom:10px; }
ul { padding-left:20px; }
li { margin-bottom:10px; }
img { max-height:40px; border:1px solid #ccc; }
.asset-name { font-size:90%; color:#333; margin-left:20px; }
button { margin-bottom:10px; }
</style></head><body>
<button onclick="document.body.style.background=(document.body.style.background==='black'?'white':'black');document.body.style.color=(document.body.style.color==='white'?'black':'white');document.querySelectorAll('img').forEach(i=>i.style.border=(document.body.style.background==='black'?'1px solid #fff':'1px solid #ccc'));">Toggle Background</button>
<h1>Logged Assets</h1>
${ entries.length ? `<ul>${entries.map(([id,entry])=>`
  <li>
    <div style="display:flex;align-items:center;gap:10px;">
      ${ entry.image ? `<img src="${entry.image}" alt> ` : `<span>(no image)</span>` }
      <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> — ${entry.date}
    </div>
    <div class="asset-name">${entry.name}</div>
  </li>`).join('') }</ul>` : `<p><em>No assets logged yet.</em></p>`}
</body></html>`);
            w.document.close();
        }));

        const hint = document.createElement('div');
        hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
        hint.style.fontSize='12px'; hint.style.color='#555';
        c.appendChild(hint);

        statusEl = document.createElement('div');
        statusEl.style.fontSize='12px'; statusEl.style.color='#000';
        c.appendChild(statusEl);

        document.body.appendChild(c);
    }

    function handlePaste(e) {
        const items = e.clipboardData?.items;
        if (!items) return;
        for (const it of items) {
            if (it.type.startsWith('image')) {
                e.preventDefault();
                const blob = it.getAsFile();
                const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
                let name = prompt('Name (no ext):', `pasted_${ts}`);
                if (name===null) return;
                name = name.trim()||`pasted_${ts}`;
                const filename = name.endsWith('.png')? name : `${name}.png`;
                let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
                if (!t) return;
                t = t.trim().toUpperCase();
                const type = t==='T'? ASSET_TYPE_TSHIRT : t==='D'? ASSET_TYPE_DECAL : null;
                if (!type) return;
                handleFileSelect([new File([blob], filename, {type: blob.type})], type);
                break;
            }
        }
    }

    window.addEventListener('load', () => {
        createUI();
        document.addEventListener('paste', handlePaste);
        scanForAssets();
        console.log('[AnnaUploader] v5.6 initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
    });

})();