Ctrl+& fixes grammar in editable fields using OpenAI
// ==UserScript==
// @name Simple Grammar Fix
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Ctrl+& fixes grammar in editable fields using OpenAI
// @match *://*/*
// @noframes
// @license WTFPL
// @icon https://www.iconsdb.com/icons/preview/royal-blue/edit-12-xxl.png
// @author moony
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @connect api.openai.com
// ==/UserScript==
(function () {
'use strict';
// Uses OpenAI free shared-chat quota. Setup: 1) keep $5+ prepaid balance, auto-recharge OFF 2) enable "Share inputs and outputs with OpenAI" for Default project: https://platform.openai.com/settings/organization/data-controls/sharing 3) menu -> paste project key (optionally ", adminKey" for the usage preflight). gpt-4.1 draws from a 1M tokens/day org-wide pool, reset 00:00 UTC; overflow is billed, hence local cap + 900k ceiling.
const API_URL = "https://api.openai.com/v1/chat/completions";
const USAGE_URL = "https://api.openai.com/v1/organization/usage/completions";
const MODEL = "gpt-4.1-2025-04-14"; // <- Change this to any model
const SYSTEM_PROMPT = "Fix grammar only, keep valid abbreviation. Return compact corrected text.";
const MAX_INPUT_BYTES = 128000, CHAT_OVERHEAD = 512, TIMEOUT = 120000;
const LOCAL_DAILY_CAP = 200000, ORG_CEILING = 900000; // ~32k max tokens <= MAX_INPUT_BYTES (gpt-4.1: 1M in / 32k out)
// 10M pool; anything else counts against the 1M pool.
const POOL_10M = new Set(["gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini-2026-03-17",
"gpt-5.4-nano-2026-03-17", "gpt-5.1-codex-mini", "gpt-5-mini-2025-08-07", "gpt-5-nano-2025-08-07",
"gpt-4.1-mini-2025-04-14", "gpt-4.1-nano-2025-04-14", "gpt-4o-mini-2024-07-18",
"o4-mini-2025-04-16", "o1-mini-2024-09-12", "codex-mini-latest"]);
const bytes = s => new TextEncoder().encode(s).length;
const utcDay = () => new Date().toISOString().slice(0, 10);
const utcDayStart = () => Math.floor(Date.parse(utcDay() + "T00:00:00Z") / 1000);
const readQuota = () => { const q = GM_getValue("quota"); return q && q.day === utcDay() ? q : { day: utcDay(), tokens: 0 }; };
const save = q => { if (Number.isFinite(q.orgTokens)) q.freeLeft = Math.max(0, 100 - (q.orgTokens + q.tokens) / ORG_CEILING * 100).toFixed(2) + "%"; GM_setValue("quota", q); };
const reserve = a => { const q = readQuota(); if (q.tokens + a > LOCAL_DAILY_CAP) return null; q.tokens += a; save(q); return q.day; };
const release = (d, a) => { const q = readQuota(); if (q.day === d) { q.tokens = Math.max(0, q.tokens - a); save(q); } };
const reconcile = (d, r, a) => { const q = readQuota(); if (q.day === d) { q.tokens = Math.max(0, q.tokens - r + a); save(q); } };
const fire = el => el.dispatchEvent(new Event('input', { bubbles: true }));
let busy = false;
const fail = msg => { busy = false; if (msg) alert("Grammar Fix: " + msg); };
GM_registerMenuCommand("🗝️ Set API Key", () => {
const raw = prompt("OpenAI project API key (optionally admin key for check free usage: projectKey +','+ adminKey):"); if (!raw) return;
const [proj, admin] = raw.split(",").map(s => s.trim());
if (proj) GM_setValue("API_KEY", proj);
if (admin) GM_setValue("ADMIN_KEY", admin);
});
// Fail-closed: finish(null) on any doubt; fires at most once.
function fetchOneMUsage(cb) {
let done = false;
const finish = v => { if (!done) { done = true; cb(v); } };
const admin = GM_getValue("ADMIN_KEY"); if (!admin) return finish(null);
const qs = new URLSearchParams({ start_time: utcDayStart(), end_time: Math.floor(Date.now() / 1000), bucket_width: "1d", limit: "1", group_by: "model" });
GM_xmlhttpRequest({
method: "GET", url: `${USAGE_URL}?${qs}`, timeout: TIMEOUT, headers: { Authorization: "Bearer " + admin },
onload: r => {
try {
const j = r.status === 200 ? JSON.parse(r.responseText) : null;
if (!j || typeof j !== "object" || Array.isArray(j) || j.has_more !== false || !Array.isArray(j.data)) return finish(null);
const ok = n => Number.isSafeInteger(n) && n >= 0;
let total = 0;
for (const b of j.data) {
if (!b || !Array.isArray(b.results)) return finish(null);
for (const { model, input_tokens: i, output_tokens: o } of b.results) {
if (typeof model !== "string" || !model || !ok(i) || !ok(o)) return finish(null);
if (!POOL_10M.has(model)) total += i + o;
}
}
finish(total);
} catch (_) { finish(null); }
},
onerror: () => finish(null), ontimeout: () => finish(null), onabort: () => finish(null)
});
}
function editableRoot(node) {
if (node && node.nodeType === Node.TEXT_NODE) node = node.parentNode;
if (!node || !node.closest) return null;
const host = node.closest('[contenteditable]');
if (host && /^(|true|plaintext-only)$/i.test(host.getAttribute('contenteditable') || '')) return host;
if (!node.isContentEditable) return null;
while (node.parentElement && node.parentElement.isContentEditable) node = node.parentElement;
return node;
}
function captureTarget() {
const el = document.activeElement;
if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) {
let start, end;
try { start = el.selectionStart; end = el.selectionEnd; } catch (_) { return null; }
if (start == null || start === end) return null;
return { kind: 'field', el, start, end, text: el.value.slice(start, end) };
}
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
const range = sel.getRangeAt(0).cloneRange();
const editable = editableRoot(range.commonAncestorContainer);
return editable ? { kind: 'ce', range, editable, text: sel.toString() } : null;
}
const stillValid = t => t.kind === 'field'
? t.el.isConnected && t.el.value.slice(t.start, t.end) === t.text
: t.editable.isConnected && t.range.toString() === t.text;
function applyFix(t, fixed) {
if (t.kind === 'field') {
const { el, start, end } = t;
el.focus(); el.setSelectionRange(start, end);
if (!document.execCommand('insertText', false, fixed)) { el.value = el.value.slice(0, start) + fixed + el.value.slice(end); fire(el); }
el.setSelectionRange(start, start + fixed.length);
} else {
const sel = window.getSelection();
sel.removeAllRanges(); sel.addRange(t.range); t.editable.focus();
if (!document.execCommand('insertText', false, fixed)) { t.range.deleteContents(); t.range.insertNode(document.createTextNode(fixed)); fire(t.editable); }
}
}
function sendInference(token, target, core, lead, trail, day, reserved, maxOut) {
GM_xmlhttpRequest({
method: "POST", url: API_URL, timeout: TIMEOUT,
headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
data: JSON.stringify({ model: MODEL, max_completion_tokens: maxOut,
messages: [{ role: "system", content: SYSTEM_PROMPT }, { role: "user", content: core }] }),
onload: r => {
busy = false;
if (r.status !== 200) return fail(`request failed (${r.status}).`);
let res; try { res = JSON.parse(r.responseText); } catch (_) { return fail("unreadable response."); }
reconcile(day, reserved, res.usage?.total_tokens ?? reserved);
const c = res.choices?.[0], out = c?.message?.content?.trim();
if (!out) return fail("empty response.");
if (c.finish_reason === "length") return fail("output truncated, not replaced.");
if (!stillValid(target)) return fail("text changed, not replaced.");
applyFix(target, lead + out + trail);
},
onerror: () => fail("request failed."), ontimeout: () => fail("request timed out."), onabort: () => fail()
});
}
document.addEventListener('keydown', e => {
if (!e.isTrusted || !e.ctrlKey || e.key !== '&') return;
const target = captureTarget(); if (!target) return;
e.preventDefault(); if (busy) return;
const token = GM_getValue("API_KEY"); if (!token) return fail("API key not set. Tampermonkey menu -> Set API Key");
const [, lead, core, trail] = target.text.match(/^(\s*)([\s\S]*?)(\s*)$/); if (!core) return;
const inBytes = bytes(core); if (inBytes > MAX_INPUT_BYTES) return fail(`selection too large (${inBytes} bytes, max ${MAX_INPUT_BYTES}).`);
const maxOut = Math.min(32768, Math.ceil(inBytes / 2) + 256); // scale so long fixes aren't cut off, capped at gpt-4.1 output limit
const reserved = bytes(SYSTEM_PROMPT) + inBytes + CHAT_OVERHEAD + maxOut;
const day = reserve(reserved); if (!day) return fail("daily local limit reached. Resets 00:00 UTC.");
busy = true;
fetchOneMUsage(remote => {
if (remote === null) { release(day, reserved); return fail("usage preflight unavailable. Nothing sent."); }
const q = readQuota(); q.orgTokens = remote; save(q);
if (remote + q.tokens > ORG_CEILING) { release(day, reserved); return fail("daily safety ceiling reached. Resets 00:00 UTC."); }
sendInference(token, target, core, lead, trail, day, reserved, maxOut);
});
});
})();