Simple Grammar Fix

Ctrl+& fixes grammar in editable fields using OpenAI

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==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);
        });
    });
})();