From 0ff3d156211a42377514f5eeda4a37199cb5c4cb Mon Sep 17 00:00:00 2001 From: Jeff Stein Date: Sat, 20 Jun 2026 08:44:52 -0600 Subject: [PATCH] add organized userscript collection --- .gitignore | 4 + Makefile | 65 ++-- README.md | 20 ++ package.json | 9 + scripts/README.md | 16 + scripts/abook-nzb-helpers/README.md | 5 + .../abook-nzb-helpers.user.js | 305 ++++++++++++++++++ scripts/nzbking-named-downloader/README.md | 5 + .../nzbking-named-downloader.user.js | 192 +++++++++++ scripts/ttyd-osc52-clipboard/README.md | 22 ++ .../ttyd-osc52-clipboard.user.js | 145 +++++++++ tests/userscripts.test.js | 88 +++++ thefp.js | 2 +- 13 files changed, 835 insertions(+), 43 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 scripts/README.md create mode 100644 scripts/abook-nzb-helpers/README.md create mode 100644 scripts/abook-nzb-helpers/abook-nzb-helpers.user.js create mode 100644 scripts/nzbking-named-downloader/README.md create mode 100644 scripts/nzbking-named-downloader/nzbking-named-downloader.user.js create mode 100644 scripts/ttyd-osc52-clipboard/README.md create mode 100644 scripts/ttyd-osc52-clipboard/ttyd-osc52-clipboard.user.js create mode 100644 tests/userscripts.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ace2af --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.local.user.js +*.private.user.js +settings.local.json diff --git a/Makefile b/Makefile index 95053c7..275cf63 100644 --- a/Makefile +++ b/Makefile @@ -1,59 +1,40 @@ -# Makefile for semantic versioning of JavaScript userscripts -# Works with macOS sed and awk +USER_SCRIPTS := $(shell find . -maxdepth 1 -name '*.js'; find scripts -name '*.user.js' 2>/dev/null) -# Find all .js files in current directory -JS_FILES := $(wildcard *.js) +.PHONY: help list patch minor major test -.PHONY: patch minor major list-versions - -# Default target shows help help: - @echo "Semantic Versioning Makefile" - @echo "Usage:" - @echo " make patch - Increment patch version (x.y.Z)" - @echo " make minor - Increment minor version (x.Y.0)" - @echo " make major - Increment major version (X.0.0)" - @echo " make list - Show current versions" - @echo "" - @echo "JavaScript files found: $(JS_FILES)" + @printf '%s\n' 'Available targets:' + @printf ' %-10s %s\n' 'help' 'Show this help.' + @printf ' %-10s %s\n' 'list' 'Show userscript versions.' + @printf ' %-10s %s\n' 'patch' 'Increment patch versions.' + @printf ' %-10s %s\n' 'minor' 'Increment minor versions.' + @printf ' %-10s %s\n' 'major' 'Increment major versions.' + @printf ' %-10s %s\n' 'test' 'Run userscript checks.' -# Show current versions list: - @echo "Current versions:" - @for file in $(JS_FILES); do \ - version=$$(grep "// @version" "$$file" | sed 's/.*@version[[:space:]]*//'); \ - echo " $$file: $$version"; \ + @printf '%s\n' 'Current versions:' + @for file in $(USER_SCRIPTS); do \ + version=$$(awk '/\/\/ @version/ {print $$3; exit}' "$$file"); \ + printf ' %s: %s\n' "$$file" "$${version:-none}"; \ done -# Increment patch version (x.y.z -> x.y.z+1) patch: - @for file in $(JS_FILES); do \ - echo "Updating $$file (patch)..."; \ - sed -i '' 's|// @version \([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)|// @version \1.\2.$$((\3+1))|' "$$file"; \ - sed -i '' 's|// @version \([0-9]*\)\.\([0-9]*\)\.\$$((.*+1))|// @version \1.\2.'$$(awk '/\/\/ @version/ {split($$3, v, "."); print v[3]+1}' "$$file" | head -1)'|' "$$file"; \ + @for file in $(USER_SCRIPTS); do \ + awk '/\/\/ @version/ {split($$3, v, "."); $$3 = v[1] "." v[2] "." (v[3] + 1)} 1' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \ done @$(MAKE) list -# More reliable patch increment using awk -patch: - @for file in $(JS_FILES); do \ - echo "Updating $$file (patch)..."; \ - awk '/\/\/ @version/ {split($$3, v, "."); $$3 = v[1] "." v[2] "." (v[3]+1)} 1' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \ - done - @$(MAKE) list - -# Increment minor version (x.y.z -> x.y+1.0) minor: - @for file in $(JS_FILES); do \ - echo "Updating $$file (minor)..."; \ - awk '/\/\/ @version/ {split($$3, v, "."); $$3 = v[1] "." (v[2]+1) ".0"} 1' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \ + @for file in $(USER_SCRIPTS); do \ + awk '/\/\/ @version/ {split($$3, v, "."); $$3 = v[1] "." (v[2] + 1) ".0"} 1' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \ done @$(MAKE) list -# Increment major version (x.y.z -> x+1.0.0) major: - @for file in $(JS_FILES); do \ - echo "Updating $$file (major)..."; \ - awk '/\/\/ @version/ {split($$3, v, "."); $$3 = (v[1]+1) ".0.0"} 1' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \ + @for file in $(USER_SCRIPTS); do \ + awk '/\/\/ @version/ {split($$3, v, "."); $$3 = (v[1] + 1) ".0.0"} 1' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \ done - @$(MAKE) list \ No newline at end of file + @$(MAKE) list + +test: + npm test diff --git a/README.md b/README.md new file mode 100644 index 0000000..8330f0a --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# User Scripts + +Tampermonkey user scripts that are safe to publish without personal hostnames or private settings. + +## Scripts + +| Script | Purpose | +| --- | --- | +| [`Abook NZB Helpers`](scripts/abook-nzb-helpers/) | Add NZB search, NZBDonkey, and copy helpers to Abook topic pages. | +| [`Free Press Audio Downloader`](thefp.js) | Existing Free Press/Substack audio downloader script. Kept at the repository root so existing Tampermonkey update URLs keep working. | +| [`NZBKing Named Downloader`](scripts/nzbking-named-downloader/) | Add NZBKing download buttons with filenames from URL parameters, clipboard text, or page subjects. | +| [`ttyd OSC52 Clipboard`](scripts/ttyd-osc52-clipboard/) | Copy tmux OSC52 clipboard sequences from ttyd/xterm.js through Tampermonkey. | + +## Repository Rules + +- Keep existing root-level scripts in place when moving them would break `@updateURL`. +- Put new installable scripts under `scripts//.user.js`. +- Keep personal settings out of git. Use Tampermonkey storage or ignored `*.local.user.js` files. +- Keep plain `make` non-destructive. It must print target help only. +- Run `make test` before committing script changes. diff --git a/package.json b/package.json new file mode 100644 index 0000000..59549e5 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "userscripts", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9507a37 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,16 @@ +# Scripts + +Each directory contains one installable Tampermonkey script. + +Existing root-level scripts may remain at the repository root when their committed `@updateURL` points there. + +Use this layout: + +```text +scripts/ + script-name/ + script-name.user.js + README.md +``` + +Personal hostnames, tokens, and debug-only settings belong in Tampermonkey storage or ignored local files, not in committed scripts. diff --git a/scripts/abook-nzb-helpers/README.md b/scripts/abook-nzb-helpers/README.md new file mode 100644 index 0000000..8b8c0a7 --- /dev/null +++ b/scripts/abook-nzb-helpers/README.md @@ -0,0 +1,5 @@ +# Abook NZB Helpers + +Adds NZB search, NZBDonkey, and copy helpers to Abook topic pages. + +Install `abook-nzb-helpers.user.js` with Tampermonkey. diff --git a/scripts/abook-nzb-helpers/abook-nzb-helpers.user.js b/scripts/abook-nzb-helpers/abook-nzb-helpers.user.js new file mode 100644 index 0000000..95b8b70 --- /dev/null +++ b/scripts/abook-nzb-helpers/abook-nzb-helpers.user.js @@ -0,0 +1,305 @@ +// ==UserScript== +// @name Abook NZB Helpers +// @version 1 +// @match https://abook.link/book/index.php?topic=* +// @run-at document-idle +// @noframes +// ==/UserScript== + +const nzblnk_icon = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADggGOSHzRgAAAAABJRU5ErkJggg=='; + +function sanatize_common(code) { + code = code.replace( + /(?:abook|kooba)\.*(?:to|link|ws)*\s*(?:-|\||~)*\s*/gi, + '', + ); + code = code.replace(/['"|]+/g, ''); + code = code.replace(/\\&+/g, ' '); + code = code.replace(/\w+: /gi, ''); + code = code.replace(/.par2/gi, ''); + code = code.replace(/ mp3/gi, ''); + code = code.replace(/ jpg/gi, ''); + code = code.replace(/PW -*/gi, ''); + return code.trim(); +} + +function search_previous(elem) { + if (!elem) return ''; + if (elem.textContent) return elem; + return search_previous(elem.previousSibling); +} + +function search_next(elem) { + if (!elem) return ''; + if (elem.textContent) return elem; + return search_next(elem.nextSibling); +} + +function next_text(elem) { + if (!elem) return ''; + if (elem.nodeName == '#text' || elem.nodeName == 'SPAN') return elem.textContent; + return next_text(elem.nextSibling); +} + +function parse_code(codeItem) { + const header = search_previous(codeItem.previousSibling); + const code = search_next(codeItem.nextSibling); + return [header, code]; +} + +function inject_nzbdonkey(code, title, search, password, time) { + const mainDiv = document.createElement('div'); + mainDiv.classList.add('nzbdonkey'); + mainDiv.setAttribute( + 'style', + 'margin-top: 20px; border: 1px dotted yellow; padding: 10px;', + ); + + const help = document.createElement('a'); + help.classList.add('nzbdonkey-help'); + help.setAttribute('target', '_blank'); + help.setAttribute('rel', 'noreferrer'); + help.setAttribute('href', 'https://tensai75.github.io/NZBDonkey/'); + help.setAttribute( + 'title', + "This extension allows you to right click the text below and click 'Get NZB File' to automatically find and process the NZB.\n\nAnother extension is required.", + ); + help.setAttribute( + 'style', + 'text-decoration: none; color: #2196f3; border-bottom: 1px dotted #2196f3;', + ); + help.appendChild(document.createTextNode('?')); + + const headDiv = document.createElement('div'); + headDiv.classList.add('nzbdonkey-head'); + headDiv.appendChild(document.createTextNode('NZBDonkey Highlight Text ')); + headDiv.setAttribute('style', 'font-weight: bold; color: red;'); + headDiv.appendChild(help); + mainDiv.appendChild(headDiv); + + const textDiv = document.createElement('div'); + textDiv.classList.add('nzbdonkey-text'); + textDiv.setAttribute('onclick', 'window.getSelection().selectAllChildren(this);'); + textDiv.setAttribute('oncontextmenu', 'window.getSelection().selectAllChildren(this);'); + textDiv.setAttribute( + 'style', + 'font-size: 10px; font-weight: normal; font-family: monospace; color: #bb96e0; padding-left: 30px;', + ); + + const titleDiv = document.createElement('div'); + titleDiv.appendChild(document.createTextNode(title)); + textDiv.appendChild(titleDiv); + + const headerDiv = document.createElement('div'); + headerDiv.appendChild(document.createTextNode(`Header: ${search}`)); + textDiv.appendChild(headerDiv); + + if (password) { + const passwordDiv = document.createElement('div'); + passwordDiv.appendChild(document.createTextNode(`Password: ${password}`)); + textDiv.appendChild(passwordDiv); + } + + mainDiv.appendChild(textDiv); + + const nzblnkT = encodeURIComponent(title); + const nzblnkH = encodeURIComponent(search); + const nzblnkG = encodeURIComponent('alt.binaries.mp3.abooks'); + const nzblnkD = encodeURIComponent(time); + const nzblnk = document.createElement('a'); + nzblnk.classList.add('nzblnk'); + + if (password) { + const nzblnkP = encodeURIComponent(password); + nzblnk.setAttribute( + 'href', + `nzblnk:?t=${nzblnkT}&h=${nzblnkH}&g=${nzblnkG}&p=${nzblnkP}&d=${nzblnkD}`, + ); + } else { + nzblnk.setAttribute('href', `nzblnk:?t=${nzblnkT}&h=${nzblnkH}&g=${nzblnkG}&d=${nzblnkD}`); + } + + const nzblnkIcon = document.createElement('img'); + nzblnkIcon.setAttribute('src', `data:image/png;charset=utf-8;base64,${nzblnk_icon}`); + nzblnkIcon.setAttribute('border', '0'); + nzblnk.appendChild(nzblnkIcon); + headDiv.before(nzblnk); + code.after(mainDiv); +} + +function kooba_copy_clipboard_str(str) { + const el = document.createElement('textarea'); + el.value = str; + el.setAttribute('readonly', ''); + el.style.position = 'absolute'; + el.style.left = '-9999px'; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); +} + +function kooba_copy_clipboard_data(obj) { + kooba_copy_clipboard_str(obj.dataset.cipboard); + + const iDiv3 = document.createElement('div'); + iDiv3.className = 'copied_to_clipboard'; + iDiv3.style.border = '1px solid red'; + iDiv3.style.backgroundColor = 'red'; + iDiv3.style.color = 'yellow'; + iDiv3.style.textAlign = 'center'; + iDiv3.style.display = 'inline-block'; + iDiv3.style.position = 'absolute'; + iDiv3.style.marginLeft = '10px'; + iDiv3.innerHTML = 'text copied to clipboard'; + obj.parentNode.insertBefore(iDiv3, obj.nextSibling); + + setTimeout(() => { + const elements = document.getElementsByClassName('copied_to_clipboard'); + while (elements.length > 0) { + elements[0].parentNode.removeChild(elements[0]); + } + }, 2000); +} + +function inject_search(code, title, search, password) { + const searchDiv = document.createElement('div'); + searchDiv.classList.add('search-links'); + searchDiv.setAttribute('style', 'display: inline-block'); + + const searchSpan = document.createElement('span'); + searchSpan.setAttribute('style', 'margin-left: .5em'); + searchSpan.appendChild(document.createTextNode('Search:')); + searchDiv.appendChild(searchSpan); + + const searchLinks = document.createElement('ul'); + searchLinks.setAttribute( + 'style', + 'display: inline-block; list-style: none; margin: 0; padding-left: 0;', + ); + + const query = encodeURIComponent(search); + const copyText = password ? `${title} {{${password}}}` : title; + const providers = [ + ['NZBIndex Beta', `https://beta.nzbindex.com/search?q=${query}`], + ['NZBIndex', `https://nzbindex.com/search?q=${query}`], + ['Binsearch', `https://www.binsearch.info/search?q=${query}`], + [ + 'NZBKing', + `https://nzbking.com/search/?q="${query}"&title=${encodeURIComponent(title)}${password ? '&pw=' + encodeURIComponent(password) : ''}`, + ], + ]; + + providers.forEach(([label, href], index) => { + const item = document.createElement('li'); + item.setAttribute('style', 'display: inline-block; margin: 0; padding: 0;'); + + const link = document.createElement('a'); + link.setAttribute('style', 'margin: 0; padding: 0 .5em;'); + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noreferrer'); + link.setAttribute('href', href); + link.appendChild(document.createTextNode(label)); + link.addEventListener('click', () => { + kooba_copy_clipboard_str(copyText); + }); + + item.appendChild(link); + if (index < providers.length - 1) { + item.appendChild(document.createTextNode('|')); + } + searchLinks.appendChild(item); + }); + + searchDiv.appendChild(searchLinks); + + const titlecopyLink = document.createElement('a'); + titlecopyLink.setAttribute( + 'style', + 'margin-left: 30px; color: #9383e0; font-weight: normal;', + ); + titlecopyLink.addEventListener('click', (e) => kooba_copy_clipboard_data(e.target)); + titlecopyLink.dataset.cipboard = title; + titlecopyLink.title = `Copy ${title} to clipboard`; + titlecopyLink.appendChild(document.createTextNode('[Copy Title]')); + searchDiv.appendChild(titlecopyLink); + + if (password) { + const titlePwCopyLink = document.createElement('a'); + titlePwCopyLink.setAttribute( + 'style', + 'margin-left: 10px; color: #9383e0; font-weight: normal;', + ); + titlePwCopyLink.addEventListener('click', (e) => kooba_copy_clipboard_data(e.target)); + titlePwCopyLink.dataset.cipboard = `${title} {{${password}}}`; + titlePwCopyLink.title = `Copy "${title} {{${password}}}" to clipboard`; + titlePwCopyLink.appendChild(document.createTextNode('[CopyTitle&PW]')); + searchDiv.appendChild(titlePwCopyLink); + } + + code.appendChild(searchDiv); +} + +function* post_passwords(postCodes) { + for (const postCode of postCodes) { + const code = parse_code(postCode); + if (code[0].textContent.toLowerCase().includes('password')) { + yield code[1].textContent; + } else { + const next = next_text(postCode.nextSibling); + if (next.toLowerCase().includes('password')) { + const rePass = next.match(/:\s*([^\n]+)/); + if (rePass && rePass[1].trim()) yield rePass[1].trim(); + } + } + } +} + +function parse_post(postItem) { + const postTitle = postItem.querySelector('.keyinfo a').text; + const title = postTitle.replace(/\[SPOT\]/gi, '').trim(); + const postTime = postItem.querySelector('.keyinfo .smalltext').textContent; + const reTime = /:\s(.+) ยป/; + let postTimestamp = Math.round(Date.parse(postTime.match(reTime)[1]) / 1000); + if (!postTimestamp) postTimestamp = Math.round(Date.now() / 1000); + console.log('Found post: ', title); + console.log('Timestamp: ', postTimestamp); + + const postCodes = postItem.querySelectorAll('.codeheader'); + const postPasswords = Array.from(post_passwords(postCodes)); + + for (const postCode of postCodes) { + const code = parse_code(postCode); + if (!code[0].textContent.toLowerCase().includes('password')) { + const new_pw = postPasswords.pop(); + const password = new_pw || ''; + const search = sanatize_common(code[1].textContent); + if (!postCode.classList.contains('code-injected')) { + inject_nzbdonkey(code[1], title, search, password, postTimestamp); + inject_search(postCode, title, search, password); + postCode.classList.add('code-injected'); + } + } + } +} + +function process_posts() { + const postItems = document.querySelectorAll('.postarea'); + postItems.forEach((postItem) => { + parse_post(postItem); + }); +} + +(() => { + process_posts(); + const myThanks = { + apply: function (target, thisArg, argumentsList) { + setTimeout(process_posts, 200); + setTimeout(process_posts, 1200); + return Reflect.apply(target, thisArg, argumentsList); + }, + }; + const proxyThanks = new Proxy(saythanks.prototype.init, myThanks); + saythanks.prototype.init = proxyThanks; +})(); diff --git a/scripts/nzbking-named-downloader/README.md b/scripts/nzbking-named-downloader/README.md new file mode 100644 index 0000000..ee79f35 --- /dev/null +++ b/scripts/nzbking-named-downloader/README.md @@ -0,0 +1,5 @@ +# NZBKing Named Downloader + +Adds download buttons to NZBKing results and names downloaded NZB files from the URL title parameter, clipboard text, or page subject. + +Install `nzbking-named-downloader.user.js` with Tampermonkey. diff --git a/scripts/nzbking-named-downloader/nzbking-named-downloader.user.js b/scripts/nzbking-named-downloader/nzbking-named-downloader.user.js new file mode 100644 index 0000000..2448d7f --- /dev/null +++ b/scripts/nzbking-named-downloader/nzbking-named-downloader.user.js @@ -0,0 +1,192 @@ +// ==UserScript== +// @name NZBKing Named Downloader +// @version 1 +// @match https://nzbking.com/* +// @run-at document-idle +// @noframes +// ==/UserScript== + +(() => { + const DEBUG = true; + const log = DEBUG ? console.log.bind(console, '[NZBKing DL]') : () => {}; + + function sanitizeFilename(name) { + return name.replace(/[\\/?%*:|"<>]/g, '_').trim(); + } + + function cleanTitle(text) { + return text.replace(/\s*\{\{.*?\}\}\s*$/, '').trim(); + } + + async function getClipboardText(attempts = 3, delay = 150) { + for (let i = 0; i < attempts; i++) { + try { + if (navigator.clipboard?.readText) { + const text = await navigator.clipboard.readText(); + log('clipboard read attempt', i + 1, '->', text ? 'success' : 'empty'); + if (text) return text; + } + } catch (e) { + log('clipboard read attempt', i + 1, '-> error:', e.name); + } + if (i < attempts - 1) await new Promise((r) => setTimeout(r, delay)); + } + return ''; + } + + function downloadBlob(href, filename) { + const a = document.createElement('a'); + a.href = href; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + function getNzbId(link) { + const m = link.href.match(/\/nzb:([^/]+)\//); + return m ? m[1] : null; + } + + function getSubjectFallback(nzbLink) { + let parent = nzbLink.parentElement; + while (parent && !parent.classList.contains('search-subject')) { + parent = parent.parentElement; + } + if (parent) { + for (const node of parent.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent.trim(); + if (t) { + return t + .replace(/^\[\d+\/\d+]\s*-\s*/, '') + .replace(/^["']/, '') + .replace(/["']$/, ''); + } + } else if (node.nodeName === 'BR') { + break; + } + } + } + return document.title.replace(/\s+-\s+NZBKing$/, '').trim(); + } + + function getTitleFromUrl() { + try { + const params = new URLSearchParams(window.location.search); + const t = params.get('title'); + const p = params.get('pw'); + if (t) { + log('URL params - title:', t, 'pw:', p || '(none)'); + const title = decodeURIComponent(t); + const pw = p ? decodeURIComponent(p) : ''; + return pw ? `${title} {{${pw}}}` : title; + } + } catch { + // Ignore malformed URI values and fall back to clipboard or page title. + } + return ''; + } + + async function resolveFilename(nzbLink) { + const urlTitle = getTitleFromUrl(); + if (urlTitle) { + log('using URL param title:', urlTitle); + return sanitizeFilename(urlTitle) + '.nzb'; + } + + const clip = await getClipboardText(); + if (clip) { + const clean = cleanTitle(clip); + if (clean) { + log('using clipboard title:', clean); + return sanitizeFilename(clean) + '.nzb'; + } + } + + const subj = getSubjectFallback(nzbLink); + if (subj) { + log('using subject fallback:', subj); + return sanitizeFilename(subj) + '.nzb'; + } + return 'download.nzb'; + } + + const buttons = []; + + async function updateButtonLabel(btn, nzbLink) { + const filename = await resolveFilename(nzbLink); + btn.textContent = `DL: ${filename}`; + btn.title = `Download as "${filename}"`; + log('label set ->', filename); + } + + function injectButton(nzbLink) { + const nzbId = getNzbId(nzbLink); + if (!nzbId) return; + + const btn = document.createElement('a'); + btn.className = 'button'; + btn.style.marginLeft = '4px'; + btn.href = 'javascript:void(0);'; + btn.textContent = 'DL: ...'; + + btn.addEventListener('mouseenter', () => { + updateButtonLabel(btn, nzbLink); + }); + + btn.addEventListener('click', async (e) => { + e.preventDefault(); + + const filename = await resolveFilename(nzbLink); + log('click download ->', filename); + + if (typeof nzbd === 'function') { + try { + nzbd(nzbId); + } catch (_) {} + } + + try { + const resp = await fetch(nzbLink.href); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + downloadBlob(url, filename); + URL.revokeObjectURL(url); + } catch (err) { + console.error('NZBKing DL error:', err); + window.open(nzbLink.href, '_blank'); + } + }); + + nzbLink.after(btn); + buttons.push({ btn, nzbLink }); + updateButtonLabel(btn, nzbLink); + } + + async function refreshLabels() { + for (const { btn, nzbLink } of buttons) { + await updateButtonLabel(btn, nzbLink); + } + } + + const nzbLinks = document.querySelectorAll('a[href^="/nzb:"]'); + for (const link of nzbLinks) { + injectButton(link); + } + + refreshLabels(); + + window.addEventListener('focus', () => { + log('window focus triggered refresh'); + refreshLabels(); + }); + + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + log('visibilitychange visible triggered refresh'); + refreshLabels(); + } + }); +})(); diff --git a/scripts/ttyd-osc52-clipboard/README.md b/scripts/ttyd-osc52-clipboard/README.md new file mode 100644 index 0000000..179d3f4 --- /dev/null +++ b/scripts/ttyd-osc52-clipboard/README.md @@ -0,0 +1,22 @@ +# ttyd OSC52 Clipboard + +Copies tmux OSC52 clipboard sequences from ttyd/xterm.js to the local clipboard through Tampermonkey. + +## Install + +Open `ttyd-osc52-clipboard.user.js` in a browser with Tampermonkey installed, then install or update the script. + +## Personal Settings + +The committed script intentionally uses broad `@match` rules and exits unless the current hostname is allowlisted in Tampermonkey storage. + +After installing: + +1. Visit your ttyd host. +2. Open the Tampermonkey menu for this script. +3. Run `Allow this host`. +4. Reload the page. + +Use `Toggle debug` from the same menu when you need diagnostic logging. + +No personal hostnames should be committed to this file. diff --git a/scripts/ttyd-osc52-clipboard/ttyd-osc52-clipboard.user.js b/scripts/ttyd-osc52-clipboard/ttyd-osc52-clipboard.user.js new file mode 100644 index 0000000..8444d15 --- /dev/null +++ b/scripts/ttyd-osc52-clipboard/ttyd-osc52-clipboard.user.js @@ -0,0 +1,145 @@ +// ==UserScript== +// @name ttyd OSC52 Clipboard +// @version 0.1.0 +// @description Copy tmux OSC52 clipboard sequences from ttyd/xterm.js +// @match http://*/* +// @match https://*/* +// @grant GM_setClipboard +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand +// @grant unsafeWindow +// @run-at document-idle +// ==/UserScript== + +(function () { + 'use strict'; + + const MARK = '__ttydTmuxCopyInstalled'; + const PREFIX = '[ttyd tmux copy]'; + + function getAllowedHosts() { + return GM_getValue('allowedHosts', []); + } + + function isDebugEnabled() { + return GM_getValue('debug', false); + } + + function log(...args) { + if (isDebugEnabled()) { + console.log(PREFIX, ...args); + } + } + + function saveAllowedHosts(hosts) { + GM_setValue('allowedHosts', Array.from(new Set(hosts)).sort()); + } + + GM_registerMenuCommand('Allow this host', function () { + const hosts = getAllowedHosts(); + saveAllowedHosts(hosts.concat(location.hostname)); + console.log(PREFIX, 'allowed host', location.hostname); + }); + + GM_registerMenuCommand('Forget this host', function () { + const hosts = getAllowedHosts().filter(function (host) { + return host !== location.hostname; + }); + + saveAllowedHosts(hosts); + console.log(PREFIX, 'forgot host', location.hostname); + }); + + GM_registerMenuCommand('Toggle debug', function () { + const debug = !isDebugEnabled(); + GM_setValue('debug', debug); + console.log(PREFIX, 'debug', debug); + }); + + if (!getAllowedHosts().includes(location.hostname)) { + log('host not allowlisted', location.hostname); + return; + } + + function decodeBase64Utf8(base64) { + const cleaned = base64.replace(/\s/g, ''); + const binary = atob(cleaned); + const bytes = Uint8Array.from(binary, function (char) { + return char.charCodeAt(0); + }); + + return new TextDecoder().decode(bytes); + } + + function findTerm() { + const textarea = document.querySelector('.xterm-helper-textarea'); + + return unsafeWindow.term || + (textarea && textarea.__xtermTerminal) || + null; + } + + function install(term) { + if (!term) { + return false; + } + + if (term[MARK]) { + return true; + } + + if (!term.parser || typeof term.parser.registerOscHandler !== 'function') { + return false; + } + + term.parser.registerOscHandler(52, function (data) { + const sep = data.indexOf(';'); + + // Treat malformed OSC52 input as handled so xterm.js does not keep + // processing it and emit parser noise. + if (sep === -1) { + log('ignored malformed payload'); + return true; + } + + const selection = data.slice(0, sep); + const payload = data.slice(sep + 1).replace(/\s/g, ''); + + if (!payload || payload === '?') { + log('ignored empty/query payload', { selection: selection }); + return true; + } + + try { + const text = decodeBase64Utf8(payload); + GM_setClipboard(text, 'text'); + log('copied payload', { selection: selection, length: text.length }); + } catch (err) { + console.error(PREFIX, 'copy failed', err); + } + + return true; + }); + + term[MARK] = true; + console.log(PREFIX, 'installed for', location.hostname); + return true; + } + + let attempts = 0; + + const timer = setInterval(function () { + attempts += 1; + + if (install(findTerm())) { + clearInterval(timer); + return; + } + + if (attempts >= 60) { + clearInterval(timer); + console.warn(PREFIX, 'terminal not found on', location.hostname); + } + }, 500); +})(); diff --git a/tests/userscripts.test.js b/tests/userscripts.test.js new file mode 100644 index 0000000..715c567 --- /dev/null +++ b/tests/userscripts.test.js @@ -0,0 +1,88 @@ +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const scriptPath = new URL('../scripts/ttyd-osc52-clipboard/ttyd-osc52-clipboard.user.js', import.meta.url); +const freePressScriptPath = new URL('../thefp.js', import.meta.url); +const abookScriptPath = new URL('../scripts/abook-nzb-helpers/abook-nzb-helpers.user.js', import.meta.url); +const nzbkingScriptPath = new URL( + '../scripts/nzbking-named-downloader/nzbking-named-downloader.user.js', + import.meta.url, +); + +async function readScript() { + return readFile(scriptPath, 'utf8'); +} + +test('userscript metadata is public and installable', async () => { + const source = await readScript(); + + assert.match(source, /\/\/ ==UserScript==/); + assert.match(source, /@name\s+ttyd OSC52 Clipboard/); + assert.match(source, /@version\s+0\.1\.0/); + assert.match(source, /@description\s+Copy tmux OSC52 clipboard sequences from ttyd\/xterm\.js/); + assert.match(source, /@match\s+http:\/\/\*\/\*/); + assert.match(source, /@match\s+https:\/\/\*\/\*/); + assert.match(source, /@grant\s+GM_setClipboard/); + assert.match(source, /@grant\s+GM_getValue/); + assert.match(source, /@grant\s+GM_setValue/); + assert.match(source, /@grant\s+GM_registerMenuCommand/); + assert.doesNotMatch(source, /vookie|terminal\.example\.com|ssh\.example\.org/i); +}); + +test('personal hosts and debug state are stored outside git', async () => { + const source = await readScript(); + + assert.match(source, /GM_getValue\('allowedHosts', \[\]\)/); + assert.match(source, /GM_setValue\('allowedHosts'/); + assert.match(source, /GM_getValue\('debug', false\)/); + assert.match(source, /GM_setValue\('debug'/); + assert.match(source, /GM_registerMenuCommand\('Allow this host'/); + assert.match(source, /GM_registerMenuCommand\('Toggle debug'/); + assert.match(source, /getAllowedHosts\(\)\.includes\(location\.hostname\)/); +}); + +test('OSC52 handling copies decoded payloads and suppresses unsupported input', async () => { + const source = await readScript(); + + assert.match(source, /registerOscHandler\(52/); + assert.match(source, /return true;/); + assert.match(source, /payload === '\?'/); + assert.match(source, /GM_setClipboard\(text, 'text'\)/); + assert.match(source, /new TextDecoder\(\)\.decode\(bytes\)/); + assert.match(source, /setInterval/); + assert.match(source, /attempts >= 60/); +}); + +test('existing Free Press script is preserved at the root update URL path', async () => { + const source = await readFile(freePressScriptPath, 'utf8'); + + assert.match(source, /@name\s+Free Press Audio Downloader/); + assert.match(source, /@match\s+https:\/\/www\.thefp\.com\/\*/); + assert.match(source, /@match\s+https:\/\/\*\.substack\.com\/\*/); + assert.match(source, /@updateURL\s+https:\/\/raw\.githubusercontent\.com\/jeeftor\/user-scripts\/master\/thefp\.js/); +}); + +test('Abook NZB Helpers is tracked as an installable userscript', async () => { + const source = await readFile(abookScriptPath, 'utf8'); + + assert.match(source, /@name\s+Abook NZB Helpers/); + assert.match(source, /@version\s+1/); + assert.match(source, /@match\s+https:\/\/abook\.link\/book\/index\.php\?topic=\*/); + assert.match(source, /@noframes/); + assert.match(source, /function inject_nzbdonkey/); + assert.match(source, /function inject_search/); + assert.match(source, /https:\/\/nzbking\.com\/search\/\?q=/); +}); + +test('NZBKing Named Downloader is tracked as an installable userscript', async () => { + const source = await readFile(nzbkingScriptPath, 'utf8'); + + assert.match(source, /@name\s+NZBKing Named Downloader/); + assert.match(source, /@version\s+1/); + assert.match(source, /@match\s+https:\/\/nzbking\.com\/\*/); + assert.match(source, /@noframes/); + assert.match(source, /async function getClipboardText/); + assert.match(source, /function sanitizeFilename/); + assert.match(source, /document\.querySelectorAll\('a\[href\^="\/nzb:"\]'\)/); +}); diff --git a/thefp.js b/thefp.js index 105e303..d9f25ac 100644 --- a/thefp.js +++ b/thefp.js @@ -1,5 +1,5 @@ // ==UserScript== -// @name Audio Downloader +// @name Free Press Audio Downloader // @namespace http://tampermonkey.net/ // @version 0.0.4 // @description Adds open and copy URL buttons for audio elements on thefp.com and substack.com