OpenUserJS - Copy Code Button

Adds a Copy/Download split button on OpenUserJS script pages (About and Source). Copy to clipboard or download as .js / .txt / .md. Ctrl+Shift+C shortcut and live line/char stats on Source page.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         OpenUserJS - Copy Code Button
// @namespace    https://openuserjs.org/
// @version      2.0
// @description  Adds a Copy/Download split button on OpenUserJS script pages (About and Source). Copy to clipboard or download as .js / .txt / .md. Ctrl+Shift+C shortcut and live line/char stats on Source page.
// @author       achma, with claude-AI
// @license      MIT
// @match        https://openuserjs.org/scripts/*/*
// @icon         https://openuserjs.org/images/favicon.ico
// @grant        GM_xmlhttpRequest
// @connect      openuserjs.org
// @run-at       document-idle
// ==/UserScript==

(() => {
    'use strict';

    // ─── Page detection ───────────────────────────────────────────────────────
    // About page:  /scripts/USER/SCRIPT_NAME          (ends here, no extra segment)
    // Source page: /scripts/USER/SCRIPT_NAME/source
    // Excluded:    /scripts/USER/SCRIPT_NAME/issues  /edit  /diff  etc.

    const path = window.location.pathname;
    const IS_SOURCE_PAGE = /\/scripts\/[^/]+\/[^/]+\/source$/.test(path);
    const IS_ABOUT_PAGE  = /\/scripts\/[^/]+\/[^/]+$/.test(path);

    if (!IS_SOURCE_PAGE && !IS_ABOUT_PAGE) return;

    // ─── Derive raw .user.js URL ──────────────────────────────────────────────
    // /scripts/USER/SCRIPT[/source]  →  https://openuserjs.org/src/scripts/USER/SCRIPT.user.js

    function getRawUrl() {
        const slug = path
            .replace(/^\/scripts\//, '')
            .replace(/\/source$/, '');
        return `https://openuserjs.org/src/scripts/${slug}.user.js`;
    }

    // ─── Script name for download filename ───────────────────────────────────
    // .script-name anchor is present on both page types

    function getScriptName() {
        const el = document.querySelector('a.script-name');
        const raw = el
            ? el.textContent.trim()
            : document.title.replace(/\s*[\|–|-].*$/, '').trim();
        return raw
            .replace(/[^\w\s\-().]/g, '')
            .replace(/\s+/g, '_')
            .substring(0, 80) || 'script';
    }

    // ─── Shared styles ────────────────────────────────────────────────────────

    const style = document.createElement('style');
    style.textContent = `
        .oujs-split-wrap {
            display: inline-flex;
            align-items: stretch;
            border-radius: 6px;
            position: relative;
            box-shadow: 0 1px 6px rgba(0,0,0,0.20);
            vertical-align: middle;
        }
        .oujs-copy-btn {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 6px 13px;
            background: #23272e;
            color: #f3f4f6;
            border: 1.5px solid #3a3f4a;
            border-right: none;
            border-radius: 6px 0 0 6px;
            font-size: 12.5px;
            font-weight: 600;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            cursor: pointer;
            transition: background 0.13s, color 0.13s;
            user-select: none;
            line-height: 1;
            white-space: nowrap;
        }
        .oujs-copy-btn:hover  { background: #2d3340; color: #fff; }
        .oujs-copy-btn.oujs-copied  { background: #10b981; border-color: #059669; color: #fff; }
        .oujs-copy-btn.oujs-error   { background: #ef4444; border-color: #dc2626; color: #fff; }
        .oujs-copy-btn.oujs-loading { background: #374151; border-color: #4b5563; color: #9ca3af; cursor: wait; }

        .oujs-btn-divider {
            width: 1px;
            background: #3a3f4a;
            align-self: stretch;
            flex-shrink: 0;
        }
        .oujs-chevron-btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            padding: 6px 9px;
            background: #23272e;
            color: #9ca3af;
            border: 1.5px solid #3a3f4a;
            border-left: none;
            border-radius: 0 6px 6px 0;
            cursor: pointer;
            transition: background 0.13s, color 0.13s;
            user-select: none;
        }
        .oujs-chevron-btn:hover { background: #2d3340; color: #f3f4f6; }
        .oujs-chevron-btn svg {
            transition: transform 0.18s cubic-bezier(0.4,0,0.2,1);
            display: block;
        }
        .oujs-chevron-btn.open svg { transform: rotate(180deg); }

        .oujs-dropdown {
            display: none;
            position: absolute;
            top: calc(100% + 5px);
            left: 0;
            min-width: 210px;
            background: #1c2028;
            border: 1.5px solid #3a3f4a;
            border-radius: 7px;
            box-shadow: 0 8px 24px rgba(0,0,0,0.38);
            z-index: 999999;
            overflow: hidden;
            animation: oujs-drop-in 0.14s cubic-bezier(0.4,0,0.2,1);
        }
        .oujs-dropdown.open { display: block; }

        @keyframes oujs-drop-in {
            from { opacity: 0; transform: translateY(-5px) scale(0.98); }
            to   { opacity: 1; transform: translateY(0)   scale(1);    }
        }
        @keyframes oujs-spin { to { transform: rotate(360deg); } }

        .oujs-drop-header {
            padding: 8px 13px 4px;
            font-size: 10px;
            font-weight: 700;
            letter-spacing: 0.09em;
            text-transform: uppercase;
            color: #6b7280;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            user-select: none;
        }
        .oujs-drop-divider { height: 1px; background: #2d3340; margin: 3px 10px; }

        .oujs-drop-item {
            display: flex;
            align-items: center;
            gap: 9px;
            padding: 8px 13px;
            font-size: 12.5px;
            font-weight: 500;
            color: #d1d5db;
            cursor: pointer;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            transition: background 0.1s, color 0.1s;
            user-select: none;
        }
        .oujs-drop-item:hover { background: #2d3340; color: #fff; }
        .oujs-drop-item:last-child { margin-bottom: 4px; }
        .oujs-drop-item.disabled { opacity: 0.4; cursor: wait; pointer-events: none; }

        .oujs-drop-badge {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 32px;
            padding: 2px 6px;
            background: #2d3340;
            color: #9ca3af;
            border-radius: 4px;
            font-size: 10.5px;
            font-weight: 700;
            font-family: monospace;
            transition: background 0.1s, color 0.1s;
            flex-shrink: 0;
        }
        .oujs-drop-item:hover .oujs-drop-badge { background: #3b82f6; color: #fff; }

        /* Source page extras */
        #oujs-stats-hint {
            display: inline-flex;
            align-items: center;
            gap: 5px;
            font-size: 11.5px;
            color: #888;
            font-family: monospace;
            user-select: none;
            vertical-align: middle;
            margin-left: 4px;
        }
        #oujs-shortcut-hint {
            font-size: 11px;
            color: #777;
            font-family: monospace;
            background: #f5f5f5;
            border: 1px solid #ddd;
            border-radius: 3px;
            padding: 1px 6px;
            user-select: none;
            vertical-align: middle;
            margin-left: 2px;
        }

        /* About page wrapper */
        #oujs-about-wrap {
            display: inline-flex;
            align-items: center;
            margin-left: 8px;
            vertical-align: middle;
        }
    `;
    document.head.appendChild(style);

    // ─── SVG Icons ────────────────────────────────────────────────────────────

    const ICON_COPY = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
        <rect x="9" y="9" width="13" height="13" rx="2"/>
        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
    </svg>`;

    const ICON_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <polyline points="20 6 9 17 4 12"/>
    </svg>`;

    const ICON_ERROR = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <circle cx="12" cy="12" r="10"/>
        <line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
    </svg>`;

    const ICON_SPINNER = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
        style="animation: oujs-spin 0.8s linear infinite;">
        <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
    </svg>`;

    const ICON_CHEVRON = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <polyline points="6 9 12 15 18 9"/>
    </svg>`;

    const ICON_DOWNLOAD = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
        <polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
    </svg>`;

    const ICON_LINES = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/>
        <line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/>
        <line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
    </svg>`;

    // ─── Helpers ──────────────────────────────────────────────────────────────

    function isMac() { return /Mac|iPhone|iPod|iPad/.test(navigator.platform); }
    function modKey() { return isMac() ? '⌘' : 'Ctrl'; }
    function fmt(n)   { return n.toLocaleString(); }

    // ─── Code extraction ──────────────────────────────────────────────────────
    // Source page: OUJS initialises the global `editor = ace.edit("editor")` via
    // jQuery $(document).ready(). We read from that global, or re-call ace.edit(),
    // which is safe to call multiple times on the same element.
    // Fallback (both pages): GM_xmlhttpRequest to the raw .user.js URL.

    function getCodeFromAce() {
        try {
            /* OUJS exposes `editor` as a bare global from its inline <script> */
            if (typeof editor !== 'undefined' && editor && typeof editor.getSession === 'function') {
                return editor.getSession().getValue() || null;
            }
            if (typeof ace !== 'undefined') {
                const e = ace.edit('editor');
                return e.getSession().getValue() || null;
            }
        } catch(_) {}
        return null;
    }

    function fetchRawSource() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method : 'GET',
                url    : getRawUrl(),
                onload : res => res.status === 200 ? resolve(res.responseText) : reject(new Error(`HTTP ${res.status}`)),
                onerror: err => reject(err)
            });
        });
    }

    async function getCode() {
        if (IS_SOURCE_PAGE) {
            const ace = getCodeFromAce();
            if (ace) return ace;
        }
        return fetchRawSource();
    }

    // ─── Download ─────────────────────────────────────────────────────────────

    function triggerDownload(content, filename, mime) {
        const blob = new Blob([content], { type: mime });
        const url  = URL.createObjectURL(blob);
        const a    = Object.assign(document.createElement('a'), { href: url, download: filename });
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    }

    async function downloadAs(ext, items) {
        items.forEach(i => i.classList.add('disabled'));
        try {
            const code = await getCode();
            const name = getScriptName();
            if (ext === 'js')  triggerDownload(code, `${name}.js`,  'text/javascript');
            if (ext === 'txt') triggerDownload(code, `${name}.txt`, 'text/plain');
            if (ext === 'md')  triggerDownload(
                `# ${name.replace(/_/g, ' ')}\n\n\`\`\`javascript\n${code}\n\`\`\`\n`,
                `${name}.md`, 'text/markdown'
            );
        } catch(e) {
            console.error('[OUJS Copier] Download failed:', e);
        } finally {
            items.forEach(i => i.classList.remove('disabled'));
        }
    }

    // ─── Copy ─────────────────────────────────────────────────────────────────

    let resetTimer = null;

    function setButtonState(btn, state, label) {
        clearTimeout(resetTimer);
        btn.classList.remove('oujs-copied', 'oujs-error', 'oujs-loading');
        if (state === 'loading') {
            btn.classList.add('oujs-loading');
            btn.innerHTML = `${ICON_SPINNER}<span>${label}</span>`;
            return;
        }
        if (state === 'success') { btn.classList.add('oujs-copied'); btn.innerHTML = `${ICON_CHECK}<span>${label}</span>`; }
        if (state === 'error')   { btn.classList.add('oujs-error');  btn.innerHTML = `${ICON_ERROR}<span>${label}</span>`; }
        resetTimer = setTimeout(() => {
            btn.classList.remove('oujs-copied', 'oujs-error');
            btn.innerHTML = `${ICON_COPY}<span>Copier</span>`;
        }, 2500);
    }

    async function copyCode(btn) {
        setButtonState(btn, 'loading', 'Fetching…');
        let code;
        try   { code = await getCode(); }
        catch { setButtonState(btn, 'error', 'Failed'); return; }
        if (!code) { setButtonState(btn, 'error', 'Empty'); return; }

        try {
            await navigator.clipboard.writeText(code);
            setButtonState(btn, 'success', 'Copied!');
        } catch {
            try {
                const ta = document.createElement('textarea');
                ta.value = code;
                Object.assign(ta.style, { position:'fixed', opacity:'0', pointerEvents:'none' });
                document.body.appendChild(ta);
                ta.select();
                const ok = document.execCommand('copy');
                ta.remove();
                setButtonState(btn, ok ? 'success' : 'error', ok ? 'Copied!' : 'Failed');
            } catch { setButtonState(btn, 'error', 'Failed'); }
        }
    }

    // ─── Build split button ───────────────────────────────────────────────────

    function buildSplitButton() {
        const wrap = document.createElement('div');
        wrap.className = 'oujs-split-wrap';

        const copyBtn = document.createElement('button');
        copyBtn.className = 'oujs-copy-btn';
        copyBtn.type = 'button';
        copyBtn.innerHTML = `${ICON_COPY}<span>Copier</span>`;
        copyBtn.title = `Copy full script source (${modKey()}+Shift+C)`;
        copyBtn.addEventListener('click', () => copyCode(copyBtn));

        const divider = document.createElement('div');
        divider.className = 'oujs-btn-divider';

        const chevronBtn = document.createElement('button');
        chevronBtn.className = 'oujs-chevron-btn';
        chevronBtn.type = 'button';
        chevronBtn.innerHTML = ICON_CHEVRON;
        chevronBtn.title = 'Download options';
        chevronBtn.setAttribute('aria-haspopup', 'true');
        chevronBtn.setAttribute('aria-expanded', 'false');

        const dropdown = document.createElement('div');
        dropdown.className = 'oujs-dropdown';
        dropdown.innerHTML = `
            <div class="oujs-drop-header">Download as</div>
            <div class="oujs-drop-divider"></div>
            <div class="oujs-drop-item" data-ext="js">${ICON_DOWNLOAD}<span style="flex:1">JavaScript file</span><span class="oujs-drop-badge">.js</span></div>
            <div class="oujs-drop-item" data-ext="txt">${ICON_DOWNLOAD}<span style="flex:1">Plain text</span><span class="oujs-drop-badge">.txt</span></div>
            <div class="oujs-drop-item" data-ext="md">${ICON_DOWNLOAD}<span style="flex:1">Markdown (code block)</span><span class="oujs-drop-badge">.md</span></div>
        `;

        const allItems   = [...dropdown.querySelectorAll('.oujs-drop-item')];
        const openDD  = () => { dropdown.classList.add('open');    chevronBtn.classList.add('open');    chevronBtn.setAttribute('aria-expanded','true');  };
        const closeDD = () => { dropdown.classList.remove('open'); chevronBtn.classList.remove('open'); chevronBtn.setAttribute('aria-expanded','false'); };
        const isOpen  = () =>   dropdown.classList.contains('open');

        allItems.forEach(item => {
            item.addEventListener('click', () => { closeDD(); downloadAs(item.dataset.ext, allItems); });
        });
        chevronBtn.addEventListener('click', e => { e.stopPropagation(); isOpen() ? closeDD() : openDD(); });
        document.addEventListener('click',   e => { if (!wrap.contains(e.target)) closeDD(); });
        document.addEventListener('keydown', e => { if (e.key === 'Escape' && isOpen()) closeDD(); });

        wrap.appendChild(copyBtn);
        wrap.appendChild(divider);
        wrap.appendChild(chevronBtn);
        wrap.appendChild(dropdown);

        return { wrap, copyBtn };
    }

    // ─── SOURCE PAGE injection ────────────────────────────────────────────────
    // Confirmed real DOM:
    //   <pre class="notranslate" translate="no" id="editor">…code…</pre>
    //   <div class="btn-toolbar">
    //     <button id="wrap"     …>Wrap</button>
    //     <button id="beautify" …>Beautify</button>
    //   </div>
    //
    // → prepend our button + stats + shortcut BEFORE the existing Wrap button.

    function injectSourcePage() {
        if (document.getElementById('oujs-source-injected')) return;

        const btnToolbar = document.querySelector('div.btn-toolbar');
        if (!btnToolbar) return;

        // Sentinel to prevent double-injection
        const sentinel = document.createElement('span');
        sentinel.id = 'oujs-source-injected';
        sentinel.style.display = 'none';
        btnToolbar.appendChild(sentinel);

        const { wrap } = buildSplitButton();

        const statsSpan = document.createElement('span');
        statsSpan.id = 'oujs-stats-hint';
        statsSpan.innerHTML = `${ICON_LINES} <span style="opacity:0.5">…</span>`;

        const hintSpan = document.createElement('span');
        hintSpan.id = 'oujs-shortcut-hint';
        hintSpan.textContent = `${modKey()}+Shift+C`;
        hintSpan.title = 'Keyboard shortcut to copy';

        // Prepend in order: [CopyBtn] [stats] [shortcut] [Wrap] [Beautify]
        btnToolbar.insertBefore(hintSpan,  btnToolbar.firstChild);
        btnToolbar.insertBefore(statsSpan, btnToolbar.firstChild);
        btnToolbar.insertBefore(wrap,      btnToolbar.firstChild);

        // Stats: try Ace global (may need a moment to initialise), then fall back to fetch
        function tryStats(attempt) {
            const code = getCodeFromAce();
            if (code) {
                const lines = code.split('\n').length;
                statsSpan.innerHTML = `${ICON_LINES} ${fmt(lines)} lines &nbsp;·&nbsp; ${fmt(code.length)} chars`;
            } else if (attempt < 25) {
                setTimeout(() => tryStats(attempt + 1), 200);
            } else {
                fetchRawSource()
                    .then(c => {
                        statsSpan.innerHTML = `${ICON_LINES} ${fmt(c.split('\n').length)} lines &nbsp;·&nbsp; ${fmt(c.length)} chars`;
                    })
                    .catch(() => { statsSpan.style.display = 'none'; });
            }
        }
        tryStats(0);
    }

    // ─── ABOUT PAGE injection ─────────────────────────────────────────────────
    // Confirmed real DOM:
    //   <h2 class="page-heading">
    //     <div class="btn-group pull-right">
    //       <a href="/install/…" class="btn btn-success">Install</a>
    //       <button class="btn btn-success dropdown-toggle">…caret…</button>
    //       <ul class="dropdown-menu dropdown-menu-right">…</ul>
    //     </div>
    //     <a class="script-author">…</a>
    //     <span class="path-divider">/</span>
    //     <a class="script-name">…</a>
    //   </h2>
    //
    // → insert our wrapper span immediately after .btn-group.pull-right.

    function injectAboutPage() {
        if (document.getElementById('oujs-about-wrap')) return;

        const btnGroup = document.querySelector('h2.page-heading .btn-group.pull-right');
        if (!btnGroup) return;

        const outerWrap = document.createElement('span');
        outerWrap.id = 'oujs-about-wrap';

        const { wrap } = buildSplitButton();
        outerWrap.appendChild(wrap);

        btnGroup.insertAdjacentElement('afterend', outerWrap);
    }

    // ─── Global keyboard shortcut ─────────────────────────────────────────────

    document.addEventListener('keydown', e => {
        const mod = isMac() ? e.metaKey : e.ctrlKey;
        if (mod && e.shiftKey && e.key.toLowerCase() === 'c') {
            e.preventDefault();
            const btn = document.querySelector('.oujs-copy-btn');
            if (btn) copyCode(btn);
        }
    });

    // ─── Init with retry ──────────────────────────────────────────────────────

    function tryInit(attempts = 0) {
        if (IS_SOURCE_PAGE) {
            if (document.querySelector('div.btn-toolbar')) {
                injectSourcePage();
            } else if (attempts < 30) {
                setTimeout(() => tryInit(attempts + 1), 150);
            }
        } else if (IS_ABOUT_PAGE) {
            if (document.querySelector('h2.page-heading .btn-group.pull-right')) {
                injectAboutPage();
            } else if (attempts < 30) {
                setTimeout(() => tryInit(attempts + 1), 150);
            }
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => tryInit());
    } else {
        tryInit();
    }

})();