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.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

Advertisement:

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

Advertisement:

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