GitHub Watch Later

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

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         GitHub Watch Later
// @namespace    https://github.com/hanmi255/github-WatchLater
// @version      0.2.0
// @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-action-item { list-style: none; display: flex; align-items: center; }
    .gwl-action-slot { display: inline-flex; align-items: center; margin-left: 8px; }

    .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";
  }

  // GitHub has changed the repository action markup several times. Keep the
  // selectors ordered from the most specific/stable containers to semantic
  // fallbacks so a layout change does not silently remove the button.
  function isActionLabel(el) {
    const label = [
      el.getAttribute && el.getAttribute("aria-label"),
      el.getAttribute && el.getAttribute("title"),
      el.textContent,
    ]
      .filter(Boolean)
      .join(" ")
      .replace(/\s+/g, " ")
      .trim();
    return /\b(?:unwatch|watching|watch|fork|starred|star)\b/i.test(label);
  }

  function containsNativeAction(el) {
    try {
      return Array.from(
        el.querySelectorAll("button, a, summary, [role='button']")
      ).some(isActionLabel);
    } catch (_) {
      return false;
    }
  }

  function findActionContainer() {
    const selectors = [
      "#repository-details-container ul.pagehead-actions",
      "#repository-details-container ul",
      "#repository-details-container [data-component='ActionBar']",
      "#repository-details-container [data-component='ButtonGroup']",
      "#repository-details-container .BtnGroup",
      "#repository-details-container .ButtonGroup",
      "ul.pagehead-actions",
      "[data-testid='repository-header'] [role='toolbar']",
      "[data-testid='repository-header'] [role='group']",
      "[data-testid='repository-header'] [data-component='ActionBar']",
      "#js-repo-pjax-container [data-component='ActionBar']",
      "main [data-component='ActionBar']",
      "main [role='toolbar']",
    ];

    for (const selector of selectors) {
      try {
        const candidate = document.querySelector(selector);
        if (!candidate) continue;
        // Generic containers are accepted only when they actually contain a
        // native repository action; this avoids attaching to an unrelated
        // list or toolbar that happens to live in the repository header.
        const isKnownActionList =
          selector === "#repository-details-container ul.pagehead-actions" ||
          selector === "ul.pagehead-actions";
        if (!isKnownActionList && !containsNativeAction(candidate)) {
          continue;
        }
        return candidate;
      } catch (_) {}
    }

    // Last resort: locate the native Watch/Fork/Star control and use its
    // nearest action group. This survives class and spacing utility changes.
    const roots = [
      document.querySelector("#repository-details-container"),
      document.querySelector("[data-testid='repository-header']"),
      document.querySelector("#js-repo-pjax-container"),
      document.querySelector("main"),
    ].filter(Boolean);
    for (const root of roots) {
      let controls = [];
      try {
        controls = Array.from(
          root.querySelectorAll("button, a, summary, [role='button']")
        );
      } catch (_) {}
      const nativeControl = controls.find(isActionLabel);
      if (!nativeControl) continue;

      const group = nativeControl.closest(
        "ul, ol, li, [role='toolbar'], [role='group'], " +
          "[data-component*='ActionBar'], [data-component*='ButtonGroup'], " +
          ".BtnGroup, .ButtonGroup"
      );
      if (group) {
        return group.tagName === "LI" && group.parentElement
          ? group.parentElement
          : group;
      }

      // Some variants have no recognizable group class at all. The native
      // control's parent is still a safe inline insertion point.
      if (nativeControl.parentElement) return nativeControl.parentElement;
    }

    return null;
  }

  function appendToActionContainer(container, btn) {
    if (/^(UL|OL)$/.test(container.tagName)) {
      const li = document.createElement("li");
      li.className = "gwl-action-item";
      li.appendChild(btn);
      container.appendChild(li);
      return;
    }

    // A semantic fallback can resolve to a single native action's list item.
    // Append a sibling instead of nesting a second action in that item.
    if (container.tagName === "LI" && container.parentElement) {
      const li = document.createElement("li");
      li.className = "gwl-action-item";
      li.appendChild(btn);
      container.parentElement.appendChild(li);
      return;
    }

    const slot = document.createElement("span");
    slot.className = "gwl-action-slot";
    slot.appendChild(btn);
    container.appendChild(slot);
  }

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

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

    const container = findActionContainer();
    if (!container) return;

    const btn = document.createElement("button");
    btn.id = "gwl-later-btn";
    btn.type = "button";
    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");
      }
    });

    appendToActionContainer(container, 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();
})();