CodePTIT Copier

Sửa lỗi dòng trống thừa khi copy trên CodePTIT. Tạo nút copy nhanh Testcase và Mã bài + Tên bài được chuẩn hóa

当前为 2025-10-24 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         CodePTIT Copier
// @namespace    https://github.com/nvbangg/CodePTIT_Copier
// @version      1.5
// @description  Sửa lỗi dòng trống thừa khi copy trên CodePTIT. Tạo nút copy nhanh Testcase và Mã bài + Tên bài được chuẩn hóa
// @author       nvbangg (https://github.com/nvbangg)
// @copyright    Copyright (c) 2025 Nguyễn Văn Bằng (nvbangg, github.com/nvbangg)
// @homepage     https://github.com/nvbangg/CodePTIT_Copier
// @homepage     https://chromewebstore.google.com/detail/codeptit-copier/ncckkgpgiplcmbmobjlffkbaaklohhbo
// @match        https://code.ptit.edu.vn/*
// @icon         https://raw.githubusercontent.com/nvbangg/CodePTIT_Copier/main/src/icon.png
// @grant        GM_registerMenuCommand
// @run-at       document-start
// @license      GPL-3.0
// ==/UserScript==

//! 📌 Nên Dùng bản extension để có đầy đủ tính năng, tùy chỉnh settings:
//! https://chromewebstore.google.com/detail/codeptit-copier/ncckkgpgiplcmbmobjlffkbaaklohhbo

//! 🌐 Xem hướng dẫn chi tiết tại:
//! 🏠 Homepage: https://github.com/nvbangg/CodePTIT_Copier

