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.
// ==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 · ${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 · ${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();
}
})();