Simple Grammar Fix

Ctrl+& fixes grammar in editable fields using OpenAI

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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