AtCoder Problems Column Limited Search

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

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 or Violentmonkey 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.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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