AtCoder Problems Column Limited Search

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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