AtCoder Problems Column Limited Search

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като 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();
})();