AtCoder Bookmark Manager

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();