Skip to content

Instantly share code, notes, and snippets.

@kendfrey
Last active March 4, 2026 15:49
Show Gist options
  • Select an option

  • Save kendfrey/ef6a096749c7f0343a4687ed51e5736f to your computer and use it in GitHub Desktop.

Select an option

Save kendfrey/ef6a096749c7f0343a4687ed51e5736f to your computer and use it in GitHub Desktop.
OGS Copy SGF
// ==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