Simple Grammar Fix

Ctrl+& fixes grammar in editable fields using OpenAI

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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