Simple Grammar Fix

Ctrl+& fixes grammar in editable fields using OpenAI

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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