Last active
March 4, 2026 15:49
-
-
Save kendfrey/ef6a096749c7f0343a4687ed51e5736f to your computer and use it in GitHub Desktop.
OGS Copy SGF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name OGS Copy SGF | |
| // @namespace https://online-go.com/ | |
| // @version 0.1.0 | |
| // @description Adds a Copy SGF button below Download SGF on OGS game pages | |
| // @author Kendall Frey, GitHub Copilot | |
| // @match https://online-go.com/* | |
| // @grant GM_setClipboard | |
| // ==/UserScript== | |
| (function () { | |
| "use strict"; | |
| const BUTTON_TEXT = "Copy SGF"; | |
| const DOWNLOAD_TEXT = "Download SGF"; | |
| const MARKER_ATTR = "data-ogs-copy-sgf"; | |
| const GAME_PATH_RE = /^\/game\/(\d+)/; | |
| function getGameId() { | |
| const match = window.location.pathname.match(GAME_PATH_RE); | |
| return match ? match[1] : null; | |
| } | |
| function isGamePage() { | |
| return GAME_PATH_RE.test(window.location.pathname); | |
| } | |
| function findDownloadSgfElement(root = document) { | |
| const candidates = root.querySelectorAll("a, button"); | |
| for (const el of candidates) { | |
| const text = (el.textContent || "").trim(); | |
| if (text === DOWNLOAD_TEXT) { | |
| return el; | |
| } | |
| } | |
| return null; | |
| } | |
| function cloneIconElement(downloadEl) { | |
| const icon = downloadEl.querySelector( | |
| "i, svg, span[class*='icon'], span[class*='fa'], span[class*='glyph']" | |
| ); | |
| return icon ? icon.cloneNode(true) : null; | |
| } | |
| function createCopyButton(downloadEl) { | |
| const tagName = downloadEl.tagName.toLowerCase(); | |
| const copyEl = document.createElement(tagName); | |
| copyEl.className = downloadEl.className || ""; | |
| if (tagName === "a") { | |
| copyEl.href = "#"; | |
| copyEl.setAttribute("role", downloadEl.getAttribute("role") || "button"); | |
| } else { | |
| copyEl.type = "button"; | |
| } | |
| copyEl.setAttribute(MARKER_ATTR, "true"); | |
| const iconEl = cloneIconElement(downloadEl); | |
| if (iconEl) { | |
| copyEl.appendChild(iconEl); | |
| copyEl.appendChild(document.createTextNode(" ")); | |
| } | |
| copyEl.appendChild(document.createTextNode(BUTTON_TEXT)); | |
| copyEl.addEventListener("click", async (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| await handleCopyClick(downloadEl, copyEl); | |
| }); | |
| return copyEl; | |
| } | |
| async function fetchSgfText(downloadEl) { | |
| const href = downloadEl.tagName.toLowerCase() === "a" ? downloadEl.getAttribute("href") : null; | |
| const gameId = getGameId(); | |
| const urls = []; | |
| if (href && href.includes("sgf")) { | |
| urls.push(new URL(href, window.location.origin).toString()); | |
| } | |
| if (gameId) { | |
| urls.push(`${window.location.origin}/api/v1/games/${gameId}/sgf`); | |
| urls.push(`${window.location.origin}/game/${gameId}/sgf`); | |
| } | |
| for (const url of urls) { | |
| try { | |
| const response = await fetch(url, { credentials: "include" }); | |
| if (!response.ok) { | |
| continue; | |
| } | |
| const text = await response.text(); | |
| if (text && text.trim().startsWith("(")) { | |
| return text; | |
| } | |
| } catch (error) { | |
| // ignore and try next | |
| } | |
| } | |
| throw new Error("Unable to fetch SGF text"); | |
| } | |
| async function handleCopyClick(downloadEl, copyEl) { | |
| try { | |
| const sgfText = await fetchSgfText(downloadEl); | |
| if (typeof GM_setClipboard === "function") { | |
| GM_setClipboard(sgfText, "text"); | |
| } else if (navigator.clipboard && navigator.clipboard.writeText) { | |
| await navigator.clipboard.writeText(sgfText); | |
| } else { | |
| throw new Error("Clipboard API not available"); | |
| } | |
| } catch (error) { | |
| console.error("Copy SGF failed:", error); | |
| } | |
| } | |
| function insertCopyButton(downloadEl) { | |
| const parent = downloadEl.parentElement; | |
| if (!parent) { | |
| return; | |
| } | |
| const container = parent.tagName.toLowerCase() === "li" ? parent.parentElement : parent; | |
| if (container && container.querySelector(`[${MARKER_ATTR}="true"]`)) { | |
| return; | |
| } | |
| const copyEl = createCopyButton(downloadEl); | |
| if (parent.tagName.toLowerCase() === "li") { | |
| const li = document.createElement("li"); | |
| li.className = parent.className || ""; | |
| li.appendChild(copyEl); | |
| parent.insertAdjacentElement("afterend", li); | |
| } else { | |
| downloadEl.insertAdjacentElement("afterend", copyEl); | |
| } | |
| } | |
| function tryInsert() { | |
| const downloadEl = findDownloadSgfElement(); | |
| if (downloadEl) { | |
| insertCopyButton(downloadEl); | |
| return true; | |
| } | |
| return false; | |
| } | |
| let observer = null; | |
| let lastPath = window.location.pathname; | |
| function ensureObserver() { | |
| if (observer) { | |
| return; | |
| } | |
| observer = new MutationObserver(() => { | |
| if (isGamePage()) { | |
| tryInsert(); | |
| } | |
| }); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| } | |
| function stopObserver() { | |
| if (observer) { | |
| observer.disconnect(); | |
| observer = null; | |
| } | |
| } | |
| function handleLocationChange() { | |
| if (isGamePage()) { | |
| ensureObserver(); | |
| tryInsert(); | |
| } else { | |
| stopObserver(); | |
| } | |
| } | |
| setInterval(() => { | |
| if (window.location.pathname !== lastPath) { | |
| lastPath = window.location.pathname; | |
| handleLocationChange(); | |
| } | |
| }, 500); | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", handleLocationChange); | |
| } else { | |
| handleLocationChange(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment