GitHub Watch Later

Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GitHub Watch Later
// @namespace    https://github.com/hanmi255/github-WatchLater
// @version      0.1.1
// @description  Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.
// @author       hanmi255
// @match        https://github.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @license      MIT
// @icon         https://github.githubassets.com/pinned-octocat.svg
// @homepageURL  https://github.com/hanmi255/github-WatchLater
// @supportURL   https://github.com/hanmi255/github-WatchLater/issues
// ==/UserScript==

(function () {
  "use strict";

  // ── Storage ──────────────────────────────────────────────────────────────

  const STORAGE_KEY = "gwl_data";

  function loadData() {
    const raw = GM_getValue(STORAGE_KEY, null);
    if (raw) return JSON.parse(raw);
    return { version: 1, repos: [], settings: { sortBy: "addedAt_desc" } };
  }

  function saveData(data) {
    GM_setValue(STORAGE_KEY, JSON.stringify(data));
  }

  function getRepos() {
    return loadData().repos;
  }

  function isAdded(id) {
    return getRepos().some((r) => r.id === id);
  }

  function addRepo(repo) {
    const data = loadData();
    if (!data.repos.some((r) => r.id === repo.id)) {
      data.repos.unshift(repo);
      saveData(data);
    }
  }

  function removeRepo(id) {
    const data = loadData();
    data.repos = data.repos.filter((r) => r.id !== id);
    saveData(data);
  }

  // ── Page detection ────────────────────────────────────────────────────────

  const RESERVED = new Set([
    "features",
    "topics",
    "explore",
    "marketplace",
    "settings",
    "notifications",
    "pulls",
    "issues",
    "login",
    "join",
    "organizations",
    "orgs",
    "sponsors",
    "about",
    "pricing",
    "contact",
    "security",
    "enterprise",
    "readme",
    "new",
    "codespaces",
    "gist",
    "discussions",
  ]);

  function getRepoInfo() {
    const parts = location.pathname.split("/").filter(Boolean);
    if (parts.length < 2) return null;
    if (RESERVED.has(parts[0])) return null;
    const owner = parts[0];
    const name = parts[1];
    if (
      [
        "repositories",
        "projects",
        "packages",
        "stars",
        "followers",
        "following",
      ].includes(name)
    )
      return null;

    const descEl = document.querySelector('meta[name="description"]');
    const description = descEl
      ? descEl.content.replace(/\s*\d+[\s\S]*$/, "").trim()
      : "";

    return {
      id: `${owner}/${name}`,
      owner,
      name,
      fullName: `${owner}/${name}`,
      url: `https://github.com/${owner}/${name}`,
      description,
      addedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      tags: [],
      note: "",
      status: "unread",
    };
  }

  // ── Styles ────────────────────────────────────────────────────────────────

  GM_addStyle(`
    .gwl-btn {
      display: inline-flex; align-items: center; gap: 4px;
      padding: 3px 12px; font-size: 12px; font-weight: 500; line-height: 20px;
      color: var(--fgColor-default, #24292f);
      background-color: var(--bgColor-default, #f6f8fa);
      border: 1px solid var(--borderColor-default, rgba(31,35,40,0.15));
      border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
    }
    .gwl-btn:hover { background-color: var(--bgColor-muted, #eaeef2); }
    .gwl-btn.gwl-added {
      color: var(--fgColor-success, #1a7f37);
      border-color: var(--borderColor-success, rgba(31,136,61,0.4));
      background-color: var(--bgColor-success-subtle, #dafbe1);
    }
    .gwl-btn.gwl-added:hover { background-color: #c6efce; }

    .gwl-overlay {
      position: fixed; inset: 0; background: rgba(0,0,0,0.4);
      z-index: 99998; display: flex; align-items: flex-start; justify-content: flex-end;
    }
    .gwl-panel {
      width: 420px; max-width: 100vw; height: 100vh;
      background: var(--bgColor-default, #ffffff);
      border-left: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; flex-direction: column;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
      font-size: 14px; color: var(--fgColor-default, #24292f);
      overflow: hidden; z-index: 99999;
    }
    .gwl-panel-header {
      padding: 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
    }
    .gwl-panel-header h2 { margin: 0; font-size: 16px; font-weight: 600; }
    .gwl-close-btn {
      background: none; border: none; cursor: pointer; padding: 4px;
      color: var(--fgColor-muted, #57606a); font-size: 18px; line-height: 1;
    }
    .gwl-close-btn:hover { color: var(--fgColor-default, #24292f); }
    .gwl-controls {
      padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; flex-direction: column; gap: 8px; flex-shrink: 0;
    }
    .gwl-search {
      width: 100%; padding: 5px 10px; font-size: 14px;
      border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;
      background: var(--bgColor-default, #fff); color: var(--fgColor-default, #24292f);
      box-sizing: border-box;
    }
    .gwl-search:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.3); }
    .gwl-row { display: flex; gap: 8px; }
    .gwl-select {
      flex: 1; padding: 5px 8px; font-size: 12px;
      border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;
      background: var(--bgColor-default, #fff); color: var(--fgColor-default, #24292f);
    }
    .gwl-list { flex: 1; overflow-y: auto; padding: 8px 0; }
    .gwl-item {
      padding: 12px 16px;
      border-bottom: 1px solid var(--borderColor-muted, #eaeef2);
    }
    .gwl-item:last-child { border-bottom: none; }
    .gwl-item-title {
      font-weight: 600; font-size: 14px; color: #0969da;
      text-decoration: none; display: block; margin-bottom: 4px;
    }
    .gwl-item-title:hover { text-decoration: underline; }
    .gwl-item-meta { font-size: 12px; color: var(--fgColor-muted, #57606a); margin-bottom: 4px; }
    .gwl-item-desc {
      font-size: 12px; color: var(--fgColor-muted, #57606a); margin-bottom: 8px;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
    }
    .gwl-item-actions { display: flex; gap: 6px; }
    .gwl-action-btn {
      padding: 2px 10px; font-size: 12px; border-radius: 6px; cursor: pointer;
      border: 1px solid var(--borderColor-default, #d0d7de);
      background: var(--bgColor-default, #f6f8fa); color: var(--fgColor-default, #24292f);
      text-decoration: none; display: inline-flex; align-items: center;
    }
    .gwl-action-btn:hover { background: var(--bgColor-muted, #eaeef2); }
    .gwl-action-btn.danger { color: #cf222e; border-color: rgba(207,34,46,0.4); }
    .gwl-action-btn.danger:hover { background: #ffebe9; }
    .gwl-empty {
      padding: 40px 16px; text-align: center;
      color: var(--fgColor-muted, #57606a); font-size: 14px;
    }
    .gwl-empty p { margin: 8px 0; }
    .gwl-footer {
      padding: 12px 16px; border-top: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; align-items: center;
    }
    .gwl-count { font-size: 12px; color: var(--fgColor-muted, #57606a); margin-left: auto; }
    .gwl-toast {
      position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
      background: var(--bgColor-emphasis, #24292f); color: #fff;
      padding: 8px 16px; border-radius: 6px; font-size: 13px; z-index: 100000;
      animation: gwl-fadein 0.2s ease;
    }
    @keyframes gwl-fadein {
      from { opacity: 0; transform: translateX(-50%) translateY(8px); }
      to   { opacity: 1; transform: translateX(-50%) translateY(0); }
    }
    .gwl-fab {
      position: fixed; bottom: 24px; right: 24px; z-index: 99997;
      padding: 5px 12px; font-size: 12px; font-weight: 500; line-height: 20px;
      color: var(--fgColor-default, #24292f);
      background-color: var(--bgColor-default, #f6f8fa);
      border: 1px solid var(--borderColor-default, rgba(31,35,40,0.15));
      border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
    }
    .gwl-fab:hover { background-color: var(--bgColor-muted, #eaeef2); }
  `);

  // ── Toast ─────────────────────────────────────────────────────────────────

  function showToast(msg) {
    const el = document.createElement("div");
    el.className = "gwl-toast";
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 2000);
  }

  // ── Later Button ──────────────────────────────────────────────────────────

  function updateBtn(btn, added) {
    btn.className = "gwl-btn" + (added ? " gwl-added" : "");
    btn.textContent = added ? "Later ✓" : "Later";
    btn.title = added ? "Remove from Watch Later" : "Add to Watch Later";
  }

  function injectLaterBtn() {
    const repoInfo = getRepoInfo();
    if (!repoInfo) return;

    const existing = document.getElementById("gwl-later-btn");
    if (existing) {
      updateBtn(existing, isAdded(repoInfo.id));
      return;
    }

    const targets = [
      () =>
        document.querySelector("#repository-details-container .d-flex.gap-2"),
      () => document.querySelector(".pagehead-actions"),
      () => {
        const star =
          document.querySelector('[data-hydro-click*="star"]') ||
          document.querySelector('button[aria-label*="Star"]') ||
          document.querySelector(".starring-container");
        return star ? star.closest("li, div.d-flex, div.BtnGroup") : null;
      },
    ];

    let container = null;
    for (const fn of targets) {
      try {
        container = fn();
      } catch (_) {}
      if (container) break;
    }
    if (!container) return;

    const btn = document.createElement("button");
    btn.id = "gwl-later-btn";
    updateBtn(btn, isAdded(repoInfo.id));

    btn.addEventListener("click", () => {
      const info = getRepoInfo() || repoInfo;
      if (isAdded(info.id)) {
        removeRepo(info.id);
        updateBtn(btn, false);
        showToast("Removed from Later");
      } else {
        addRepo(info);
        updateBtn(btn, true);
        showToast("Added to Later");
      }
    });

    if (container.tagName === "UL") {
      const li = document.createElement("li");
      li.appendChild(btn);
      container.appendChild(li);
    } else {
      container.appendChild(btn);
    }
  }

  // ── Panel ─────────────────────────────────────────────────────────────────

  let panelOpen = false;

  function openPanel() {
    if (panelOpen) return;
    panelOpen = true;

    const overlay = document.createElement("div");
    overlay.className = "gwl-overlay";

    const panel = document.createElement("div");
    panel.className = "gwl-panel";

    // Header
    const header = document.createElement("div");
    header.className = "gwl-panel-header";
    const h2 = document.createElement("h2");
    h2.textContent = "GitHub Watch Later";
    const headerRight = document.createElement("div");
    headerRight.style.cssText = "display:flex;gap:4px;align-items:center";

    const refreshBtn = document.createElement("button");
    refreshBtn.className = "gwl-close-btn";
    refreshBtn.title = "Refresh";
    refreshBtn.innerHTML = "&#8635;";
    refreshBtn.addEventListener("click", render);

    const closeBtn = document.createElement("button");
    closeBtn.className = "gwl-close-btn";
    closeBtn.textContent = "×";
    closeBtn.addEventListener("click", close);

    headerRight.appendChild(refreshBtn);
    headerRight.appendChild(closeBtn);
    header.appendChild(h2);
    header.appendChild(headerRight);

    // Controls
    const controls = document.createElement("div");
    controls.className = "gwl-controls";

    const search = document.createElement("input");
    search.className = "gwl-search";
    search.type = "text";
    search.placeholder = "Search by repo or owner...";

    const sortSel = document.createElement("select");
    sortSel.className = "gwl-select";
    [
      ["addedAt_desc", "Newest first"],
      ["addedAt_asc", "Oldest first"],
      ["name_asc", "Name A→Z"],
      ["name_desc", "Name Z→A"],
    ].forEach(([v, t]) => {
      const o = document.createElement("option");
      o.value = v;
      o.textContent = t;
      sortSel.appendChild(o);
    });
    sortSel.value = loadData().settings.sortBy;

    controls.appendChild(search);
    controls.appendChild(sortSel);

    // List
    const list = document.createElement("div");
    list.className = "gwl-list";

    // Footer
    const footer = document.createElement("div");
    footer.className = "gwl-footer";

    const exportBtn = document.createElement("button");
    exportBtn.className = "gwl-action-btn";
    exportBtn.textContent = "Export";
    exportBtn.addEventListener("click", exportData);

    const importBtn = document.createElement("button");
    importBtn.className = "gwl-action-btn";
    importBtn.textContent = "Import";

    const importInput = document.createElement("input");
    importInput.type = "file";
    importInput.accept = ".json";
    importInput.style.display = "none";
    importInput.addEventListener("change", (e) => {
      importData(e);
      render();
    });
    importBtn.addEventListener("click", () => importInput.click());

    const clearBtn = document.createElement("button");
    clearBtn.className = "gwl-action-btn danger";
    clearBtn.textContent = "Clear All";
    clearBtn.addEventListener("click", () => {
      if (
        !confirm("Are you sure you want to clear all Watch Later repositories?")
      )
        return;
      const data = loadData();
      data.repos = [];
      saveData(data);
      render();
      showToast("Cleared all repositories");
    });

    const countEl = document.createElement("span");
    countEl.className = "gwl-count";

    footer.appendChild(exportBtn);
    footer.appendChild(importBtn);
    footer.appendChild(importInput);
    footer.appendChild(clearBtn);
    footer.appendChild(countEl);

    panel.appendChild(header);
    panel.appendChild(controls);
    panel.appendChild(list);
    panel.appendChild(footer);
    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) close();
    });
    const onKey = (e) => {
      if (e.key === "Escape") close();
    };
    document.addEventListener("keydown", onKey);

    function render() {
      const keyword = search.value.trim().toLowerCase();
      const sortBy = sortSel.value;

      let repos = getRepos();

      if (keyword)
        repos = repos.filter(
          (r) =>
            r.fullName.toLowerCase().includes(keyword) ||
            r.owner.toLowerCase().includes(keyword) ||
            r.name.toLowerCase().includes(keyword) ||
            (r.description || "").toLowerCase().includes(keyword),
        );

      repos = [...repos].sort((a, b) => {
        switch (sortBy) {
          case "addedAt_asc":
            return new Date(a.addedAt) - new Date(b.addedAt);
          case "name_asc":
            return a.fullName.localeCompare(b.fullName);
          case "name_desc":
            return b.fullName.localeCompare(a.fullName);
          default:
            return new Date(b.addedAt) - new Date(a.addedAt);
        }
      });

      countEl.textContent = `${repos.length} repo${repos.length !== 1 ? "s" : ""}`;
      list.innerHTML = "";

      if (repos.length === 0) {
        const empty = document.createElement("div");
        empty.className = "gwl-empty";
        empty.innerHTML =
          getRepos().length === 0
            ? '<p>No repositories saved yet.</p><p>Click "Later" on any GitHub repository to save it here.</p>'
            : "<p>No matching repositories found.</p>";
        list.appendChild(empty);
        return;
      }

      repos.forEach((repo) => {
        const item = document.createElement("div");
        item.className = "gwl-item";

        const title = document.createElement("a");
        title.className = "gwl-item-title";
        title.href = repo.url;
        title.target = "_blank";
        title.rel = "noopener noreferrer";
        title.textContent = repo.fullName;

        const meta = document.createElement("div");
        meta.className = "gwl-item-meta";
        meta.textContent = `added ${new Date(repo.addedAt).toLocaleDateString()}`;

        const actions = document.createElement("div");
        actions.className = "gwl-item-actions";

        const openBtn = document.createElement("a");
        openBtn.className = "gwl-action-btn";
        openBtn.href = repo.url;
        openBtn.target = "_blank";
        openBtn.rel = "noopener noreferrer";
        openBtn.textContent = "Open";

        const removeBtn = document.createElement("button");
        removeBtn.className = "gwl-action-btn danger";
        removeBtn.textContent = "Remove";
        removeBtn.addEventListener("click", () => {
          removeRepo(repo.id);
          render();
          const btn = document.getElementById("gwl-later-btn");
          const info = getRepoInfo();
          if (btn && info && info.id === repo.id) updateBtn(btn, false);
          showToast("Removed from Later");
        });

        actions.appendChild(openBtn);
        actions.appendChild(removeBtn);
        item.appendChild(title);
        item.appendChild(meta);
        if (repo.description) {
          const desc = document.createElement("div");
          desc.className = "gwl-item-desc";
          desc.textContent = repo.description;
          item.appendChild(desc);
        }
        item.appendChild(actions);
        list.appendChild(item);
      });
    }

    sortSel.addEventListener("change", () => {
      const data = loadData();
      data.settings.sortBy = sortSel.value;
      saveData(data);
      render();
    });

    let searchTimer;
    search.addEventListener("input", () => {
      clearTimeout(searchTimer);
      searchTimer = setTimeout(render, 200);
    });

    render();

    function close() {
      document.removeEventListener("keydown", onKey);
      overlay.remove();
      panelOpen = false;
    }
  }

  // ── Import / Export ───────────────────────────────────────────────────────

  function exportData() {
    const blob = new Blob([JSON.stringify(loadData(), null, 2)], {
      type: "application/json",
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `github-watch-later-${new Date().toISOString().slice(0, 10)}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }

  function importData(e) {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const imported = JSON.parse(ev.target.result);
        if (!Array.isArray(imported.repos)) throw new Error();
        const data = loadData();
        const ids = new Set(data.repos.map((r) => r.id));
        let added = 0;
        imported.repos.forEach((r) => {
          if (!ids.has(r.id)) {
            data.repos.push(r);
            added++;
          }
        });
        saveData(data);
        showToast(`Imported ${added} new repo${added !== 1 ? "s" : ""}`);
      } catch (_) {
        showToast("Import failed: invalid file");
      }
    };
    reader.readAsText(file);
    e.target.value = "";
  }

  // ── Keyboard shortcut + event bridge ─────────────────────────────────────

  document.addEventListener('gwl:open-panel', openPanel);

  let bgnLoaded = false;
  document.addEventListener('bgn:loaded', () => {
    bgnLoaded = true;
    const fab = document.getElementById('gwl-fab');
    if (fab) fab.remove();
  });

  function injectFab() {
    if (bgnLoaded) return;
    if (document.getElementById('gwl-fab')) return;
    const btn = document.createElement('button');
    btn.id = 'gwl-fab';
    btn.className = 'gwl-fab';
    btn.title = 'Later (Alt+L)';
    btn.textContent = 'Later';
    btn.addEventListener('click', openPanel);
    document.body.appendChild(btn);
  }

  document.addEventListener('keydown', e => {
    if (e.altKey && e.key === 'l') openPanel();
  });

  // ── MutationObserver + URL change detection ───────────────────────────────

  let lastUrl = location.href;

  function onPageChange() {
    injectFab();
    injectLaterBtn();
  }

  const observer = new MutationObserver(() => {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      setTimeout(onPageChange, 300);
    } else {
      if (!document.getElementById("gwl-later-btn")) injectLaterBtn();
      if (!bgnLoaded && !document.getElementById("gwl-fab")) injectFab();
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });

  onPageChange();
})();