AtCoder Problems Column Limited Search

AtCoder Problems の Problem List について、検索対象の列をチェックボックスで制限します。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AtCoder Problems Column Limited Search
// @namespace    http://tampermonkey.net/
// @version      2026.07.07
// @description  AtCoder Problems の Problem List について、検索対象の列をチェックボックスで制限します。
// @author       Not_Leonian
// @match        https://kenkoooo.com/atcoder*
// @icon         https://www.google.com/s2/favicons?domain=https://kenkoooo.com/atcoder
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(() => {
	"use strict";

	const CONTROL_ID = "acp-column-limited-search-controls";
	const STYLE_ID = "acp-column-limited-search-style";
	const STORAGE_KEY = "acp.columnLimitedSearch.selectedFields.v1";
	const INF_LIKE = 9e17;

	const FIELD_LABEL = {
		contestDate: "Date",
		title: "Problem",
		contest: "Contest",
		status: "Result",
		lastAcceptedDate: "Last AC Date",
		solverCount: "Solvers",
		point: "Point",
		problemModel: "Difficulty",
		solveProbability: "Solve Prob",
		timeEstimation: "Time",
		executionTime: "Fastest",
		codeLength: "Shortest",
		firstUserId: "First",
	};

	const selectedFieldsByTable = new WeakMap();
	let nextTableInstanceId = 1;

	function getTableInstanceId(tableInstance) {
		if (!tableInstance.__acpColumnLimitedSearchInstanceId) {
			Object.defineProperty(
				tableInstance,
				"__acpColumnLimitedSearchInstanceId",
				{
					value: String(nextTableInstanceId++),
					enumerable: false,
					configurable: false,
				},
			);
		}
		return tableInstance.__acpColumnLimitedSearchInstanceId;
	}

	function isProblemListPage() {
		return /^#\/list(?:\/|\?|$)/.test(window.location.hash || "");
	}

	function normalizeText(value) {
		return String(value == null ? "" : value)
			.replace(/\s+/g, " ")
			.trim();
	}

	function finiteNumberText(value) {
		return typeof value === "number" &&
			Number.isFinite(value) &&
			value < INF_LIKE
			? String(value)
			: "";
	}

	function collectionToText(value) {
		if (value == null) return "";
		if (value instanceof Set) return Array.from(value).join(" ");
		if (Array.isArray(value)) return value.map(collectionToText).join(" ");
		return String(value);
	}

	function genericObjectText(value) {
		if (value == null) return "";
		if (typeof value !== "object") return String(value);

		const parts = [];
		for (const key of [
			"id",
			"title",
			"name",
			"contest_id",
			"problem_id",
			"problem_index",
			"result",
			"user_id",
		]) {
			if (value[key] != null) parts.push(String(value[key]));
		}
		return parts.join(" ");
	}

	function statusToText(status) {
		if (status == null || typeof status !== "object")
			return genericObjectText(status);

		const parts = [];
		switch (status.label) {
			case 0:
				parts.push("AC", "Accepted", "Success");
				break;
			case 1:
				parts.push("Trying", "Failed");
				break;
			case 2:
				parts.push("Warning", status.result || "");
				break;
			case 3:
				parts.push("None");
				break;
			default:
				parts.push(genericObjectText(status));
				break;
		}

		if (status.solvedRivals) parts.push(collectionToText(status.solvedRivals));
		if (status.solvedLanguages)
			parts.push(collectionToText(status.solvedLanguages));
		if (status.submittedLanguages)
			parts.push(collectionToText(status.submittedLanguages));
		return parts.join(" ");
	}

	function problemModelToText(model) {
		if (model == null || typeof model !== "object") return "";
		const parts = [];
		if (typeof model.difficulty === "number")
			parts.push(String(model.difficulty));
		if (typeof model.rawDifficulty === "number")
			parts.push(String(Math.round(model.rawDifficulty)));
		if (typeof model.discrimination === "number")
			parts.push(String(model.discrimination));
		if (typeof model.slope === "number") parts.push(String(model.slope));
		if (typeof model.intercept === "number")
			parts.push(String(model.intercept));
		return parts.join(" ");
	}

	function searchValueForField(row, field) {
		switch (field) {
			case "contestDate":
				return row.contestDate;
			case "title":
				return [row.title, row.id, row.mergedProblem?.id].join(" ");
			case "contest":
				return [
					row.contestTitle,
					row.contest?.id,
					row.contest?.title,
					row.mergedProblem?.contest_id,
				].join(" ");
			case "status":
				return statusToText(row.status);
			case "lastAcceptedDate":
				return row.lastAcceptedDate;
			case "solverCount":
				return finiteNumberText(row.solverCount);
			case "point":
				return (
					finiteNumberText(row.point) || (row.point >= INF_LIKE ? "-" : "")
				);
			case "problemModel":
				return problemModelToText(row.problemModel) || "-";
			case "solveProbability":
				return collectionToText(
					row.solveProbability || row.solveProb || row.probability,
				);
			case "timeEstimation":
				return collectionToText(
					row.timeEstimation || row.solveTime || row.predictedSolveTime,
				);
			case "executionTime":
				return [finiteNumberText(row.executionTime), row.fastestUserId].join(
					" ",
				);
			case "codeLength":
				return [finiteNumberText(row.codeLength), row.shortestUserId].join(" ");
			case "firstUserId":
				return row.firstUserId;
			default:
				return genericObjectText(row[field]);
		}
	}

	function getReactFiber(node) {
		if (!node) return null;
		const key = Object.keys(node).find(
			(k) =>
				k.startsWith("__reactFiber$") ||
				k.startsWith("__reactInternalInstance$"),
		);
		return key ? node[key] : null;
	}

	function isBootstrapTableInstance(value) {
		return (
			value?.store &&
			value.colInfos &&
			typeof value.handleSearch === "function" &&
			typeof value.getTableData === "function"
		);
	}

	function findBootstrapTableInstance(fromNode) {
		for (let node = fromNode; node; node = node.parentElement) {
			let fiber = getReactFiber(node);
			while (fiber) {
				if (isBootstrapTableInstance(fiber.stateNode)) return fiber.stateNode;
				fiber = fiber.return;
			}
		}
		return null;
	}

	function patchSearch(tableInstance) {
		const store = tableInstance?.store;
		if (!store || store.__acpColumnLimitedSearchPatched) return;

		const originalSearch = store._search.bind(store);

		Object.defineProperty(store, "__acpColumnLimitedSearchPatched", {
			value: true,
			enumerable: false,
			configurable: false,
		});

		Object.defineProperty(store, "__acpColumnLimitedSearchOriginalSearch", {
			value: originalSearch,
			enumerable: false,
			configurable: false,
		});

		store._search = function acpColumnLimitedSearch(source) {
			const rawSearchText =
				this.searchText == null ? "" : String(this.searchText);
			const searchText = rawSearchText.trim().toLowerCase();
			const selectedFields = selectedFieldsByTable.get(tableInstance);

			if (!Array.isArray(selectedFields)) {
				originalSearch(source);
				return;
			}

			if (searchText === "") {
				this.filteredData = source;
				this.isOnFilter = true;
				return;
			}

			if (selectedFields.length === 0) {
				this.filteredData = [];
				this.isOnFilter = true;
				return;
			}

			this.filteredData = source.filter((row) =>
				selectedFields.some((field) =>
					normalizeText(searchValueForField(row, field))
						.toLowerCase()
						.includes(searchText),
				),
			);
			this.isOnFilter = true;
		};
	}

	function findProblemListTable() {
		return Array.from(document.querySelectorAll("table")).find(
			(table) =>
				table.querySelector('th[data-field="title"]') &&
				table.querySelector('th[data-field="contestDate"]'),
		);
	}

	function isVisible(element) {
		const style = window.getComputedStyle(element);
		return style.display !== "none" && style.visibility !== "hidden";
	}

	function collectVisibleColumns(table) {
		const seen = new Set();
		return Array.from(table.querySelectorAll("thead th[data-field]"))
			.filter(isVisible)
			.map((th) => {
				const field = th.getAttribute("data-field");
				const label =
					FIELD_LABEL[field] || normalizeText(th.textContent || field);
				return { field, label };
			})
			.filter((column) => {
				if (!column.field || seen.has(column.field)) return false;
				seen.add(column.field);
				return true;
			});
	}

	function loadSelectedFields(availableFields) {
		try {
			const parsed = JSON.parse(
				window.localStorage.getItem(STORAGE_KEY) || "null",
			);
			if (Array.isArray(parsed)) {
				return availableFields.filter((field) => parsed.includes(field));
			}
		} catch (_) {
			// Ignore broken localStorage data.
		}
		return availableFields.slice();
	}

	function saveSelectedFields(selectedFields) {
		try {
			window.localStorage.setItem(STORAGE_KEY, JSON.stringify(selectedFields));
		} catch (_) {
			// localStorage can be unavailable in strict browser configurations.
		}
	}

	function findSearchInput(table) {
		const container =
			table.closest(".react-bs-table-container") || table.parentElement;
		return (
			container?.querySelector(
				'.react-bs-table-search-form input[type="text"], input[placeholder="Search"]',
			) ||
			document.querySelector(
				'.react-bs-table-search-form input[type="text"], input[placeholder="Search"]',
			)
		);
	}

	function refreshSearch(tableInstance, input) {
		const value = input ? input.value : tableInstance.store.searchText || "";
		if (typeof tableInstance.handleSearch === "function") {
			tableInstance.handleSearch(value);
		} else if (input) {
			input.dispatchEvent(new Event("input", { bubbles: true }));
		}
	}

	function ensureStyle() {
		if (document.getElementById(STYLE_ID)) return;

		const style = document.createElement("style");
		style.id = STYLE_ID;
		style.textContent = `
			#${CONTROL_ID} {
				border: 1px solid rgba(0, 0, 0, 0.125);
				border-radius: 0.25rem;
				margin: 0.5rem 0;
				padding: 0.5rem 0.75rem;
				background: rgba(0, 0, 0, 0.02);
				font-size: 0.875rem;
			}
			#${CONTROL_ID} .acp-cls-title {
				font-weight: 600;
				margin-right: 0.75rem;
				white-space: nowrap;
			}
			#${CONTROL_ID} .acp-cls-body {
				display: flex;
				flex-wrap: wrap;
				align-items: center;
				gap: 0.35rem 0.75rem;
			}
			#${CONTROL_ID} label {
				margin: 0;
				white-space: nowrap;
				user-select: none;
			}
			#${CONTROL_ID} input[type="checkbox"] {
				margin-right: 0.25rem;
				vertical-align: middle;
			}
			#${CONTROL_ID} button {
				padding: 0.1rem 0.45rem;
				line-height: 1.4;
			}
			#${CONTROL_ID} .acp-cls-count {
				color: #666;
				white-space: nowrap;
			}
		`;
		document.head.appendChild(style);
	}

	function renderControls(table, tableInstance, columns) {
		ensureStyle();

		const availableFields = columns.map((column) => column.field);
		const selectedFields = loadSelectedFields(availableFields);
		selectedFieldsByTable.set(tableInstance, selectedFields);

		const old = document.getElementById(CONTROL_ID);
		if (old) old.remove();

		const root = document.createElement("div");
		root.id = CONTROL_ID;

		const body = document.createElement("div");
		body.className = "acp-cls-body";
		root.appendChild(body);

		const title = document.createElement("span");
		title.className = "acp-cls-title";
		title.textContent = "Column Limited Search:";
		body.appendChild(title);

		const inputs = [];
		for (const column of columns) {
			const label = document.createElement("label");

			const input = document.createElement("input");
			input.type = "checkbox";
			input.dataset.field = column.field;
			input.checked = selectedFields.includes(column.field);
			inputs.push(input);

			label.appendChild(input);
			label.appendChild(document.createTextNode(column.label));
			body.appendChild(label);
		}

		const allButton = document.createElement("button");
		allButton.type = "button";
		allButton.className = "btn btn-sm btn-outline-secondary";
		allButton.textContent = "All";
		body.appendChild(allButton);

		const noneButton = document.createElement("button");
		noneButton.type = "button";
		noneButton.className = "btn btn-sm btn-outline-secondary";
		noneButton.textContent = "None";
		body.appendChild(noneButton);

		const count = document.createElement("span");
		count.className = "acp-cls-count";
		body.appendChild(count);

		const searchInput = findSearchInput(table);

		const apply = () => {
			const nextSelectedFields = inputs
				.filter((input) => input.checked)
				.map((input) => input.dataset.field)
				.filter(Boolean);
			selectedFieldsByTable.set(tableInstance, nextSelectedFields);
			saveSelectedFields(nextSelectedFields);
			count.textContent = `${nextSelectedFields.length}/${inputs.length} selected`;
			refreshSearch(tableInstance, searchInput);
		};

		for (const input of inputs) {
			input.addEventListener("change", apply);
		}

		allButton.addEventListener("click", () => {
			inputs.forEach((input) => {
				input.checked = true;
			});
			apply();
		});

		noneButton.addEventListener("click", () => {
			inputs.forEach((input) => {
				input.checked = false;
			});
			apply();
		});

		count.textContent = `${selectedFields.length}/${inputs.length} selected`;

		const searchForm =
			searchInput &&
			(searchInput.closest(".react-bs-table-search-form") ||
				searchInput.parentElement);
		if (searchForm?.parentElement) {
			searchForm.parentElement.insertBefore(root, searchForm.nextSibling);
		} else {
			const container =
				table.closest(".react-bs-table-container") || table.parentElement;
			container.parentElement.insertBefore(root, container);
		}
	}

	function install() {
		if (!isProblemListPage()) {
			const old = document.getElementById(CONTROL_ID);
			if (old) old.remove();
			return;
		}

		const table = findProblemListTable();
		if (!table) return;

		const tableInstance = findBootstrapTableInstance(table);
		if (!tableInstance) return;

		const columns = collectVisibleColumns(table);
		if (columns.length === 0) return;

		patchSearch(tableInstance);

		const existing = document.getElementById(CONTROL_ID);
		const existingSignature = existing?.dataset.signature;
		const nextSignature = `${getTableInstanceId(tableInstance)}:${columns
			.map((column) => column.field)
			.join("|")}`;
		if (existing && existingSignature === nextSignature) {
			selectedFieldsByTable.set(
				tableInstance,
				loadSelectedFields(columns.map((c) => c.field)),
			);
			return;
		}

		renderControls(table, tableInstance, columns);
		const rendered = document.getElementById(CONTROL_ID);
		if (rendered) rendered.dataset.signature = nextSignature;
	}

	let scheduled = false;
	function scheduleInstall() {
		if (scheduled) return;
		scheduled = true;
		window.setTimeout(() => {
			scheduled = false;
			install();
		}, 200);
	}

	window.addEventListener("hashchange", scheduleInstall);
	window.addEventListener("popstate", scheduleInstall);

	const observer = new MutationObserver(scheduleInstall);
	observer.observe(document.documentElement, {
		childList: true,
		subtree: true,
	});

	scheduleInstall();
})();