CogTech Language Tool (CogWrite)

Extension-style floating LanguageTool checker for Google Docs and normal text fields.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         CogTech Language Tool (CogWrite)
// @namespace    https://greasyfork.org/scripts/languagetool-checker
// @version      1.2.0
// @description  Extension-style floating LanguageTool checker for Google Docs and normal text fields.
// @author       Phil 🥹👍 and CogTech
// @match        *://*/*
// @all-frames   true
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @grant        GM_addStyle
// @grant        GM.addStyle
// @grant        GM_getValue
// @grant        GM.getValue
// @grant        GM_setValue
// @grant        GM.setValue
// @grant        GM_registerMenuCommand
// @grant        GM.registerMenuCommand
// @connect      api.languagetool.org
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  if (window.__cogwriteFloatingCheckerLoaded) return;
  window.__cogwriteFloatingCheckerLoaded = true;

  const API_URL = 'https://api.languagetool.org/v2/check';
  const MAX_TEXT_CHARS = 20000;
  const EDITABLE_SELECTOR = [
    'textarea',
    'input[type="text"]',
    'input[type="search"]',
    'input[type="email"]',
    'input[type="url"]',
    'input[type="tel"]',
    '[contenteditable="true"]',
    '[contenteditable="plaintext-only"]',
    '[role="textbox"]'
  ].join(',');

  const gm = {
    getValue(key, fallback) {
      try {
        if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
        if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') return GM.getValue(key, fallback);
      } catch (_) {}
      return fallback;
    },
    setValue(key, value) {
      try {
        if (typeof GM_setValue === 'function') return GM_setValue(key, value);
        if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') return GM.setValue(key, value);
      } catch (_) {}
      return undefined;
    },
    addStyle(css) {
      try {
        if (typeof GM_addStyle === 'function') return GM_addStyle(css);
        if (typeof GM !== 'undefined' && typeof GM.addStyle === 'function') return GM.addStyle(css);
      } catch (_) {}

      const style = document.createElement('style');
      style.textContent = css;
      (document.head || document.documentElement).appendChild(style);
      return style;
    },
    registerMenuCommand(name, fn) {
      try {
        if (typeof GM_registerMenuCommand === 'function') return GM_registerMenuCommand(name, fn);
        if (typeof GM !== 'undefined' && typeof GM.registerMenuCommand === 'function') return GM.registerMenuCommand(name, fn);
      } catch (_) {}
      return undefined;
    },
    request(details) {
      if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(details);
      if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') return GM.xmlHttpRequest(details);

      return fetch(details.url, {
        method: details.method || 'GET',
        headers: details.headers || {},
        body: details.data
      }).then(async (res) => {
        details.onload?.({ status: res.status, responseText: await res.text() });
      }).catch((err) => details.onerror?.(err));
    }
  };

  const settings = {
    enabled: gm.getValue('lt_enabled', true),
    language: gm.getValue('lt_language', 'auto'),
    preferredVariants: gm.getValue('lt_variants', 'en-US,en-GB,de-DE'),
    picky: gm.getValue('lt_picky', false)
  };

  let root;
  let panel;
  let button;
  let statusEl;
  let textBox;
  let resultsEl;
  let lastMatches = [];

  gm.addStyle(`
    #cogwrite-root {
      position: fixed !important;
      top: 86px !important;
      right: 18px !important;
      z-index: 2147483647 !important;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
      color: #f8fafc !important;
    }
    #cogwrite-root button,
    #cogwrite-root textarea {
      font: inherit !important;
    }
    #cogwrite-button {
      width: 48px !important;
      height: 48px !important;
      border-radius: 50% !important;
      border: 1px solid #334155 !important;
      background: #111827 !important;
      color: #ffffff !important;
      box-shadow: 0 10px 28px rgba(15, 23, 42, 0.32) !important;
      cursor: pointer !important;
      font-weight: 700 !important;
      font-size: 14px !important;
      display: flex !important;
      align-items: center !important;
      justify-content: center !important;
      user-select: none !important;
    }
    #cogwrite-button.cogwrite-has-issues {
      background: #991b1b !important;
      border-color: #fecaca !important;
    }
    #cogwrite-panel {
      display: none !important;
      position: absolute !important;
      right: 0 !important;
      top: 58px !important;
      width: 360px !important;
      max-width: calc(100vw - 36px) !important;
      max-height: 520px !important;
      overflow: hidden !important;
      border: 1px solid #334155 !important;
      border-radius: 8px !important;
      background: #0f172a !important;
      box-shadow: 0 18px 48px rgba(15, 23, 42, 0.38) !important;
    }
    #cogwrite-panel.cogwrite-open {
      display: block !important;
    }
    .cogwrite-header {
      display: flex !important;
      align-items: center !important;
      justify-content: space-between !important;
      padding: 10px 12px !important;
      border-bottom: 1px solid #334155 !important;
      font-weight: 700 !important;
    }
    .cogwrite-header button {
      background: transparent !important;
      border: 0 !important;
      color: #cbd5e1 !important;
      cursor: pointer !important;
      font-size: 16px !important;
      padding: 2px 6px !important;
    }
    .cogwrite-body {
      padding: 10px !important;
    }
    #cogwrite-status {
      color: #cbd5e1 !important;
      font-size: 12px !important;
      line-height: 1.4 !important;
      margin-bottom: 8px !important;
    }
    .cogwrite-actions {
      display: flex !important;
      gap: 8px !important;
      margin-bottom: 10px !important;
    }
    .cogwrite-action {
      border: 0 !important;
      border-radius: 6px !important;
      background: #2563eb !important;
      color: #ffffff !important;
      cursor: pointer !important;
      padding: 7px 10px !important;
      font-size: 12px !important;
      white-space: nowrap !important;
    }
    .cogwrite-action.secondary {
      background: #334155 !important;
    }
    #cogwrite-text {
      width: 100% !important;
      min-height: 92px !important;
      box-sizing: border-box !important;
      resize: vertical !important;
      border-radius: 6px !important;
      border: 1px solid #475569 !important;
      background: #020617 !important;
      color: #f8fafc !important;
      padding: 8px !important;
      margin-bottom: 10px !important;
      outline: none !important;
    }
    #cogwrite-results {
      max-height: 260px !important;
      overflow-y: auto !important;
    }
    .cogwrite-issue {
      border-top: 1px solid #334155 !important;
      padding: 9px 0 !important;
    }
    .cogwrite-message {
      font-weight: 700 !important;
      margin-bottom: 5px !important;
    }
    .cogwrite-context {
      color: #cbd5e1 !important;
      overflow-wrap: anywhere !important;
      margin-bottom: 7px !important;
    }
    .cogwrite-suggestions {
      display: flex !important;
      flex-wrap: wrap !important;
      gap: 6px !important;
    }
    .cogwrite-suggestion {
      border: 0 !important;
      border-radius: 6px !important;
      background: #334155 !important;
      color: #ffffff !important;
      cursor: pointer !important;
      padding: 4px 7px !important;
      font-size: 12px !important;
    }
  `);

  function isEditable(el) {
    return Boolean(
      el &&
      el.nodeType === Node.ELEMENT_NODE &&
      el.matches?.(EDITABLE_SELECTOR) &&
      !el.disabled &&
      !el.readOnly
    );
  }

  function getEditableText(el) {
    if (!isEditable(el)) return '';
    if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return el.value || '';
    return el.innerText || el.textContent || '';
  }

  function getSelectedOrFocusedText() {
    const selection = String(window.getSelection?.() || '').trim();
    if (selection) return selection;

    const active = document.activeElement;
    const focusedText = getEditableText(active).trim();
    if (focusedText) return focusedText;

    return '';
  }

  function setStatus(message) {
    statusEl.textContent = message;
  }

  function setOpen(open) {
    panel.classList.toggle('cogwrite-open', open);
  }

  function setButtonState(matches, checking = false) {
    button.classList.toggle('cogwrite-has-issues', matches.length > 0);
    button.textContent = checking ? '...' : matches.length ? String(matches.length) : 'CW';
  }

  function checkText(text) {
    const value = String(text || '').trim();
    if (!value) {
      lastMatches = [];
      setButtonState(lastMatches);
      setStatus('Paste text here, select text in the page, or click into a normal text field.');
      renderResults('', []);
      return;
    }

    const body = new URLSearchParams();
    body.set('text', value.slice(0, MAX_TEXT_CHARS));
    body.set('language', settings.language);
    if (settings.language === 'auto') body.set('preferredVariants', settings.preferredVariants);
    if (settings.picky) body.set('level', 'picky');

    setStatus('Checking with LanguageTool...');
    setButtonState(lastMatches, true);

    gm.request({
      method: 'POST',
      url: API_URL,
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      data: body.toString(),
      onload(res) {
        if (res.status !== 200) {
          setStatus(`LanguageTool API error ${res.status}`);
          setButtonState(lastMatches);
          return;
        }

        try {
          const data = JSON.parse(res.responseText);
          lastMatches = data.matches || [];
          setStatus(`${lastMatches.length} issue${lastMatches.length === 1 ? '' : 's'} found.`);
          renderResults(value, lastMatches);
          setButtonState(lastMatches);
        } catch (error) {
          setStatus(error.message);
          setButtonState(lastMatches);
        }
      },
      onerror() {
        setStatus('Network error contacting LanguageTool.');
        setButtonState(lastMatches);
      }
    });
  }

  function renderResults(text, matches) {
    resultsEl.textContent = '';

    if (!matches.length) {
      const empty = document.createElement('div');
      empty.className = 'cogwrite-empty';
      empty.textContent = text ? 'No issues found.' : 'No text checked yet.';
      resultsEl.appendChild(empty);
      return;
    }

    for (const match of matches) {
      const issue = document.createElement('div');
      issue.className = 'cogwrite-issue';

      const ctxStart = Math.max(0, match.offset - 24);
      const ctxEnd = Math.min(text.length, match.offset + match.length + 24);

      const message = document.createElement('div');
      message.className = 'cogwrite-message';
      message.textContent = match.message;
      issue.appendChild(message);

      const context = document.createElement('div');
      context.className = 'cogwrite-context';
      context.appendChild(document.createTextNode(text.slice(ctxStart, match.offset)));
      const badText = document.createElement('b');
      badText.textContent = text.slice(match.offset, match.offset + match.length);
      context.appendChild(badText);
      context.appendChild(document.createTextNode(text.slice(match.offset + match.length, ctxEnd)));
      issue.appendChild(context);

      const suggestions = document.createElement('div');
      suggestions.className = 'cogwrite-suggestions';

      for (const replacement of (match.replacements || []).slice(0, 5)) {
        const suggestion = document.createElement('button');
        suggestion.type = 'button';
        suggestion.className = 'cogwrite-suggestion';
        suggestion.textContent = replacement.value;
        suggestion.addEventListener('click', () => {
          textBox.value = textBox.value.slice(0, match.offset) +
            replacement.value +
            textBox.value.slice(match.offset + match.length);
          checkText(textBox.value);
        });
        suggestions.appendChild(suggestion);
      }

      issue.appendChild(suggestions);
      resultsEl.appendChild(issue);
    }
  }

  function createUi() {
    if (!settings.enabled || root || !document.documentElement) return;

    root = document.createElement('div');
    root.id = 'cogwrite-root';

    panel = document.createElement('div');
    panel.id = 'cogwrite-panel';

    const header = document.createElement('div');
    header.className = 'cogwrite-header';

    const title = document.createElement('span');
    title.textContent = 'CogWrite';

    const closeButton = document.createElement('button');
    closeButton.type = 'button';
    closeButton.setAttribute('aria-label', 'Close');
    closeButton.textContent = 'x';

    header.append(title, closeButton);

    const body = document.createElement('div');
    body.className = 'cogwrite-body';

    statusEl = document.createElement('div');
    statusEl.id = 'cogwrite-status';
    statusEl.textContent = 'Paste text here or select text in the document.';

    const actions = document.createElement('div');
    actions.className = 'cogwrite-actions';

    const grabButton = document.createElement('button');
    grabButton.type = 'button';
    grabButton.className = 'cogwrite-action';
    grabButton.dataset.action = 'grab';
    grabButton.textContent = 'Use selected text';

    const checkButton = document.createElement('button');
    checkButton.type = 'button';
    checkButton.className = 'cogwrite-action';
    checkButton.dataset.action = 'check';
    checkButton.textContent = 'Check';

    const clearButton = document.createElement('button');
    clearButton.type = 'button';
    clearButton.className = 'cogwrite-action secondary';
    clearButton.dataset.action = 'clear';
    clearButton.textContent = 'Clear';

    actions.append(grabButton, checkButton, clearButton);

    textBox = document.createElement('textarea');
    textBox.id = 'cogwrite-text';
    textBox.placeholder = 'Paste or type text to check';

    resultsEl = document.createElement('div');
    resultsEl.id = 'cogwrite-results';

    body.append(statusEl, actions, textBox, resultsEl);
    panel.append(header, body);

    button = document.createElement('button');
    button.type = 'button';
    button.id = 'cogwrite-button';
    button.title = 'CogWrite';
    button.textContent = 'CW';

    root.append(panel, button);

    document.documentElement.appendChild(root);

    button.addEventListener('click', () => setOpen(!panel.classList.contains('cogwrite-open')));
    closeButton.addEventListener('click', () => setOpen(false));
    grabButton.addEventListener('click', () => {
      const text = getSelectedOrFocusedText();
      textBox.value = text;
      setStatus(text ? 'Loaded text. Press Check.' : 'No selectable or focused text found. Paste text below.');
    });
    checkButton.addEventListener('click', () => checkText(textBox.value));
    clearButton.addEventListener('click', () => {
      textBox.value = '';
      lastMatches = [];
      setButtonState(lastMatches);
      setStatus('Cleared.');
      renderResults('', []);
    });

    renderResults('', []);
  }

  gm.registerMenuCommand(settings.enabled ? 'Disable CogWrite' : 'Enable CogWrite', () => {
    settings.enabled = !settings.enabled;
    gm.setValue('lt_enabled', settings.enabled);
    location.reload();
  });

  gm.registerMenuCommand('CogWrite language: auto', () => {
    settings.language = 'auto';
    gm.setValue('lt_language', 'auto');
  });

  gm.registerMenuCommand('CogWrite language: en-US', () => {
    settings.language = 'en-US';
    gm.setValue('lt_language', 'en-US');
  });

  gm.registerMenuCommand('Toggle CogWrite picky mode', () => {
    settings.picky = !settings.picky;
    gm.setValue('lt_picky', settings.picky);
    alert(`Picky mode: ${settings.picky ? 'on' : 'off'}`);
  });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', createUi, { once: true });
  } else {
    createUi();
  }
})();