(() => {
  "use strict";
  const WORD_SEPARATOR = "";
  const ICONS = {
    copy: '<svg width="25" height="25" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h10a2 2 0 0 1 2 2v10"/><rect x="3" y="8" width="13" height="13" rx="2"/></svg>',
    check:
      '<svg width="25" height="25" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
    rowCopy:
      '<svg width="25" height="25" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h10a2 2 0 0 1 2 2v10"/><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M7 13h7"/><path d="M7 17h6"/></svg>',
  };

  const addStyles = () => {
    const style = document.createElement("style");
    style.textContent = `
  .copy-btn,.title-copy-btn,.row-copy-btn{background:rgba(30,144,255,.4);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;width:25px;height:25px;border-radius:5px;padding:2px;position:relative;outline:none!important;user-select:none}
  .copy-btn{position:absolute;top:0;right:0}
  .title-copy-btn{margin-right:8px;top:-3px;display:inline-flex;vertical-align:middle}
  .row-copy-btn{position:absolute;left:-26px;top:0;background:rgba(255,165,0,.5)}
  .copied{background:rgba(50,205,50,1)!important}`;
    document.head.appendChild(style);
  };

  const $ = (s) => document.querySelector(s);
  const $$ = (s) => [...document.querySelectorAll(s)];
  const isBeta = () => location.pathname.includes("/beta/problems");
  const hasText = (el) => el?.textContent?.trim();
  const preventEvent = (e) => (e.preventDefault(), e.stopPropagation());

  const showCopied = (button) => {
    const originalContent = button.innerHTML;
    button.innerHTML = ICONS.check;
    button.classList.add("copied");
    setTimeout(() => {
      button.innerHTML = originalContent;
      button.classList.remove("copied");
    }, 800);
  };

  const getTestcase = (cell) =>
    (cell.innerText ?? "")
      .replace(
        /[\u00A0\u1680\u180E\u2000-\u200B\u202F\u205F\u3000\uFEFF]/g,
        " "
      )
      .trimEnd();

  const copy = (text, button) => (
    navigator.clipboard.writeText(text), showCopied(button)
  );

  const copyRow = (row, button) => {
    const cells = row.querySelectorAll("td");
    if (cells.length < 2) return;
    const [input, output] = [getTestcase(cells[0]), getTestcase(cells[1])];
    input.trim() && navigator.clipboard.writeText(input);
    setTimeout(
      () => output.trim() && navigator.clipboard.writeText(output),
      400
    );
    showCopied(button);
  };

  const formatTitle = (title) =>
    !title
      ? ""
      : title
          .normalize("NFD")
          .replace(/[\u0300-\u036f]|[đĐ]/g, (m) =>
            m === "đ" ? "d" : m === "Đ" ? "D" : ""
          )
          .replace(/[^A-Za-z0-9]+/g, " ")
          .trim()
          .replace(/\S+/g, (w) => w[0].toUpperCase() + w.slice(1).toLowerCase())
          .replace(/ /g, WORD_SEPARATOR);

  const addCellBtn = (cell) => {
    if (!hasText(cell) || cell.dataset.copyAdded) return;
    window.getComputedStyle(cell).position === "static" &&
      (cell.style.position = "relative");
    cell.dataset.copyAdded = "true";
    const btn = document.createElement("button");
    btn.className = "copy-btn";
    btn.innerHTML = ICONS.copy;
    btn.addEventListener(
      "click",
      (e) => (
        preventEvent(e),
        ((content) => content.trim() && copy(content, e.currentTarget))(
          getTestcase(cell)
        )
      )
    );
    cell.appendChild(btn);
  };

  const addRowBtn = (row) => {
    if (!row || row.dataset.rowCopyAdded) return;
    const cells = row.querySelectorAll("td");
    if (cells.length < 2 || !hasText(cells[0]) || !hasText(cells[1])) return;
    window.getComputedStyle(cells[0]).position === "static" &&
      (cells[0].style.position = "relative");
    row.dataset.rowCopyAdded = "true";
    const btn = document.createElement("button");
    btn.className = "row-copy-btn";
    btn.innerHTML = ICONS.rowCopy;
    btn.addEventListener(
      "click",
      (e) => (preventEvent(e), copyRow(row, e.currentTarget))
    );
    cells[0].appendChild(btn);
  };

  const addTitleBtn = (titleEl) => {
    if (!titleEl || titleEl.dataset.copyAdded) return;
    titleEl.dataset.copyAdded = "true";
    const btn = document.createElement("button");
    btn.className = "title-copy-btn";
    btn.innerHTML = ICONS.copy;
    btn.addEventListener(
      "click",
      (e) => (
        preventEvent(e),
        (() => {
          const code = (location.pathname.split("/").pop() || "").trim();
          const titleText = (titleEl.textContent || "").trim();
          const joiner = WORD_SEPARATOR || "_";
          const name = [code, formatTitle(titleText)]
            .filter(Boolean)
            .join(joiner);
          name && copy(name, e.currentTarget);
        })()
      )
    );
    titleEl.insertBefore(btn, titleEl.firstChild);
  };

  const processTables = (tables) => {
    tables.forEach((t) => {
      t.querySelectorAll("tbody p").forEach(
        (p) => (p.outerHTML = `<div>${p.innerHTML}</div>`)
      );
      t.querySelectorAll("tr:not(:first-child)").forEach((row) => {
        row.querySelectorAll("td").forEach(addCellBtn);
        addRowBtn(row);
      });
    });
  };

  const processLegacyPage = () => {
    if (!/\/student\/question\/[A-Za-z0-9_]+/.test(location.pathname)) return;
    const container = $(".submit__des");
    if (!container) return;
    addTitleBtn($(".submit__nav p span a.link--red"));
    const tables = [...container.querySelectorAll("table")];
    processTables(tables);
  };

  const processBetaPage = () => {
    if (!/\/beta\/problems\/[A-Za-z0-9_]+/.test(location.pathname)) return;
    addTitleBtn($$("h2").find(hasText));
    processTables($$("table"));
  };

  const cleanup = () => {
    $$(".copy-btn, .title-copy-btn, .row-copy-btn").forEach((btn) =>
      btn.remove()
    );
    $$("[data-copy-added], [data-row-copy-added]").forEach(
      (el) => (
        el.removeAttribute("data-copy-added"),
        el.removeAttribute("data-row-copy-added")
      )
    );
  };

  const process = () => (
    cleanup(), isBeta() ? processBetaPage() : processLegacyPage()
  );

  let observerTimer;
  const observer = new MutationObserver(() => {
    clearTimeout(observerTimer);
    observerTimer = setTimeout(() => {
      observer.lastUrl !== location.href
        ? ((observer.lastUrl = location.href), process())
        : (isBeta() ? processBetaPage : processLegacyPage)();
    }, 500);
  });
  observer.lastUrl = location.href;

  const start = () => {
    addStyles();
    try {
      GM_registerMenuCommand("🏠 GitHub Homepage", () => {
        window.open("https://github.com/nvbangg/CodePTIT_Copier", "_blank");
      });
      GM_registerMenuCommand("🌐 View in Chrome Web Store", () => {
        window.open(
          "https://chromewebstore.google.com/detail/codeptit-copier/ncckkgpgiplcmbmobjlffkbaaklohhbo",
          "_blank"
        );
      });
    } catch {}
    observer.observe(document.documentElement, {
      childList: true,
      subtree: true,
    });
    process();
  };

  document.readyState !== "loading"
    ? start()
    : document.addEventListener("DOMContentLoaded", start);
})();