AtCoder Problems Custom Add

Custom add, deduplicate, sort, shuffle, remove, and optionally set points on the AtCoder Problems contest creation page.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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 Problems Custom Add
// @namespace    https://kenkoooo.com/atcoder/
// @version      1.5.0
// @description  Custom add, deduplicate, sort, shuffle, remove, and optionally set points on the AtCoder Problems contest creation page.
// @author       generated
// @match        https://kenkoooo.com/atcoder/*
// @grant        none
// @license MIT 
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  const APP_TITLE = "Custom Add";
  const VERSION = "1.5.0";
  const PANEL_ID = "apca-panel";
  const LEGACY_PANEL_IDS = ["apcb-panel"];
  const STATUS_KEY = "apca:last-status";
  const FORM_KEY = "apca:contest-form";
  const POINTS_KEY = "apca:pending-points";
  const POINT_CACHE_KEY = "apca:point-cache";
  const PROBLEMS_URL = "https://kenkoooo.com/atcoder/resources/problems.json";
  const SEP = "~";
  const DEFAULT_MIN_NO = 1;
  const DEFAULT_MAX_NO = 9999;
  const SERIES = ["abc", "arc", "agc", "awc", "adt", "ahc", "custom"];
  const SERIES_PRIORITY = ["abc", "arc", "agc", "awc", "adt", "ahc"];
  const INDEXES = [
    "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
    "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "EX",
  ];

  let problemsPromise = null;
  let restoreStarted = false;
  let pointsApplyStarted = false;
  let applyingPoints = false;
  let originalDocumentTitle = null;

  const $ = (selector, base = document) => base.querySelector(selector);
  const $$ = (selector, base = document) => Array.from(base.querySelectorAll(selector));
  const isCreatePage = () => /^#\/contest\/create(?:\?|$)/.test(location.hash || "");
  const root = () => document.getElementById(PANEL_ID);
  const normIndex = (s) => String(s || "").trim().toUpperCase();
  const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));

  function uniq(items) {
    const seen = new Set();
    const out = [];
    for (const item of items) {
      if (!item || seen.has(item)) continue;
      seen.add(item);
      out.push(item);
    }
    return out;
  }

  function updateDocumentTitle() {
    if (isCreatePage()) {
      if (originalDocumentTitle === null) originalDocumentTitle = document.title;
      if (document.title !== APP_TITLE) document.title = APP_TITLE;
    } else if (originalDocumentTitle !== null && document.title === APP_TITLE) {
      document.title = originalDocumentTitle;
    }
  }

  function show(message, type = "info") {
    const el = $("[data-apca-status]", root());
    if (!el) return;
    el.textContent = message || "";
    el.style.display = message ? "block" : "none";
    el.className = ({
      info: "alert alert-info py-2 my-2",
      success: "alert alert-success py-2 my-2",
      warning: "alert alert-warning py-2 my-2",
      danger: "alert alert-danger py-2 my-2",
    }[type] || "alert alert-info py-2 my-2");
  }

  function setBusy(busy) {
    const r = root();
    if (!r) return;
    r.dataset.busy = busy ? "1" : "0";

    const series = $("[data-apca-series]", r)?.value || "abc";
    const mode = $("[data-apca-mode]", r)?.value || "random";
    const pointMode = $("[data-apca-point-mode]", r)?.value || "unset";

    $$("button,input,select", r).forEach((el) => { el.disabled = busy; });

    const custom = $("[data-apca-custom]", r);
    const count = $("[data-apca-count]", r);
    const point = $("[data-apca-point]", r);
    if (custom) custom.disabled = busy || series !== "custom";
    if (count) count.disabled = busy || mode !== "random";
    if (point) point.disabled = busy || pointMode !== "set";
  }

  async function getProblems() {
    if (!problemsPromise) {
      problemsPromise = fetch(PROBLEMS_URL, { credentials: "same-origin" })
        .then((res) => {
          if (!res.ok) throw new Error(`problems.json: HTTP ${res.status}`);
          return res.json();
        })
        .then((json) => {
          if (!Array.isArray(json)) throw new Error("Invalid problems.json");
          return json.filter((p) => p && typeof p.id === "string" && typeof p.contest_id === "string");
        });
    }
    return problemsPromise;
  }

  function normalizeContestId(input) {
    const raw = String(input || "").trim();
    if (!raw) return "";
    try {
      const url = new URL(raw);
      const m = url.pathname.match(/\/contests\/([^/?#]+)/i);
      if (m) return m[1].toLowerCase();
    } catch (_) {}
    const m = raw.match(/(?:contests\/)?([a-zA-Z0-9_-]+)/);
    return m ? m[1].toLowerCase() : "";
  }

  function contestAliases(id) {
    const s = String(id || "").toLowerCase();
    const out = new Set([s]);
    const m = s.match(/^([a-z]+)0*(\d+)$/);
    if (m) {
      const prefix = m[1];
      const n = Number(m[2]);
      if (Number.isFinite(n)) {
        out.add(`${prefix}${n}`);
        out.add(`${prefix}${String(n).padStart(3, "0")}`);
        out.add(`${prefix}${String(n).padStart(4, "0")}`);
      }
    }
    return out;
  }

  function parseOptionalInt(value, name) {
    const text = String(value || "").trim();
    if (text === "") return null;
    const n = Number(text);
    if (!Number.isInteger(n) || n < 0) throw new Error(`${name}: integer >= 0`);
    return n;
  }

  function readPointPolicy(required = false) {
    const r = root();
    const mode = $("[data-apca-point-mode]", r)?.value || "unset";
    if (mode === "unset") return { mode: "unset", point: null };
    const text = String($("[data-apca-point]", r)?.value || "").trim();
    if (text === "") {
      if (required) throw new Error("Point: integer >= 0");
      return { mode: "unset", point: null };
    }
    const n = Number(text);
    if (!Number.isInteger(n) || n < 0) throw new Error("Point: integer >= 0");
    return { mode: "set", point: n };
  }

  function selectedIndexes() {
    const r = root();
    if (!r || $("[data-apca-index-all]", r)?.checked) return [];
    return $$("[data-apca-index]", r)
      .filter((input) => input.checked)
      .map((input) => normIndex(input.value))
      .filter(Boolean);
  }

  function onIndexChange(event) {
    const r = root();
    if (!r) return;
    const all = $("[data-apca-index-all]", r);
    const indexes = $$("[data-apca-index]", r);
    if (event.target?.matches?.("[data-apca-index-all]")) {
      if (all.checked) indexes.forEach((input) => { input.checked = false; });
      else if (!indexes.some((input) => input.checked)) all.checked = true;
      return;
    }
    if (event.target?.matches?.("[data-apca-index]")) {
      all.checked = !indexes.some((input) => input.checked);
    }
  }

  function firstNumber(s) {
    const m = String(s || "").match(/(\d+)/);
    return m ? Number(m[1]) : null;
  }

  function matchPrefix(contestId, prefix) {
    const id = String(contestId || "").toLowerCase();
    const p = String(prefix || "").toLowerCase();
    if (!p) return null;
    if (p === "adt") return id.startsWith("adt_") || id === "adt" ? { number: firstNumber(id) ?? 0 } : null;

    const numbered = id.match(new RegExp(`^${esc(p)}0*(\\d+)$`, "i"));
    if (numbered) return { number: Number(numbered[1]) };
    if (id === p || id.startsWith(`${p}_`) || id.startsWith(`${p}-`)) {
      return { number: firstNumber(id) ?? 0 };
    }
    return null;
  }

  function readCondition() {
    const r = root();
    const series = $("[data-apca-series]", r)?.value || "abc";
    const customPrefixes = String($("[data-apca-custom]", r)?.value || "")
      .split(/[\s,、]+/)
      .map((s) => s.trim().toLowerCase())
      .filter(Boolean);
    const minNo = parseOptionalInt($("[data-apca-min]", r)?.value, "No. min");
    const maxNo = parseOptionalInt($("[data-apca-max]", r)?.value, "No. max");
    const mode = $("[data-apca-mode]", r)?.value || "random";
    const count = parseOptionalInt($("[data-apca-count]", r)?.value, "Count");

    if (!SERIES.includes(series)) throw new Error("Series");
    if (series === "custom" && customPrefixes.length === 0) throw new Error("Custom prefix");
    if (minNo !== null && maxNo !== null && minNo > maxNo) throw new Error("No. min > No. max");
    if (mode === "random" && (!Number.isInteger(count) || count <= 0)) throw new Error("Count: integer >= 1");

    return { series, customPrefixes, indexes: selectedIndexes(), minNo, maxNo, mode, count };
  }

  function isDefaultRange(cond) {
    return cond.minNo === DEFAULT_MIN_NO && cond.maxNo === DEFAULT_MAX_NO;
  }

  function isRangeActive(cond) {
    return !isDefaultRange(cond) && (cond.minNo !== null || cond.maxNo !== null);
  }

  function filterProblems(problems, cond) {
    const prefixes = cond.series === "custom" ? cond.customPrefixes : [cond.series];
    const indexSet = new Set(cond.indexes);
    const rangeActive = isRangeActive(cond);
    return problems.filter((problem) => {
      let matched = null;
      for (const prefix of prefixes) {
        matched = matchPrefix(problem.contest_id, prefix);
        if (matched) break;
      }
      if (!matched) return false;
      if (rangeActive && cond.minNo !== null && matched.number < cond.minNo) return false;
      if (rangeActive && cond.maxNo !== null && matched.number > cond.maxNo) return false;
      if (indexSet.size && !indexSet.has(normIndex(problem.problem_index))) return false;
      return true;
    });
  }

  function seriesOfContest(contestId) {
    const id = String(contestId || "").toLowerCase();
    if (id.startsWith("adt_") || id === "adt") return "adt";
    const m = id.match(/^([a-z]+)\d+/);
    if (m) return m[1];
    return id.split(/[_-]/)[0] || "";
  }

  function originalContestFromProblemId(problemId) {
    const id = String(problemId || "").toLowerCase();
    const m = id.match(/^([a-z]+)(\d+)[_-]/);
    if (!m) return "";
    return `${m[1]}${Number(m[2])}`;
  }

  function indexKey(index) {
    const s = normIndex(index);
    if (/^[A-Z]$/.test(s)) return [s.charCodeAt(0) - 65, s];
    if (s === "EX") return [26, s];
    const first = s.match(/[A-Z]/)?.[0];
    return [first ? first.charCodeAt(0) - 65 : 999, s];
  }

  function priorityOfProblem(problem) {
    const contestId = String(problem?.contest_id || "").toLowerCase();
    const original = originalContestFromProblemId(problem?.id);
    const originalMatch = original && contestAliases(original).has(contestId) ? 0 : 1;
    const series = seriesOfContest(contestId);
    const seriesRank = SERIES_PRIORITY.includes(series) ? SERIES_PRIORITY.indexOf(series) : 50;
    const contestRank = contestId || "~";
    const indexRank = indexKey(problem?.problem_index || "");
    return [originalMatch, seriesRank, contestRank, indexRank[0], indexRank[1]];
  }

  function comparePriority(a, b) {
    const ak = priorityOfProblem(a);
    const bk = priorityOfProblem(b);
    for (let i = 0; i < Math.min(ak.length, bk.length); i += 1) {
      if (typeof ak[i] === "number" && typeof bk[i] === "number") {
        if (ak[i] !== bk[i]) return ak[i] - bk[i];
      } else {
        const c = String(ak[i]).localeCompare(String(bk[i]), undefined, { numeric: true, sensitivity: "base" });
        if (c) return c;
      }
    }
    return 0;
  }

  function bestProblemMap(problems) {
    const byId = new Map();
    for (const problem of problems) {
      if (!problem?.id) continue;
      const current = byId.get(problem.id);
      if (!current || comparePriority(problem, current) < 0) byId.set(problem.id, problem);
    }
    return byId;
  }

  function dedupeProblems(problems, byId = null) {
    const seen = new Set();
    const out = [];
    for (const problem of problems) {
      const id = problem?.id;
      if (!id || seen.has(id)) continue;
      seen.add(id);
      out.push(byId?.get(id) || problem);
    }
    return out;
  }

  function compareProblem(a, b) {
    const c = String(a.contest_id).localeCompare(String(b.contest_id), undefined, { numeric: true, sensitivity: "base" });
    if (c) return c;
    const ak = indexKey(a.problem_index);
    const bk = indexKey(b.problem_index);
    if (ak[0] !== bk[0]) return ak[0] - bk[0];
    return String(ak[1]).localeCompare(String(bk[1]), undefined, { numeric: true, sensitivity: "base" });
  }

  function randomFloat() {
    const cryptoObj = window.crypto || window.msCrypto;
    if (cryptoObj?.getRandomValues) {
      const buf = new Uint32Array(1);
      cryptoObj.getRandomValues(buf);
      return buf[0] / (0xffffffff + 1);
    }
    return Math.random();
  }

  function shuffle(items) {
    const a = items.slice();
    for (let i = a.length - 1; i > 0; i -= 1) {
      const j = Math.floor(randomFloat() * (i + 1));
      [a[i], a[j]] = [a[j], a[i]];
    }
    return a;
  }

  function readHashIds() {
    const i = location.hash.indexOf("?");
    if (i < 0) return [];
    const ids = new URLSearchParams(location.hash.slice(i + 1)).get("problemIds");
    return ids ? ids.split(SEP).map((id) => decodeURIComponent(id)).filter(Boolean) : [];
  }

  function problemRows() {
    const rows = $$(`tr[data-rbd-draggable-id]`).filter((el) => !el.closest(`#${PANEL_ID}`));
    if (rows.length) return rows;

    const out = [];
    $$(`[data-rbd-draggable-id]`).forEach((el) => {
      if (el.closest(`#${PANEL_ID}`)) return;
      const row = el.closest("tr") || el;
      if (!row || row.closest(`#${PANEL_ID}`) || out.includes(row)) return;
      out.push(row);
    });
    return out;
  }

  function readDomIds() {
    const draggableIds = problemRows()
      .map((el) => el.getAttribute("data-rbd-draggable-id") || el.querySelector("[data-rbd-draggable-id]")?.getAttribute("data-rbd-draggable-id"))
      .filter(Boolean);
    if (draggableIds.length) return draggableIds;

    const ids = [];
    $$("a[href*='/tasks/']").forEach((a) => {
      if (a.closest(`#${PANEL_ID}`)) return;
      try {
        const m = new URL(a.href).pathname.match(/\/tasks\/([^/?#]+)/);
        if (m) ids.push(m[1]);
      } catch (_) {}
    });
    return ids.filter(Boolean);
  }

  function rawCurrentIds() {
    const dom = readDomIds();
    return dom.length ? dom : readHashIds();
  }

  function currentIds() {
    return uniq(rawCurrentIds());
  }

  function pointColumnIndexForRow(row) {
    const table = row.closest("table");
    if (!table) return 3;
    const headers = Array.from(table.querySelectorAll("thead th"));
    const idx = headers.findIndex((th) => String(th.textContent || "").trim().toLowerCase() === "point");
    return idx >= 0 ? idx : 3;
  }

  function pointCellOfRow(row) {
    const cells = Array.from(row.children).filter((el) => el.tagName === "TD");
    const idx = pointColumnIndexForRow(row);
    return cells[idx] || cells[3] || null;
  }

  function parsePointCellValue(cell) {
    if (!cell) return undefined;
    const input = cell.querySelector('input[type="number"]');
    const text = String(input ? input.value : cell.textContent || "").trim();
    if (text === "" || text === "-") return null;
    if (/^\d+$/.test(text)) return Number(text);
    return undefined;
  }

  function readCurrentPointMap(includeNull = true) {
    const map = new Map();
    for (const row of problemRows()) {
      const id = row.getAttribute("data-rbd-draggable-id") || row.querySelector("[data-rbd-draggable-id]")?.getAttribute("data-rbd-draggable-id");
      const value = parsePointCellValue(pointCellOfRow(row));
      if (!id || value === undefined) continue;
      if (value !== null || includeNull) map.set(id, value);
    }
    return map;
  }

  function mapToPlainObject(map) {
    const obj = {};
    for (const [id, point] of map || new Map()) {
      if (!id) continue;
      if (point === null || (Number.isInteger(point) && point >= 0)) obj[id] = point;
    }
    return obj;
  }

  function plainObjectToPointMap(obj) {
    const map = new Map();
    Object.entries(obj || {}).forEach(([id, point]) => {
      if (!id) return;
      if (point === null || (Number.isInteger(point) && point >= 0)) map.set(id, point);
    });
    return map;
  }

  function savePointCache(ids = currentIds(), pointMap = readCurrentPointMap(true)) {
    if (!ids.length || !pointMap.size) {
      sessionStorage.removeItem(POINT_CACHE_KEY);
      return;
    }
    sessionStorage.setItem(POINT_CACHE_KEY, JSON.stringify({
      ids,
      points: mapToPlainObject(pointMap),
      createdAt: Date.now(),
    }));
  }

  function readPointCache() {
    try {
      const raw = sessionStorage.getItem(POINT_CACHE_KEY);
      if (!raw) return new Map();
      const data = JSON.parse(raw);
      return plainObjectToPointMap(data.points || {});
    } catch (_) {
      sessionStorage.removeItem(POINT_CACHE_KEY);
      return new Map();
    }
  }

  function pointMapForNavigation(ids, policy = { mode: "preserve", point: null }) {
    const existingIds = currentIds();
    const domMap = readCurrentPointMap(true);
    const cacheMap = readPointCache();
    if (domMap.size) savePointCache(existingIds, domMap);

    const out = new Map();
    for (const id of ids) {
      if (!id) continue;
      if (policy.mode === "set") {
        out.set(id, policy.point);
      } else if (policy.mode === "clear") {
        out.set(id, null);
      } else {
        if (domMap.has(id)) {
          const value = domMap.get(id);
          if (value !== null) out.set(id, value);
        } else if (existingIds.length && !domMap.size && cacheMap.has(id)) {
          const value = cacheMap.get(id);
          if (value !== null) out.set(id, value);
        }
      }
    }
    return out;
  }

  function schedulePoints(pointMap) {
    const points = mapToPlainObject(pointMap);
    if (Object.keys(points).length === 0) {
      sessionStorage.removeItem(POINTS_KEY);
      return;
    }
    sessionStorage.setItem(POINTS_KEY, JSON.stringify({ points, createdAt: Date.now() }));
  }

  function outside(selector) {
    return $$(selector).filter((el) => !el.closest(`#${PANEL_ID}`));
  }

  function saveForm() {
    const selectors = [
      'input[placeholder="Contest Title"]',
      'textarea[placeholder="Description"]',
      'input[placeholder^="Penalty"]',
      'input[placeholder="AtCoder ID list separated by space"]',
      'input[type="date"]',
      'input[type="time"]',
      'input[type="datetime-local"]',
      "select",
    ];
    const data = {};
    selectors.forEach((selector) => { data[selector] = outside(selector).map((el) => el.value ?? ""); });
    sessionStorage.setItem(FORM_KEY, JSON.stringify(data));
  }

  function setValue(el, value) {
    if (!el) return;
    const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
    if (setter) setter.call(el, value);
    else el.value = value;
    el.dispatchEvent(new Event("input", { bubbles: true }));
    el.dispatchEvent(new Event("change", { bubbles: true }));
  }

  function restoreFormOnce() {
    const raw = sessionStorage.getItem(FORM_KEY);
    if (!raw) return true;
    if (!outside('input[placeholder="Contest Title"]')[0]) return false;
    try {
      const data = JSON.parse(raw);
      Object.entries(data).forEach(([selector, values]) => {
        const elements = outside(selector);
        (values || []).forEach((value, i) => setValue(elements[i], value));
      });
    } finally {
      sessionStorage.removeItem(FORM_KEY);
    }
    return true;
  }

  function startRestore() {
    if (restoreStarted) return;
    restoreStarted = true;
    let tries = 0;
    const timer = setInterval(() => {
      tries += 1;
      if (restoreFormOnce() || tries >= 40) clearInterval(timer);
    }, 250);
  }

  function navigate(ids, message, pointMap = null, options = {}) {
    const nextIds = uniq(ids).filter(Boolean);
    const capturePoints = options.capturePoints !== false;
    if (capturePoints) savePointCache(currentIds(), readCurrentPointMap(true));
    saveForm();
    if (message) sessionStorage.setItem(STATUS_KEY, message);
    if (pointMap) schedulePoints(pointMap);
    else sessionStorage.removeItem(POINTS_KEY);

    if (!nextIds.length) {
      location.href = `${location.origin}${location.pathname}${location.search}#/contest/create`;
    } else {
      const encoded = nextIds.map((id) => encodeURIComponent(id)).join(SEP);
      location.href = `${location.origin}${location.pathname}${location.search}#/contest/create?problemIds=${encoded}`;
    }
    setTimeout(() => location.reload(), 50);
  }

  async function setPointInRow(row, point) {
    const cell = pointCellOfRow(row);
    if (!cell) return false;

    const currentValue = parsePointCellValue(cell);
    if (currentValue === point) return true;

    cell.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
    await sleep(20);

    let input = cell.querySelector('input[type="number"]');
    if (!input) {
      await sleep(80);
      input = cell.querySelector('input[type="number"]');
    }
    if (!input) return false;

    setValue(input, point === null ? "" : String(point));
    input.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", bubbles: true }));
    input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true }));
    input.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", bubbles: true }));
    input.blur();
    await sleep(10);
    return true;
  }

  async function applyPointMapToVisibleRows(pointMap) {
    const rows = problemRows();
    if (!rows.length) return { done: false, applied: 0, missing: pointMap.size };

    const rowsById = new Map();
    rows.forEach((row) => {
      const id = row.getAttribute("data-rbd-draggable-id") || row.querySelector("[data-rbd-draggable-id]")?.getAttribute("data-rbd-draggable-id");
      if (id) rowsById.set(id, row);
    });

    const missing = Array.from(pointMap.keys()).filter((id) => !rowsById.has(id));
    if (missing.length) return { done: false, applied: 0, missing: missing.length };

    let applied = 0;
    for (const [id, point] of pointMap.entries()) {
      const row = rowsById.get(id);
      if (!row) continue;
      if (await setPointInRow(row, point)) applied += 1;
    }
    savePointCache(currentIds(), readCurrentPointMap(true));
    return { done: true, applied, missing: 0 };
  }

  async function applyPendingPointsOnce() {
    const raw = sessionStorage.getItem(POINTS_KEY);
    if (!raw || applyingPoints) return true;
    let data;
    try {
      data = JSON.parse(raw);
    } catch (_) {
      sessionStorage.removeItem(POINTS_KEY);
      return true;
    }

    const pointMap = plainObjectToPointMap(data.points || {});
    if (!pointMap.size) {
      sessionStorage.removeItem(POINTS_KEY);
      return true;
    }

    applyingPoints = true;
    try {
      const result = await applyPointMapToVisibleRows(pointMap);
      if (result.done) {
        sessionStorage.removeItem(POINTS_KEY);
        show(`Points applied: ${result.applied}`, "success");
        return true;
      }
      return false;
    } finally {
      applyingPoints = false;
    }
  }

  function startPointApply() {
    if (pointsApplyStarted) return;
    if (!sessionStorage.getItem(POINTS_KEY)) return;
    pointsApplyStarted = true;
    let tries = 0;
    const timer = setInterval(async () => {
      tries += 1;
      const done = await applyPendingPointsOnce();
      if (done || tries >= 100) {
        clearInterval(timer);
        pointsApplyStarted = false;
        if (!done) show("Point setting failed", "warning");
      }
    }, 250);
  }

  function syncPointCache() {
    if (!isCreatePage()) return;
    const ids = currentIds();
    const map = readCurrentPointMap(true);
    if (ids.length && map.size) savePointCache(ids, map);
  }

  async function appendProblems(problems, label, allProblems = null) {
    const policy = readPointPolicy(false);
    const source = allProblems || await getProblems();
    const byId = bestProblemMap(source);
    const selected = dedupeProblems(problems, byId)
      .sort(compareProblem)
      .map((p) => p.id)
      .filter(Boolean);
    if (!selected.length) {
      show("No problem to add", "warning");
      return;
    }
    const cur = currentIds();
    const next = uniq([...cur, ...selected]);
    const added = next.length - cur.length;
    if (added <= 0) {
      show("Already added", "warning");
      return;
    }
    navigate(next, `${label}: ${added} added`, pointMapForNavigation(next, policy.mode === "set" ? policy : { mode: "preserve", point: null }));
  }

  async function addContest() {
    const id = normalizeContestId($("[data-apca-contest]", root())?.value);
    if (!id) {
      show("Contest ID required", "warning");
      return;
    }
    setBusy(true);
    try {
      const problems = await getProblems();
      const aliases = contestAliases(id);
      const byId = bestProblemMap(problems);
      const selected = dedupeProblems(
        problems.filter((p) => aliases.has(String(p.contest_id).toLowerCase())),
        byId,
      ).sort(compareProblem);
      await appendProblems(selected, id.toUpperCase(), problems);
    } catch (e) {
      console.error(e);
      show(String(e.message || e), "danger");
    } finally {
      setBusy(false);
    }
  }

  function rangeLabel(cond) {
    if (!isRangeActive(cond)) return "";
    const left = cond.minNo !== null ? String(cond.minNo) : "";
    const right = cond.maxNo !== null ? String(cond.maxNo) : "";
    return ` No.${left}-${right}`;
  }

  function labelOf(cond) {
    const series = cond.series === "custom" ? cond.customPrefixes.join(",").toUpperCase() : cond.series.toUpperCase();
    const index = cond.indexes.length ? cond.indexes.join(",") : "ALL";
    return `${series}-${index}${rangeLabel(cond)}`;
  }

  async function countCandidates() {
    setBusy(true);
    try {
      const cond = readCondition();
      const problems = await getProblems();
      const candidates = dedupeProblems(filterProblems(problems, cond), bestProblemMap(problems));
      show(`${labelOf(cond)}: ${candidates.length} candidates`, candidates.length ? "success" : "warning");
    } catch (e) {
      console.error(e);
      show(String(e.message || e), "danger");
    } finally {
      setBusy(false);
    }
  }

  async function addByCondition() {
    setBusy(true);
    try {
      const cond = readCondition();
      const problems = await getProblems();
      const byId = bestProblemMap(problems);
      const candidates = dedupeProblems(filterProblems(problems, cond), byId).sort(compareProblem);
      const selected = cond.mode === "random"
        ? shuffle(candidates).slice(0, Math.min(cond.count, candidates.length)).sort(compareProblem)
        : candidates;
      await appendProblems(selected, labelOf(cond), problems);
    } catch (e) {
      console.error(e);
      show(String(e.message || e), "danger");
    } finally {
      setBusy(false);
    }
  }

  function removeDuplicatesCurrent() {
    const raw = rawCurrentIds();
    if (!raw.length) {
      show("No problem", "warning");
      return;
    }
    const ids = uniq(raw);
    const removed = raw.length - ids.length;
    if (removed <= 0) {
      show("No duplicate", "success");
      return;
    }
    navigate(ids, `Duplicates removed: ${removed}`, pointMapForNavigation(ids, { mode: "preserve", point: null }));
  }

  function removeAllCurrent() {
    const ids = currentIds();
    if (!ids.length) {
      show("No problem", "warning");
      return;
    }
    if (!window.confirm(`Remove all ${ids.length} problems?`)) return;
    sessionStorage.removeItem(POINT_CACHE_KEY);
    sessionStorage.removeItem(POINTS_KEY);
    navigate([], `All removed: ${ids.length}`, null, { capturePoints: false });
  }

  async function sortCurrent() {
    const ids = currentIds();
    if (!ids.length) {
      show("No problem", "warning");
      return;
    }
    setBusy(true);
    try {
      const problems = await getProblems();
      const byId = bestProblemMap(problems);
      const pos = new Map(ids.map((id, i) => [id, i]));
      const sorted = ids.slice().sort((a, b) => {
        const pa = byId.get(a);
        const pb = byId.get(b);
        if (pa && pb) return compareProblem(pa, pb);
        if (pa) return -1;
        if (pb) return 1;
        return (pos.get(a) ?? 0) - (pos.get(b) ?? 0);
      });
      navigate(sorted, `Sorted: ${sorted.length}`, pointMapForNavigation(sorted, { mode: "preserve", point: null }));
    } catch (e) {
      console.error(e);
      show(String(e.message || e), "danger");
    } finally {
      setBusy(false);
    }
  }

  function shuffleCurrent() {
    const ids = currentIds();
    if (!ids.length) {
      show("No problem", "warning");
      return;
    }
    const shuffled = shuffle(ids);
    navigate(shuffled, `Shuffled: ${ids.length}`, pointMapForNavigation(shuffled, { mode: "preserve", point: null }));
  }

  async function setAllPointsCurrent() {
    const ids = currentIds();
    if (!ids.length) {
      show("No problem", "warning");
      return;
    }
    setBusy(true);
    try {
      const policy = readPointPolicy(true);
      const point = policy.mode === "set" ? policy.point : null;
      const pointMap = new Map(ids.map((id) => [id, point]));
      const result = await applyPointMapToVisibleRows(pointMap);
      if (!result.done) {
        show("Point setting failed", "warning");
        return;
      }
      show(`Points applied: ${result.applied}`, "success");
    } catch (e) {
      console.error(e);
      show(String(e.message || e), "danger");
    } finally {
      setBusy(false);
    }
  }

  function indexCheckbox(value, checked = false) {
    const attr = value === "__all" ? "data-apca-index-all" : "data-apca-index";
    const text = value === "__all" ? "All" : value;
    return `<label class="apca-index-item"><input type="checkbox" ${attr} value="${value}" ${checked ? "checked" : ""}>${text}</label>`;
  }

  function createPanel() {
    const div = document.createElement("div");
    div.id = PANEL_ID;
    div.dataset.apcaVersion = VERSION;
    div.className = "card my-3";
    div.innerHTML = `
      <style>
        #${PANEL_ID} .apca-index-list{display:flex;flex-wrap:wrap;gap:6px 10px;padding:8px;border:1px solid #ced4da;border-radius:.25rem;min-height:38px}
        #${PANEL_ID} .apca-index-item{margin:0;white-space:nowrap;font-weight:normal}
        #${PANEL_ID} .apca-index-item input{margin-right:3px}
      </style>
      <div class="card-header"><strong>Custom Add</strong></div>
      <div class="card-body">
        <div class="form-row align-items-end">
          <div class="form-group col-md-7">
            <label class="mb-1">Contest ID / URL</label>
            <input data-apca-contest class="form-control">
          </div>
          <div class="form-group col-md-5">
            <button data-apca-add-contest class="btn btn-primary btn-block" type="button">Add Contest</button>
          </div>
        </div>
        <hr>
        <div class="form-row">
          <div class="form-group col-md-2">
            <label class="mb-1">Series</label>
            <select data-apca-series class="form-control">
              <option value="abc" selected>ABC</option>
              <option value="arc">ARC</option>
              <option value="agc">AGC</option>
              <option value="awc">AWC</option>
              <option value="adt">ADT</option>
              <option value="ahc">AHC</option>
              <option value="custom">custom</option>
            </select>
          </div>
          <div class="form-group col-md-2">
            <label class="mb-1">custom prefix</label>
            <input data-apca-custom class="form-control" disabled>
          </div>
          <div class="form-group col-md-1">
            <label class="mb-1">No. min</label>
            <input data-apca-min class="form-control" type="number" min="0" value="${DEFAULT_MIN_NO}">
          </div>
          <div class="form-group col-md-1">
            <label class="mb-1">No. max</label>
            <input data-apca-max class="form-control" type="number" min="0" value="${DEFAULT_MAX_NO}">
          </div>
          <div class="form-group col-md-2">
            <label class="mb-1">Selection</label>
            <select data-apca-mode class="form-control">
              <option value="random" selected>Random</option>
              <option value="all">All</option>
            </select>
          </div>
          <div class="form-group col-md-2">
            <label class="mb-1">Count</label>
            <input data-apca-count class="form-control" type="number" min="1" value="50">
          </div>
          <div class="form-group col-md-2">
            <label class="mb-1">Action</label>
            <button data-apca-add-condition class="btn btn-success btn-block" type="button">Add by Condition</button>
          </div>
        </div>
        <div class="form-group">
          <label class="mb-1">Problem index</label>
          <div data-apca-indexes class="apca-index-list">
            ${indexCheckbox("__all", true)}${INDEXES.map((v) => indexCheckbox(v)).join("")}
          </div>
        </div>
        <div class="form-row align-items-end">
          <div class="form-group col-md-2">
            <label class="mb-1">Point mode</label>
            <select data-apca-point-mode class="form-control">
              <option value="unset" selected>Unset</option>
              <option value="set">Set</option>
            </select>
          </div>
          <div class="form-group col-md-2">
            <label class="mb-1">Point</label>
            <input data-apca-point class="form-control" type="number" min="0" disabled>
          </div>
          <div class="form-group col-md-2">
            <button data-apca-set-points class="btn btn-outline-primary btn-block" type="button">Set All Points</button>
          </div>
          <div class="form-group col-md-2">
            <button data-apca-preview class="btn btn-outline-secondary btn-block" type="button">Count Candidates</button>
          </div>
          <div class="form-group col-md-2">
            <button data-apca-dedupe class="btn btn-outline-primary btn-block" type="button">Remove Duplicates</button>
          </div>
          <div class="form-group col-md-2">
            <button data-apca-remove-all class="btn btn-outline-danger btn-block" type="button">Remove All</button>
          </div>
        </div>
        <div class="form-row align-items-end">
          <div class="form-group col-md-6">
            <button data-apca-sort class="btn btn-outline-primary btn-block" type="button">Sort Problems</button>
          </div>
          <div class="form-group col-md-6">
            <button data-apca-shuffle class="btn btn-outline-primary btn-block" type="button">Shuffle Problems</button>
          </div>
        </div>
        <div data-apca-status style="display:none"></div>
      </div>`;
    return div;
  }

  function bind() {
    const r = root();
    if (!r || r.dataset.bound === VERSION) return;
    r.dataset.bound = VERSION;
    $("[data-apca-series]", r)?.addEventListener("change", () => setBusy(false));
    $("[data-apca-mode]", r)?.addEventListener("change", () => setBusy(false));
    $("[data-apca-point-mode]", r)?.addEventListener("change", () => setBusy(false));
    $("[data-apca-indexes]", r)?.addEventListener("change", onIndexChange);
    $("[data-apca-add-contest]", r)?.addEventListener("click", addContest);
    $("[data-apca-preview]", r)?.addEventListener("click", countCandidates);
    $("[data-apca-add-condition]", r)?.addEventListener("click", addByCondition);
    $("[data-apca-set-points]", r)?.addEventListener("click", setAllPointsCurrent);
    $("[data-apca-dedupe]", r)?.addEventListener("click", removeDuplicatesCurrent);
    $("[data-apca-remove-all]", r)?.addEventListener("click", removeAllCurrent);
    $("[data-apca-sort]", r)?.addEventListener("click", sortCurrent);
    $("[data-apca-shuffle]", r)?.addEventListener("click", shuffleCurrent);
    const last = sessionStorage.getItem(STATUS_KEY);
    if (last) {
      show(last, "success");
      sessionStorage.removeItem(STATUS_KEY);
    } else {
      show("");
    }
    setBusy(false);
  }

  function insert() {
    updateDocumentTitle();
    LEGACY_PANEL_IDS.forEach((id) => document.getElementById(id)?.remove());
    if (!isCreatePage()) {
      root()?.remove();
      return;
    }

    const currentPanel = root();
    if (currentPanel && currentPanel.dataset.apcaVersion !== VERSION) currentPanel.remove();

    if (!root()) {
      const div = createPanel();
      const heading = $$("h1,h2").find((el) => /Create Contest|Contest Create/i.test(el.textContent || ""));
      if (heading) (heading.closest(".row") || heading).insertAdjacentElement("afterend", div);
      else ($(".container") || $("main") || document.body).prepend(div);
    }
    bind();
    startRestore();
    startPointApply();
  }

  function main() {
    insert();
    new MutationObserver(insert).observe(document.documentElement, { childList: true, subtree: true });
    addEventListener("hashchange", () => {
      restoreStarted = false;
      pointsApplyStarted = false;
      setTimeout(insert, 50);
    });
    setInterval(insert, 1000);
    setInterval(syncPointCache, 1000);
  }

  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", main, { once: true });
  else main();
})();