GitHub Issue Auto Save History (Issues Textarea Recovery)

自动保存 GitHub issue 文本并记录历史,每 5s 保存一次 (内容一致则不保存)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         GitHub Issue Auto Save History (Issues Textarea Recovery)
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  自动保存 GitHub issue 文本并记录历史,每 5s 保存一次 (内容一致则不保存)
// @author       Jason Feng <[email protected]>
// @license      MIT
// @match        https://github.com/*/issues/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const __DEBUG__SAVE_INFO = true;
  const SAVE_INTERVAL_SEC = 5;
  const SAVE_MAX_LENGTH = 100;
  const STORAGE_KEY = 'github_issue_autosave_current';
  const HISTORY_KEY = 'github_issue_autosave_history';
  const HISTORY_BTN_CLASSNAME = `${HISTORY_KEY}-btn`;
  const POPUP_WIDTH = 540;
  const POPUP_HEIGHT = 300;

  /** ---------- 封装存储层 (兼容 Safari) ---------- */
  function setItem(key, val) {
    try {
      localStorage.setItem(key, JSON.stringify(val));
    } catch (e) {
      GM_setValue(key, val);
    }
  }

  function getItem(key, def = null) {
    try {
      const v = localStorage.getItem(key);
      return v ? JSON.parse(v) : def;
    } catch (e) {
      return GM_getValue(key, def);
    }
  }

  /** 保存当前 textarea 文本到存储(防重复) */
  function saveCurrentText(val) {
    const current = getItem(STORAGE_KEY, '');

    const historyObj = getItem(HISTORY_KEY, {});
    const latestEntry = Object.values(historyObj).slice(-1)[0];

    if (val === current || val === latestEntry) {
      if (__DEBUG__SAVE_INFO) console.log('⚪ SKIP - DUPLICATE');
      return;
    }

    setItem(STORAGE_KEY, val);

    const now = new Date();
    const timeKey = now.toLocaleString();
    historyObj[timeKey] = val;

    if (__DEBUG__SAVE_INFO) {
      console.log(`🟢 SAVE - ${timeKey}\n          ${JSON.stringify(val)}`);
    }

    const entries = Object.entries(historyObj);
    const trimmed = entries.slice(-1 * SAVE_MAX_LENGTH);
    setItem(HISTORY_KEY, Object.fromEntries(trimmed));
  }

  /** 渲染弹窗内容 */
  function renderPopupContent(popup) {
    popup.innerHTML = '';

    const container = document.createElement('div');
    container.style.cssText = `
      overflow: hidden;
      height: ${POPUP_HEIGHT}px;
      padding: 10px;
    `;

    const historyContainer = document.createElement('div');
    historyContainer.style.cssText = `
      display: flex;
      flex-direction: column;
      gap: 8px;
      overflow: auto;
      height: ${POPUP_HEIGHT - 65}px;
    `;

    const historyObj = getItem(HISTORY_KEY, {});
    const historyEntries = Object.entries(historyObj);

    historyEntries.reverse().forEach(([time, text]) => {
      const item = document.createElement('div');
      item.style.cssText = `
        display: flex;
        align-items: center;
        gap: 4px;
      `;

      const useBtn = document.createElement('button');
      useBtn.textContent = time;
      useBtn.style.cssText = `
        padding: 2px 6px;
        cursor: pointer;
      `;
      useBtn.addEventListener('click', () => {
        const textarea = document.querySelector('#react-issue-comment-composer textarea');
        if (!textarea) return;
        textarea.value = text;
        textarea.focus();
      });

      const input = document.createElement('input');
      input.type = 'text';
      input.value = text;
      input.style.cssText = `
        flex: 1;
        width: 100%;
        padding: 2px 4px;
        font-size: 12px;
      `;

      item.appendChild(useBtn);
      item.appendChild(input);
      historyContainer.appendChild(item);
    });

    container.appendChild(historyContainer);

    const writeBtn = document.createElement('button');
    writeBtn.textContent = 'save-current';
    writeBtn.style.cssText = `
      margin-top: 10px;
      padding: 2px 6px;
      cursor: pointer;
    `;
    writeBtn.addEventListener('click', () => {
      const textarea = document.querySelector('#react-issue-comment-composer textarea');
      console.log('save-current', textarea);

      if (!textarea) return;
      saveCurrentText(textarea.value);
      renderPopupContent(popup);
    });
    container.appendChild(writeBtn);

    popup.appendChild(container);
  }

  /** 创建弹窗 */
  function createHistoryPopup(historyBtn) {
    let popup = document.querySelector('#github-issue-history-popup');
    if (!popup) {
      popup = document.createElement('div');
      popup.id = 'github-issue-history-popup';
      popup.style.cssText = `
        position: absolute;
        top: ${historyBtn.offsetTop - POPUP_HEIGHT - 10}px;
        left: ${historyBtn.offsetLeft}px;
        width: ${POPUP_WIDTH}px;
        height: ${POPUP_HEIGHT}px;
        overflow: hidden;
        border-radius: 8px;
        background: #fff;
        border: 1px solid #ccc;
        z-index: 9999;
        font-family: monospace;
        font-size: 12px;
        display: none;
      `;
      historyBtn.parentElement.appendChild(popup);
    }

    historyBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      popup.style.display = popup.style.display === 'none' ? 'block' : 'none';
      popup.style.top = `${historyBtn.offsetTop - POPUP_HEIGHT - 10}px`;
      popup.style.left = `${historyBtn.offsetLeft}px`;
      renderPopupContent(popup);
    });

    document.addEventListener('click', () => {
      if (popup.style.display === 'block') popup.style.display = 'none';
    });

    return popup;
  }

  /** 初始化 */
  function initIssueAutosave() {
    const tablistContainer = document.querySelector('[role="tablist"]');
    if (!tablistContainer) return;
    if (tablistContainer.querySelector(`.${HISTORY_BTN_CLASSNAME}`)) return;

    const historyBtn = document.createElement('button');
    historyBtn.textContent = 'History';
    historyBtn.className = `TabNav-item ViewSwitch-module__tabNavLink--JJGgB ${HISTORY_BTN_CLASSNAME}`;
    historyBtn.style.cssText = `
      border: none;
      background: transparent;
      cursor: pointer;
    `;
    tablistContainer.appendChild(historyBtn);

    createHistoryPopup(historyBtn);

    const textarea = document.querySelector('#react-issue-comment-composer textarea');
    if (textarea) {
      textarea.value = getItem(STORAGE_KEY, '');
    }
  }

  initIssueAutosave();
  document.addEventListener('pjax:end', initIssueAutosave);
  const observer = new MutationObserver(initIssueAutosave);
  observer.observe(document.body, { childList: true, subtree: true });

  setInterval(() => {
    const textarea = document.querySelector('#react-issue-comment-composer textarea');
    if (!textarea) return;
    saveCurrentText(textarea.value);
  }, SAVE_INTERVAL_SEC * 1000);
})();