AtCoder Bookmark Manager

Bookmark, track, and view AtCoder tasks with persistent GM storage

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AtCoder Bookmark Manager
// @namespace    local.safe
// @version      2.0.1
// @description  Bookmark, track, and view AtCoder tasks with persistent GM storage
// @match        https://atcoder.jp/*
// @grant        GM.getValue
// @grant        GM.setValue
// @license      MIT
// ==/UserScript==
 
(async function () {
  "use strict";
 
  const STORAGE_KEY = "ac_bookmarks_v3";
 
  const STATUS = {
    NONE: "none",
    TODO: "todo",
    DONE: "done",
  };
 
  const STATUS_ORDER = {
    [STATUS.TODO]: 0,
    [STATUS.DONE]: 1,
    [STATUS.NONE]: 2,
  };
 
  let overlayEl = null;
  let overlayListEl = null;
  let overlaySearchEl = null;
  let floatingPanelEl = null;
  let bookmarkBtnEl = null;
  let viewerBtnEl = null;
 
  function isTaskPage() {
    return /^\/contests\/[^/]+\/tasks\/[^/]+$/.test(location.pathname);
  }
 
  function canonicalUrl() {
    return `${location.origin}${location.pathname}`;
  }
 
  function getTaskMeta() {
    const url = canonicalUrl();
    const pathMatch = location.pathname.match(/^\/contests\/([^/]+)\/tasks\/([^/]+)$/);
 
    const contestId = pathMatch?.[1] ?? "";
    const taskId = pathMatch?.[2] ?? "";
 
    const title =
      document.querySelector("h1")?.textContent?.trim() ||
      document.querySelector("h2")?.textContent?.trim() ||
      document.title.replace(/\s*-\s*AtCoder\s*$/, "").trim() ||
      "unknown";
 
    return {
      url,
      title,
      contestId,
      taskId,
    };
  }
 
  async function loadData() {
    const raw = await GM.getValue(STORAGE_KEY, "{}");
 
    if (typeof raw === "string") {
      try {
        const parsed = JSON.parse(raw);
        return parsed && typeof parsed === "object" ? parsed : {};
      } catch {
        return {};
      }
    }
 
    return raw && typeof raw === "object" ? raw : {};
  }
 
  async function saveData(data) {
    await GM.setValue(STORAGE_KEY, JSON.stringify(data));
  }
 
  function getStatusText(status) {
    switch (status) {
      case STATUS.TODO:
        return "★";
      case STATUS.DONE:
        return "✓";
      default:
        return "☆";
    }
  }
 
  function getStatusLabel(status) {
    switch (status) {
      case STATUS.TODO:
        return "todo";
      case STATUS.DONE:
        return "done";
      default:
        return "none";
    }
  }
 
  function nextStatus(status) {
    switch (status) {
      case STATUS.NONE:
        return STATUS.TODO;
      case STATUS.TODO:
        return STATUS.DONE;
      default:
        return STATUS.NONE;
    }
  }
 
  function makeButton(label) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = label;
    btn.style.cssText = `
      width: 40px;
      height: 40px;
      border: none;
      border-radius: 999px;
      background: white;
      box-shadow: 0 2px 10px rgba(0,0,0,0.18);
      font-size: 20px;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      vertical-align: middle;
      user-select: none;
    `;
    return btn;
  }
 
  function ensureFloatingPanel() {
    if (floatingPanelEl) return floatingPanelEl;
 
    const panel = document.createElement("div");
    panel.id = "ac-bookmark-panel";
    panel.style.cssText = `
      position: fixed;
      right: 12px;
      bottom: 82px;
      z-index: 999999;
      display: flex;
      gap: 8px;
      align-items: center;
    `;
 
    bookmarkBtnEl = makeButton("☆");
    bookmarkBtnEl.id = "ac-bookmark-btn";
    bookmarkBtnEl.title = "Bookmark this problem";
 
    viewerBtnEl = document.createElement("button");
    viewerBtnEl.type = "button";
    viewerBtnEl.textContent = "Bookmarks";
    viewerBtnEl.title = "Open bookmark viewer";
    viewerBtnEl.style.cssText = `
      height: 40px;
      padding: 0 12px;
      border: none;
      border-radius: 999px;
      background: black;
      color: white;
      box-shadow: 0 2px 10px rgba(0,0,0,0.18);
      font-size: 14px;
      cursor: pointer;
      user-select: none;
    `;
 
    panel.appendChild(bookmarkBtnEl);
    panel.appendChild(viewerBtnEl);
    document.body.appendChild(panel);
 
    floatingPanelEl = panel;
    return panel;
  }
 
  function ensureOverlay() {
    if (overlayEl) return overlayEl;
 
    overlayEl = document.createElement("div");
    overlayEl.id = "ac-bookmark-overlay";
    overlayEl.style.cssText = `
      position: fixed;
      inset: 0;
      z-index: 1000000;
      background: rgba(0, 0, 0, 0.45);
      display: none;
      align-items: center;
      justify-content: center;
      padding: 20px;
    `;
 
    const modal = document.createElement("div");
    modal.style.cssText = `
      width: min(960px, 100%);
      max-height: min(85vh, 900px);
      background: white;
      border-radius: 16px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.28);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    `;
 
    const header = document.createElement("div");
    header.style.cssText = `
      display: flex;
      gap: 12px;
      align-items: center;
      justify-content: space-between;
      padding: 14px 16px;
      border-bottom: 1px solid #e5e7eb;
      background: #fafafa;
    `;
 
    const left = document.createElement("div");
    left.style.cssText = `
      display: flex;
      flex-direction: column;
      gap: 4px;
      min-width: 0;
    `;
 
    const title = document.createElement("div");
    title.textContent = "AtCoder Bookmarks";
    title.style.cssText = `
      font-size: 18px;
      font-weight: 700;
    `;
 
    const subtitle = document.createElement("div");
    subtitle.textContent = "Saved tasks, status, and quick links";
    subtitle.style.cssText = `
      font-size: 12px;
      color: #6b7280;
    `;
 
    left.appendChild(title);
    left.appendChild(subtitle);
 
    const controls = document.createElement("div");
    controls.style.cssText = `
      display: flex;
      gap: 8px;
      align-items: center;
      flex-wrap: wrap;
      justify-content: flex-end;
    `;
 
    overlaySearchEl = document.createElement("input");
    overlaySearchEl.type = "search";
    overlaySearchEl.placeholder = "Search title / URL";
    overlaySearchEl.style.cssText = `
      width: 260px;
      max-width: 48vw;
      height: 36px;
      border: 1px solid #d1d5db;
      border-radius: 10px;
      padding: 0 12px;
      outline: none;
      font-size: 14px;
    `;
 
    const closeBtn = document.createElement("button");
    closeBtn.type = "button";
    closeBtn.textContent = "Close";
    closeBtn.style.cssText = `
      height: 36px;
      padding: 0 12px;
      border: none;
      border-radius: 10px;
      background: #111827;
      color: white;
      cursor: pointer;
      font-size: 14px;
    `;
 
    controls.appendChild(overlaySearchEl);
    controls.appendChild(closeBtn);
 
    header.appendChild(left);
    header.appendChild(controls);
 
    overlayListEl = document.createElement("div");
    overlayListEl.style.cssText = `
      overflow: auto;
      padding: 16px;
      display: grid;
      gap: 10px;
      background: white;
    `;
 
    modal.appendChild(header);
    modal.appendChild(overlayListEl);
    overlayEl.appendChild(modal);
    document.body.appendChild(overlayEl);
 
    overlayEl.addEventListener("click", (e) => {
      if (e.target === overlayEl) closeOverlay();
    });
 
    closeBtn.addEventListener("click", closeOverlay);
 
    overlaySearchEl.addEventListener("input", () => {
      renderOverlayList();
    });
 
    return overlayEl;
  }
 
  function closeOverlay() {
    if (overlayEl) overlayEl.style.display = "none";
  }
 
  function openOverlay() {
    ensureOverlay();
    overlayEl.style.display = "flex";
    renderOverlayList();
    overlaySearchEl.value = overlaySearchEl.value || "";
    overlaySearchEl.focus();
  }
 
  async function mutateEntry(url, updater) {
    const data = await loadData();
    const current = data[url] || null;
    const updated = updater(current);
 
    if (updated === null) {
      delete data[url];
    } else {
      data[url] = updated;
    }
 
    await saveData(data);
    await refreshBookmarkButton();
    await renderOverlayList();
  }
 
  async function toggleCurrentTask() {
    const meta = getTaskMeta();
    const data = await loadData();
    const current = data[meta.url] || null;
    const currentStatus = current?.status || STATUS.NONE;
    const newStatus = nextStatus(currentStatus);
 
    if (newStatus === STATUS.NONE) {
      delete data[meta.url];
    } else {
      data[meta.url] = {
        url: meta.url,
        title: meta.title,
        contestId: meta.contestId,
        taskId: meta.taskId,
        status: newStatus,
        savedAt: current?.savedAt || new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };
    }
 
    await saveData(data);
    await refreshBookmarkButton();
    await renderOverlayList();
  }
 
  async function refreshBookmarkButton() {
    if (!bookmarkBtnEl) return;
 
    if (!isTaskPage()) {
      bookmarkBtnEl.style.display = "none";
      return;
    }
 
    bookmarkBtnEl.style.display = "inline-flex";
 
    const meta = getTaskMeta();
    const data = await loadData();
    const entry = data[meta.url];
    const status = entry?.status || STATUS.NONE;
 
    bookmarkBtnEl.textContent = getStatusText(status);
    bookmarkBtnEl.title =
      status === STATUS.NONE
        ? "Mark as upsolve target"
        : status === STATUS.TODO
          ? "Mark as solved"
          : "Remove bookmark";
  }
 
  function sortEntries(entries) {
    return entries.sort((a, b) => {
      const ao = STATUS_ORDER[a.status] ?? 99;
      const bo = STATUS_ORDER[b.status] ?? 99;
      if (ao !== bo) return ao - bo;
      const at = Date.parse(a.updatedAt || a.savedAt || "");
      const bt = Date.parse(b.updatedAt || b.savedAt || "");
      return (bt || 0) - (at || 0);
    });
  }
 
  async function renderOverlayList() {
    if (!overlayListEl) return;
 
    const data = await loadData();
    const entries = sortEntries(
      Object.values(data).filter((x) => x && typeof x === "object")
    );
 
    const q = (overlaySearchEl?.value || "").trim().toLowerCase();
 
    const filtered = q
      ? entries.filter((e) => {
          const hay = `${e.title || ""} ${e.url || ""} ${e.contestId || ""} ${e.taskId || ""} ${e.status || ""}`.toLowerCase();
          return hay.includes(q);
        })
      : entries;
 
    overlayListEl.innerHTML = "";
 
    const summary = document.createElement("div");
    summary.style.cssText = `
      font-size: 13px;
      color: #6b7280;
      margin-bottom: 2px;
    `;
    summary.textContent = `${filtered.length} / ${entries.length} saved`;
    overlayListEl.appendChild(summary);
 
    if (filtered.length === 0) {
      const empty = document.createElement("div");
      empty.style.cssText = `
        padding: 24px;
        border: 1px dashed #d1d5db;
        border-radius: 12px;
        color: #6b7280;
        background: #fafafa;
      `;
      empty.textContent = "No saved entries.";
      overlayListEl.appendChild(empty);
      return;
    }
 
    for (const entry of filtered) {
      const row = document.createElement("div");
      row.style.cssText = `
        display: grid;
        grid-template-columns: 1fr auto;
        gap: 12px;
        align-items: center;
        padding: 14px 16px;
        border: 1px solid #e5e7eb;
        border-radius: 14px;
      `;
 
      const left = document.createElement("div");
      left.style.cssText = `
        min-width: 0;
        display: flex;
        flex-direction: column;
        gap: 6px;
      `;
 
      const topLine = document.createElement("div");
      topLine.style.cssText = `
        display: flex;
        gap: 8px;
        align-items: center;
        flex-wrap: wrap;
      `;
 
      const link = document.createElement("a");
      link.href = entry.url;
      link.target = "_blank";
      link.rel = "noopener noreferrer";
      link.textContent = entry.title || entry.url;
      link.style.cssText = `
        color: #0366d6;
        text-decoration: none;
        font-weight: 600;
        word-break: break-word;
      `;
 
      const badge = document.createElement("span");
      badge.textContent = getStatusLabel(entry.status || STATUS.NONE);
      badge.style.cssText = `
        font-size: 12px;
        padding: 2px 8px;
        border-radius: 999px;
        background: #f3f4f6;
        color: #374151;
      `;
 
      const meta = document.createElement("div");
      meta.style.cssText = `
        font-size: 12px;
        color: #6b7280;
        word-break: break-word;
      `;
      meta.textContent = `${entry.contestId || ""}${entry.contestId && entry.taskId ? " / " : ""}${entry.taskId || ""}  ${entry.savedAt ? "・ " + new Date(entry.savedAt).toLocaleString() : ""}`;
 
      topLine.appendChild(link);
      topLine.appendChild(badge);
      left.appendChild(topLine);
      left.appendChild(meta);
 
      const actions = document.createElement("div");
      actions.style.cssText = `
        display: flex;
        gap: 8px;
        align-items: center;
        flex-wrap: wrap;
        justify-content: flex-end;
      `;
 
      const cycleBtn = document.createElement("button");
      cycleBtn.type = "button";
      cycleBtn.textContent = "Toggle";
      cycleBtn.style.cssText = `
        height: 34px;
        padding: 0 12px;
        border: none;
        border-radius: 10px;
        background: #111827;
        color: white;
        cursor: pointer;
        font-size: 13px;
      `;
      cycleBtn.title = "Cycle status: todo → done → none";
 
      cycleBtn.addEventListener("click", async () => {
        await mutateEntry(entry.url, (current) => {
          const curStatus = current?.status || STATUS.NONE;
          const newStatus = nextStatus(curStatus);
 
          if (newStatus === STATUS.NONE) {
            return null;
          }
 
          return {
            ...(current || {}),
            url: entry.url,
            title: entry.title || current?.title || entry.url,
            contestId: entry.contestId || current?.contestId || "",
            taskId: entry.taskId || current?.taskId || "",
            status: newStatus,
            savedAt: current?.savedAt || entry.savedAt || new Date().toISOString(),
            updatedAt: new Date().toISOString(),
          };
        });
      });
 
      const deleteBtn = document.createElement("button");
      deleteBtn.type = "button";
      deleteBtn.textContent = "Delete";
      deleteBtn.style.cssText = `
        height: 34px;
        padding: 0 12px;
        border: 1px solid #d1d5db;
        border-radius: 10px;
        background: white;
        color: #111827;
        cursor: pointer;
        font-size: 13px;
      `;
      deleteBtn.title = "Remove this bookmark";
 
      deleteBtn.addEventListener("click", async () => {
        await mutateEntry(entry.url, () => null);
      });
 
      actions.appendChild(cycleBtn);
      actions.appendChild(deleteBtn);
 
      row.appendChild(left);
      row.appendChild(actions);
      overlayListEl.appendChild(row);
    }
  }
 
  function wireEvents() {
    if (bookmarkBtnEl) {
      bookmarkBtnEl.addEventListener("click", toggleCurrentTask);
    }
 
    if (viewerBtnEl) {
      viewerBtnEl.addEventListener("click", openOverlay);
    }
  }
 
  async function init() {
    if (!document.body) {
      window.addEventListener("DOMContentLoaded", init, { once: true });
      return;
    }
 
    ensureFloatingPanel();
    wireEvents();
    await refreshBookmarkButton();
  }
 
  await init();
})();