GitHub Freshness fix

通过颜色高亮的方式,帮助你快速判断一个 GitHub 仓库是否在更新。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         GitHub Freshness fix
// @namespace    http://tampermonkey.net/
// @version      1.1.6
// @description  通过颜色高亮的方式,帮助你快速判断一个 GitHub 仓库是否在更新。
// @author       向前 https://docs.rational-stars.top/ https://github.com/rational-stars/GitHub-Freshness https://home.rational-stars.top/
// @license      MIT
// @icon         https://raw.githubusercontent.com/rational-stars/picgo/refs/heads/main/avatar.jpg
// @match        https://github.com/*/*
// @match        https://github.com/search?*
// @match        https://github.com/*/*/tree/*
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @require      https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require      https://cdn.jsdelivr.net/npm/@simonwep/[email protected]/dist/pickr.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/build/global/luxon.min.js
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// ==/UserScript==

/* global luxon, Pickr, Swal, $ */

(function () {
  'use strict';

  // --- Constants & Imports ---
  const DateTime = luxon.DateTime;

  // --- Styles ---
  GM_addStyle(`@import url('https://cdn.jsdelivr.net/npm/@simonwep/[email protected]/dist/themes/classic.min.css');`);
  GM_addStyle(`
      .swal2-popup.swal2-modal.swal2-show {
          color: #FFF;
          border-radius: 20px;
          background: #31b96c;
          box-shadow: 8px 8px 16px #217e49, -8px -8px 16px relentlessly-41f48f;
      }
      #swal2-title a {
          display: inline-block;
          height: 40px;
          margin-right: 10px;
          border-radius: 10px;
          overflow: hidden;
          color: #fff;
      }
      #swal2-title {
          display: flex !important;
          justify-content: center;
          align-items: center;
      }
      .row-box select {
          border: unset;
          border-radius: .15em;
      }
      .row-box {
          display: flex;
          margin: 25px;
          align-items: center;
          justify-content: space-between;
      }
      .row-box .swal2-input {
          height: 40px;
      }
      .row-box label {
          margin-right: 10px;
      }
      .row-box main input {
          background: rgba(15, 172, 83, 1);
          width: 70px;
          border: unset;
          box-shadow: unset;
          text-align: right;
          margin: 0;
      }
      .row-box main {
          display: flex;
          align-items: center;
      }
      /* Custom Badge Styles */
      .freshness-stars { padding: 8px; }
      .freshness-updated { margin-left: 5px; }
      /* Ensure colors are visible against GitHub's default styling */
      .freshness-force-color { color: inherit !important; }
  `);

  const PanelDom = `
      <div class="row-box">
          <label for="THEME-select">主题设置:</label>
          <main>
              <select tabindex="-1" id="THEME-select" class="swal2-input">
                  <option value="light">light</option>
                  <option value="dark">dark</option>
              </select>
          </main>
      </div>
      <div class="row-box">
          <label id="TIME_BOUNDARY-label">时间阈值:</label>
          <main>
              <input id="TIME_BOUNDARY-number" type="number" class="swal2-input" value="" maxlength="3" pattern="\d{1,3}">
              <select tabindex="-1" id="TIME_BOUNDARY-select" class="swal2-input">
                  <option value="day">日</option>
                  <option value="week">周</option>
                  <option value="month">月</option>
                  <option value="year">年</option>
              </select>
          </main>
      </div>
      <div class="row-box">
          <div><label id="BGC-label">背景颜色:</label><input type="checkbox" id="BGC-enabled"></div>
          <main>
              <span id="BGC-highlight-color-value"><div id="BGC-highlight-color-pickr"></div></span>
              <span id="BGC-grey-color-value"><div id="BGC-grey-color-pickr"></div></span>
          </main>
      </div>
      <div class="row-box">
          <div><label id="FONT-label">字体颜色:</label><input type="checkbox" id="FONT-enabled"></div>
          <main>
              <span id="FONT-highlight-color-value"><div id="FONT-highlight-color-pickr"></div></span>
              <span id="FONT-grey-color-value"><div id="FONT-grey-color-pickr"></div></span>
          </main>
      </div>
      <div class="row-box">
          <div><label id="DIR-label">文件夹颜色:</label><input type="checkbox" id="DIR-enabled"></div>
          <main>
              <span id="DIR-highlight-color-value"><div id="DIR-highlight-color-pickr"></div></span>
              <span id="DIR-grey-color-value"><div id="DIR-grey-color-pickr"></div></span>
          </main>
      </div>
      <div class="row-box">
          <div><label id="TIME_FORMAT-label">时间格式化:</label><input type="checkbox" id="TIME_FORMAT-enabled"></div>
      </div>
      <div class="row-box">
           <div><label id="SORT-label">文件排序:</label><input type="checkbox" id="SORT-enabled"></div>
          <main>
              <select tabindex="-1" id="SORT-select" class="swal2-input">
                  <option value="asc">时间正序</option>
                  <option value="desc">时间倒序</option>
              </select>
          </main>
      </div>
      <div class="row-box">
          <label for="CURRENT_THEME-select">当前主题:</label>
          <main>
              <select tabindex="-1" id="CURRENT_THEME-select" class="swal2-input">
                  <option value="auto">auto</option>
                  <option value="light">light</option>
                  <option value="dark">dark</option>
              </select>
          </main>
      </div>
      <div class="row-box">
          <div>
              <label id="AWESOME-label"><a target="_blank" href="https://github.com/settings/tokens">AWESOME token: </a></label>
              <input type="checkbox" id="AWESOME-enabled">
          </div>
          <main>
              <input id="AWESOME_TOKEN" type="password" class="swal2-input" value="">
          </main>
      </div>
      <p style="font-size: 0.9em; opacity: 0.8;">复选框切换需刷新页面生效。</p>
  `;

  // --- Configuration ---
  const default_THEME = {
      BGC: { highlightColor: 'rgba(15, 172, 83, 1)', greyColor: 'rgba(245, 245, 245, 0.24)', isEnabled: true },
      TIME_BOUNDARY: { number: 30, select: 'day' },
      FONT: { highlightColor: 'rgba(252, 252, 252, 1)', greyColor: 'rgba(0, 0, 0, 1)', isEnabled: true },
      DIR: { highlightColor: 'rgba(15, 172, 83, 1)', greyColor: 'rgba(154, 154, 154, 1)', isEnabled: true },
      SORT: { select: 'desc', isEnabled: true },
      AWESOME: { isEnabled: false },
      TIME_FORMAT: { isEnabled: true },
  };

  let CURRENT_THEME = GM_getValue('CURRENT_THEME', 'light');
  let AWESOME_TOKEN = GM_getValue('AWESOME_TOKEN', '');
  let THEME_TYPE = getThemeType();
  const config_JSON = JSON.parse(GM_getValue('config_JSON', JSON.stringify({ light: default_THEME })));
  let THEME = config_JSON[THEME_TYPE] || default_THEME;

  const configPickr = {
      theme: 'monolith',
      components: {
          preview: true, opacity: true, hue: true,
          interaction: { rgba: true, input: true, clear: true, save: true },
      },
  };

  // --- Helper Functions ---

  function getThemeType() {
      let themeType = CURRENT_THEME;
      if (CURRENT_THEME === 'auto') {
          themeType = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
      }
      return themeType;
  }

  function initPickr(el_default) {
      const pickr = Pickr.create({ ...configPickr, ...el_default });
      pickr.on('save', (color, instance) => {
          pickr.hide();
      });
  }

  function getUpdatedThemeConfig() {
      let updatedTheme = {};
      for (const [themeKey, themeVal] of Object.entries(default_THEME)) {
          updatedTheme[themeKey] = {};
          for (let [key, val] of Object.entries(themeVal)) {
              if (key === 'highlightColor' || key === 'greyColor') {
                  const type = key === 'highlightColor' ? 'highlight' : 'grey';
                  val = $(`#${themeKey}-${type}-color-value .pcr-button`).css('--pcr-color');
              } else if (key === 'isEnabled') {
                  val = $(`#${themeKey}-enabled`).prop('checked');
              } else if (key === 'number' || key === 'select') {
                  val = $(`#${themeKey}-${key}`).val();
              }
              updatedTheme[themeKey][key] = val;
          }
      }
      return updatedTheme;
  }

  function handelData(theme) {
      if (!theme) return;
      for (const [themeKey, themeVal] of Object.entries(theme)) {
          for (const [key, val] of Object.entries(themeVal)) {
              if (key === 'highlightColor' || key === 'greyColor') {
                  const type = key === 'highlightColor' ? 'highlight' : 'grey';
                  $(`#${themeKey}-${type}-color-value .pcr-button`).css('--pcr-color', val);
              } else if (key === 'isEnabled') {
                  $(`#${themeKey}-enabled`).prop('checked', val);
              } else if (key === 'number' || key === 'select') {
                  $(`#${themeKey}-${key}`).val(val);
              }
          }
      }
  }

  // --- UI Construction ---

  function createSettingsPanel() {
      Swal.fire({
          title: `GitHub Freshness Settings`,
          html: PanelDom,
          focusConfirm: false,
          preConfirm: () => {
              const updated_THEME = getUpdatedThemeConfig();
              CURRENT_THEME = $('#CURRENT_THEME-select').val();
              AWESOME_TOKEN = $('#AWESOME_TOKEN').val();

              GM_setValue('config_JSON', JSON.stringify({
                  ...config_JSON,
                  [$('#THEME-select').val()]: updated_THEME,
              }));
              GM_setValue('CURRENT_THEME', CURRENT_THEME);
              GM_setValue('AWESOME_TOKEN', AWESOME_TOKEN);

              THEME = updated_THEME;
              GitHub_Freshness(updated_THEME);

              Swal.fire({ position: 'top-center', background: '#4ab96f', icon: 'success', title: 'Saved!', showConfirmButton: false, timer: 800 });
          },
          heightAuto: false,
          showCancelButton: true,
          confirmButtonText: 'Save',
          didOpen: () => {
             initSettings(THEME);
             $('#THEME-select').on('change', function () {
                  let selectedTheme = $(this).val();
                  let theme = config_JSON[selectedTheme] || default_THEME;
                  handelData(theme);
              });
          }
      });
  }

  function initSettings(theme) {
      if (!theme) theme = default_THEME;
      const setupPickr = (id, color) => initPickr({ el: id, default: color });

      setupPickr('#BGC-highlight-color-pickr', theme.BGC.highlightColor);
      setupPickr('#BGC-grey-color-pickr', theme.BGC.greyColor);
      setupPickr('#FONT-highlight-color-pickr', theme.FONT.highlightColor);
      setupPickr('#FONT-grey-color-pickr', theme.FONT.greyColor);
      setupPickr('#DIR-highlight-color-pickr', theme.DIR.highlightColor);
      setupPickr('#DIR-grey-color-pickr', theme.DIR.greyColor);

      $('#THEME-select').val(getThemeType());
      $('#CURRENT_THEME-select').val(CURRENT_THEME);
      $('#AWESOME_TOKEN').val(AWESOME_TOKEN);
      handelData(theme);
  }

  // --- DOM Manipulation Helpers ---

  function setElementBGC(el, BGC, timeResult) {
      if (el.length && BGC.isEnabled) {
          // Use setProperty with 'important' to guarantee override
          el[0].style.setProperty('background-color', timeResult ? BGC.highlightColor : BGC.greyColor, 'important');
      }
  }

  function setElementDIR(el, DIR, timeResult) {
      if (el.length && DIR.isEnabled) {
          const color = timeResult ? DIR.highlightColor : DIR.greyColor;
          // CRITICAL FIX: Use setProperty with 'important' to force color on SVG
          if (el[0]) {
              el[0].style.setProperty('fill', color, 'important');
              el[0].style.setProperty('stroke', color, 'important');
          }
          // Also set attr for maximal compatibility
          el.attr('fill', color);
          el.attr('stroke', color);
      }
  }

  function setElementFONT(el, FONT, timeResult) {
      if (FONT.isEnabled) {
          // CRITICAL FIX: Use setProperty with 'important' to force font color
          el[0].style.setProperty('color', timeResult ? FONT.highlightColor : FONT.greyColor, 'important');
      }
  }

  function setElementTIME_FORMAT(el, TIME_FORMAT, datetime) {
      if (TIME_FORMAT.isEnabled && el.css('display') !== 'none') {
          el.css('display', 'none');
          const formattedDate = formatDate(datetime);
          if (el.parent().find('.formatted-date-span').length === 0) {
              el.before(`<span class="formatted-date-span">${formattedDate}</span>`);
          }
      } else if (!TIME_FORMAT.isEnabled) {
          el.parent().find('.formatted-date-span').remove();
          el.css('display', 'block');
      }
  }

  function formatDate(isoDateString) {
      return DateTime.fromISO(isoDateString).toFormat('yyyy-MM-dd');
  }

  function handelTime(time, time_boundary, type = 'ISO8601') {
      const { number, select } = time_boundary;
      let days = 0;
      switch (select) {
          case 'day': days = number; break;
          case 'week': days = number * 7; break;
          case 'month': days = number * 30; break;
          case 'year': days = number * 365; break;
          default: days = 30;
      }

      const thresholdDate = new Date();
      thresholdDate.setDate(thresholdDate.getDate() - days);

      let inputDate;
      if (type === 'UTC') {
          try {
             const dt = DateTime.fromFormat(time, "yyyy年M月d日 'GMT'Z HH:mm", { zone: 'UTC' }).setZone('Asia/Shanghai');
             inputDate = dt.toJSDate();
          } catch(e) {
             console.error("Error parsing search result date:", e);
             inputDate = new Date(); // Fallback
          }
      } else {
          inputDate = new Date(time);
      }

      return inputDate >= thresholdDate;
  }

  // --- Core Logic ---

  function toAPIUrl(href) {
      const match = href.match(/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)/);
      return match ? `https://api.github.com/repos/${match[1]}/${match[2]}` : null;
  }

  function GitHub_FreshnessAwesome(theme) {
      const observer = new IntersectionObserver((entries) => {
          entries.forEach(el => {
              if (el.isIntersecting && el.target.getAttribute('request') !== 'true') {
                  const href = $(el.target).attr('href');
                  const apiHref = toAPIUrl(href);
                  if(!apiHref) return;

                  el.target.setAttribute('request', 'true'); // Prevent double fetch

                  $.ajax({
                      url: apiHref,
                      method: 'GET',
                      headers: AWESOME_TOKEN ? { 'Authorization': `token ${AWESOME_TOKEN}` } : {},
                      success: function (data) {
                          const timeResult = handelTime(data.updated_at, theme.TIME_BOUNDARY);
                          if (theme.AWESOME.isEnabled) {
                              $(el.target).after(
                                  `<span class="freshness-stars">★${data.stargazers_count}</span>` +
                                  `<span class="freshness-updated">📅${formatDate(data.updated_at)}</span>`
                              );
                              $(el.target).css('padding', '0 12px');
                          }
                          setElementBGC($(el.target), theme.BGC, timeResult);
                          setElementFONT($(el.target), theme.FONT, timeResult);
                      },
                      error: function (err) {
                          if (err.status === 403) console.warn("GitHub API Rate Limit Exceeded");
                      }
                  });
              }
          });
      }, { threshold: 0.5 });

      // FIX: Use highly generic containers for Awesome lists
      $('.Box-row a, .markdown-body a').each(function () {
          if (/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/.test($(this).attr('href'))) {
              observer.observe(this);
          }
      });
  }

  function GitHub_FreshnessSearchPage(theme) {
      // Stable entry point
      const elements = $('relative-time[datetime]');

      if (elements.length === 0) return;

      elements.each(function () {
          const title = $(this).attr('title') || $(this).attr('datetime');
          if (title) {
              const timeResult = handelTime(title, theme.TIME_BOUNDARY, $(this).is('[datetime]') ? 'ISO8601' : 'UTC');

              // FIX: Use most stable search result row container (targeting the card/list item)
              const BGC_element = $(this).closest('div[data-testid*="results-card"], li, article, .Box-row');

              setElementBGC(BGC_element, theme.BGC, timeResult);
              setElementFONT($(this), theme.FONT, timeResult);

              if (theme.TIME_FORMAT.isEnabled) {
                   try {
                       let dt;
                       if($(this).is('[datetime]')) {
                           dt = DateTime.fromISO($(this).attr('datetime'));
                       } else {
                           dt = DateTime.fromFormat(title, "yyyy年M月d日 'GMT'Z HH:mm", { zone: 'UTC' }).setZone('Asia/Shanghai');
                       }
                       if (dt.isValid) $(this).text(dt.toFormat('yyyy-MM-dd'));
                   } catch(e) {}
              }
          }
      });
  }

  function GitHub_Freshness(theme) {
      if (!theme) theme = THEME;
      const matchUrl = isMatchedUrl();
      if (!matchUrl) return;

      if (matchUrl === 'matchSearchPage') {
          return GitHub_FreshnessSearchPage(theme);
      }

      // NEW FIX: Apply background color to the table header row (if found)
      if (theme.BGC.isEnabled) {
          // Look for the header row container which has role="row" and typically contains text like "Latest commit"
          const headerRow = $('div[role="row"]:has(span:contains("Latest commit"))').first();
          if (headerRow.length) {
              // Apply a neutral/grey background color to the entire header row
              headerRow[0].style.setProperty('background-color', theme.BGC.greyColor, 'important');
          }
      }

      // Repo File List Logic (Tree View)
      const timeElements = $('relative-time[datetime]');
      if (timeElements.length === 0) return;

      let trRows = [];

      timeElements.each(function (index) {
          const datetime = $(this).attr('datetime');
          if (datetime) {
              const timeResult = handelTime(datetime, theme.TIME_BOUNDARY);

              // Row Container (Verified working)
              const trElement = $(this).closest('tr, li, div[role="row"], [data-testid*="row"], .Box-row');

              if (trElement.length) {
                  trRows.push(trElement[0]);

                  // BGC FIX (Height Match): Target the last child of the row container (which is the date column)
                  const BGC_element = $(trElement).children().last();

                  // ICON Element
                  const ICON_element = trElement.find('svg').first();

                  setElementBGC(BGC_element, theme.BGC, timeResult); // Applies BGC to the last column
                  setElementDIR(ICON_element, theme.DIR, timeResult); // Applies color to SVG
                  setElementFONT($(this).parent(), theme.FONT, timeResult); // Applies color to date text container

                  setElementTIME_FORMAT($(this), theme.TIME_FORMAT, datetime);
              }
          }
      });

      // Sorting (Verified working)
      if (theme.SORT.isEnabled && trRows.length > 0) {
          trRows.sort((a, b) => {
              const tA = new Date($(a).find('relative-time').attr('datetime'));
              const tB = new Date($(b).find('relative-time').attr('datetime'));
              return theme.SORT.select === 'asc' ? tA - tB : tB - tA;
          });

          const parentContainer = $(trRows[0]).parent();
          if (parentContainer.length) {
              parentContainer.append(trRows);
          }
      }
  }

  function isMatchedUrl() {
      const href = window.location.href;
      if (/^https:\/\/github\.com\/search\?.*$/.test(href)) return 'matchSearchPage';
      if (/^https:\/\/github\.com\/[^/]+\/[^/]+(?:\?.*)?$|^https:\/\/github\.com\/[^/]+\/[^/]+\/tree\/.+$/.test(href)) return 'matchRepoPage';
      return null;
  }

  // --- Initialization & Event Listeners ---

  function debounce(func, wait) {
      let timeout;
      return function (...args) {
          clearTimeout(timeout);
          timeout = setTimeout(() => func.apply(this, args), wait);
      };
  }

  const runScript = debounce(() => {
      GitHub_Freshness();
  }, 350);

  // Initial Load
  $(function() {
      console.log('GitHub Freshness Loaded');
      runScript();
  });

  // Navigation Handling (PJAX, PopState, PushState)
  document.addEventListener('pjax:end', runScript);
  window.addEventListener('popstate', () => setTimeout(runScript, 350));

  // Hook into History API for SPA navigation
  const originalPush = history.pushState;
  const originalReplace = history.replaceState;

  history.pushState = function () {
      originalPush.apply(this, arguments);
      setTimeout(runScript, 350);
  };

  history.replaceState = function () {
      originalReplace.apply(this, arguments);
      setTimeout(runScript, 350);
  };

  // Register Menu
  GM_registerMenuCommand('⚙️ Settings', createSettingsPanel);

  // System Theme Listener
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
      if (CURRENT_THEME === 'auto') {
          THEME = config_JSON[e.matches ? 'dark' : 'light'] || default_THEME;
          GitHub_Freshness(THEME);
      }
  });

})();