Greasy Fork is available in English.

111477 Enhancements

Better search indexing, search filters, and starring.

이 스크립트를 설치하려면 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         111477 Enhancements
// @namespace    Craaab4
// @author       Craaab4
// @version      1.0
// @description  Better search indexing, search filters, and starring.
// @match        *://a.111477.xyz/*
// @run-at       document-end
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
	'use strict';

	const table = document.getElementById('fileTable');
	const searchInput = document.getElementById('search');
	if (!table || !searchInput) return;

	const tbody = table.tBodies[0];
	const parentRow = tbody.querySelector('tr[data-parent]');
	const entryRows = () => Array.from(tbody.querySelectorAll('tr[data-entry]'));

	// ── storage ──────────────────────────────────────────────────────────────
	const STAR_KEY = '111477_starred_v1';
	const SORT_KEY = '111477_sort_v1';
	const SEARCH_KEY = '111477_search_' + location.pathname;
	const FILT_KEY = '111477_filters_' + location.pathname;

	function getStars() {
		try { return new Set(JSON.parse(localStorage.getItem(STAR_KEY) || '[]')); }
		catch { return new Set(); }
	}
	function saveStars(s) { localStorage.setItem(STAR_KEY, JSON.stringify([...s])); }
	let starredSet = getStars();

	function saveSortState(col, order) { localStorage.setItem(SORT_KEY, JSON.stringify({ col, order })); }
	function getSortState() { try { return JSON.parse(localStorage.getItem(SORT_KEY)); } catch { return null; } }

	function saveFilterState() {
		localStorage.setItem(FILT_KEY, JSON.stringify({
			mode: activeMode,
			minWords: minWordsVal,
			maxWords: maxWordsVal,
			starredOnly: showStarredOnly,
		}));
	}
	function loadFilterState() {
		try { return JSON.parse(localStorage.getItem(FILT_KEY)) || {}; } catch { return {}; }
	}

	// ── utils ─────────────────────────────────────────────────────────────────
	function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
	function escapeHtml(s) {
		return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
	}
	function debounce(fn, ms) {
		let t;
		return (...a) => {
			clearTimeout(t);
			t = setTimeout(() => fn(...a), ms);
		};
	}
	function wordCount(name) { return name.trim().split(/[\s\-_.]+/).filter(Boolean).length; }

	// ── ranking (used in normal mode only) ───────────────────────────────────
	function getRank(lname, tokens, fullQuery) {
		if (!fullQuery) return 0;
		if (lname === fullQuery) return 0;
		if (lname.startsWith(fullQuery)) return 1;
		const wb = new RegExp('(^|[^a-z0-9])' + escapeRegex(fullQuery) + '([^a-z0-9]|$)');
		if (wb.test(lname)) return 2;
		const allWB = tokens.every(t => new RegExp('(^|[^a-z0-9])' + escapeRegex(t) + '([^a-z0-9]|$)').test(lname));
		if (allWB) return 3;
		const allSub = tokens.every(t => lname.includes(t));
		if (allSub) return 4;
		return -1;
	}

	function highlightTokens(text, tokens) {
		if (!tokens.length) return escapeHtml(text);
		const pat = tokens.map(escapeRegex).join('|');
		return escapeHtml(text).replace(new RegExp('(' + pat + ')', 'ig'), '<mark>$1</mark>');
	}
	function highlightRegex(text, re) {
		return escapeHtml(text).replace(re, m => '<mark>' + escapeHtml(m) + '</mark>');
	}

	// ── inject styles ─────────────────────────────────────────────────────────
	document.head.insertAdjacentHTML('beforeend', `<style>
		/* layout */
		#enh111477-search-wrap { margin-bottom: 6px; }
		.enh111477-row { display: flex; align-items: stretch; gap: 8px; margin-bottom: 6px; }
		.enh111477-row input#search { margin-bottom: 0; flex: 1; min-width: 0; }

		/* filter bar */
		#enh111477-filter-bar {
			display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
			padding: 6px 8px; background: #1a1a1a; border: 1px solid #2a2a2a;
			border-radius: 4px; margin-bottom: 6px;
		}
		#enh111477-filter-bar label { color: #888; font-size: 0.78rem; white-space: nowrap; }
		.enh111477-mode-btn {
			background: #1e1e1e; border: 1px solid #333; color: #aaa;
			padding: 4px 10px; border-radius: 4px; cursor: pointer;
			font-size: 0.78rem; white-space: nowrap; font-family: monospace;
			transition: all .15s;
		}
		.enh111477-mode-btn:hover { border-color: #4da6ff; color: #4da6ff; }
		.enh111477-mode-btn.active { background: #4da6ff22; border-color: #4da6ff; color: #4da6ff; }
		.enh111477-sep { color: #333; user-select: none; }
		#enh111477-minwords-wrap { display: flex; align-items: center; gap: 5px; color: #888; font-size: 0.78rem; }
		#enh111477-minwords, #enh111477-maxwords {
			width: 44px; padding: 3px 6px; background: #1e1e1e; border: 1px solid #333;
			color: #fff; border-radius: 4px; font-size: 0.78rem; text-align: center; margin: 0;
		}
		#enh111477-minwords:focus, #enh111477-maxwords:focus { border-color: #4da6ff; outline: none; }
		#enh111477-regex-error { color: #ff6b6b; font-size: 0.75rem; margin-left: 4px; display: none; }

		/* star button */
		.enh111477-star-col { width: 30px; text-align: center; }
		.enh111477-star-btn {
			background: none; border: none; cursor: pointer; font-size: 1rem;
			color: #555; padding: 0 3px; line-height: 1; transition: color .1s;
		}
		.enh111477-star-btn:hover { color: #ffd24d; }
		.enh111477-star-btn.starred { color: #ffd24d; }

		/* starred-only button */
		.enh111477-staronlybtn {
			background: #1e1e1e; border: 1px solid #333; color: #aaa;
			padding: 0 12px; border-radius: 4px; cursor: pointer;
			font-size: 0.85rem; white-space: nowrap; transition: all .15s;
		}
		.enh111477-staronlybtn:hover { border-color: #ffd24d; color: #ffd24d; }
		.enh111477-staronlybtn.active { background: #ffd24d22; border-color: #ffd24d; color: #ffd24d; }

		/* result count + mark */
		#enh111477-count { color: #666; font-size: 0.8rem; margin-bottom: 8px; min-height: 1em; }
		mark { background: #4da6ff44; color: inherit; border-radius: 2px; padding: 0 1px; }

	</style>`);

	// ── build UI ──────────────────────────────────────────────────────────────
	const wrap = document.createElement('div');
	wrap.id = 'enh111477-search-wrap';
	searchInput.parentNode.insertBefore(wrap, searchInput);

	// row 1: search input + starred-only btn
	const row1 = document.createElement('div');
	row1.className = 'enh111477-row';
	searchInput.removeAttribute('oninput');
	row1.appendChild(searchInput);

	const starOnlyBtn = document.createElement('button');
	starOnlyBtn.type = 'button';
	starOnlyBtn.className = 'enh111477-staronlybtn';
	starOnlyBtn.textContent = '☆ Starred';
	row1.appendChild(starOnlyBtn);
	wrap.appendChild(row1);

	// row 2: filter bar
	const filterBar = document.createElement('div');
	filterBar.id = 'enh111477-filter-bar';
	filterBar.innerHTML = `
		<label>Mode:</label>
		<button type="button" class="enh111477-mode-btn active" data-mode="normal" title="Smart ranked search — all query words must appear">Normal</button>
		<button type="button" class="enh111477-mode-btn" data-mode="exact" title="Full name must exactly equal the query (case-insensitive)">Exact</button>
		<button type="button" class="enh111477-mode-btn" data-mode="regex" title="Treat query as a regular expression, e.g. ^From$">Regex</button>
		<span class="enh111477-sep">|</span>
		<div id="enh111477-minwords-wrap">
			<label for="enh111477-minwords">Min words:</label>
			<input id="enh111477-minwords" type="number" min="0" max="99" value="0" title="Only show entries whose name has at least this many words">
			<label for="enh111477-maxwords">Max:</label>
			<input id="enh111477-maxwords" type="number" min="0" max="99" value="0" placeholder="∞" title="Only show entries whose name has at most this many words (0 = no limit)">
		</div>
		<span id="enh111477-regex-error">⚠ invalid regex</span>
	`;
	wrap.appendChild(filterBar);

	// count line
	const countEl = document.createElement('div');
	countEl.id = 'enh111477-count';
	wrap.appendChild(countEl);

	// ── filter state ──────────────────────────────────────────────────────────
	let activeMode = 'normal'; // 'normal' | 'exact' | 'regex'
	let minWordsVal = 0;
	let maxWordsVal = 0; // 0 = no limit
	let showStarredOnly = false;
	let compiledRegex = null;

	// restore persisted filter state
	const savedFilters = loadFilterState();
	if (savedFilters.mode) activeMode = savedFilters.mode;
	if (savedFilters.minWords) minWordsVal = savedFilters.minWords;
	if (savedFilters.maxWords) maxWordsVal = savedFilters.maxWords;
	if (savedFilters.starredOnly) showStarredOnly = savedFilters.starredOnly;

	// apply restored states to UI
	filterBar.querySelectorAll('.enh111477-mode-btn').forEach(btn => {
		btn.classList.toggle('active', btn.dataset.mode === activeMode);
	});
	document.getElementById('enh111477-minwords').value = minWordsVal;
	document.getElementById('enh111477-maxwords').value = maxWordsVal || '';
	if (showStarredOnly) {
		starOnlyBtn.classList.add('active');
		starOnlyBtn.textContent = '★ Starred';
	}

	// mode buttons
	filterBar.querySelectorAll('.enh111477-mode-btn').forEach(btn => {
		btn.addEventListener('click', () => {
			activeMode = btn.dataset.mode;
			filterBar.querySelectorAll('.enh111477-mode-btn').forEach(b => b.classList.toggle('active', b === btn));
			saveFilterState();
			applyCurrentView();
		});
	});

	// min words
	document.getElementById('enh111477-minwords').addEventListener('input', function () {
		minWordsVal = Math.max(0, parseInt(this.value) || 0);
		saveFilterState();
		debouncedApply();
	});

	// max words
	document.getElementById('enh111477-maxwords').addEventListener('input', function () {
		maxWordsVal = Math.max(0, parseInt(this.value) || 0);
		saveFilterState();
		debouncedApply();
	});

	// starred only
	starOnlyBtn.addEventListener('click', () => {
		showStarredOnly = !showStarredOnly;
		starOnlyBtn.classList.toggle('active', showStarredOnly);
		starOnlyBtn.textContent = showStarredOnly ? '★ Starred' : '☆ Starred';
		saveFilterState();
		applyCurrentView();
	});

	// ── star column ───────────────────────────────────────────────────────────
	const headerRow = table.tHead.rows[0];
	const starTh = document.createElement('th');
	starTh.textContent = '★';
	starTh.style.cssText = 'width:30px;text-align:center;cursor:default;';
	headerRow.insertBefore(starTh, headerRow.cells[1]);

	if (parentRow) {
		parentRow.insertBefore(document.createElement('td'), parentRow.cells[1]);
	}

	entryRows().forEach(row => {
		const url = row.dataset.url;
		const td = document.createElement('td');
		td.className = 'enh111477-star-col';
		const btn = document.createElement('button');
		btn.type = 'button';
		btn.className = 'enh111477-star-btn' + (starredSet.has(url) ? ' starred' : '');
		btn.textContent = starredSet.has(url) ? '★' : '☆';
		btn.title = 'Star / unstar';
		btn.addEventListener('click', e => {
			e.preventDefault(); e.stopPropagation();
			if (starredSet.has(url)) {
				starredSet.delete(url);
				btn.textContent = '☆'; btn.classList.remove('starred');
			} else {
				starredSet.add(url);
				btn.textContent = '★'; btn.classList.add('starred');
			}
			saveStars(starredSet);
			if (showStarredOnly) applyCurrentView();
		});
		td.appendChild(btn);
		row.insertBefore(td, row.cells[1]);
	});

	function updateSelectedCount() {
		if (typeof window.updateSelectedCount === 'function') {
			window.updateSelectedCount();
		}
	}
	let naturalOrder = entryRows();
	function updateNaturalOrder() { naturalOrder = entryRows(); }
	function restoreNaturalOrder() {
		naturalOrder.forEach(r => tbody.appendChild(r));
		if (parentRow) tbody.insertBefore(parentRow, tbody.firstChild);
	}

	// ── wrap sortTable ────────────────────────────────────────────────────────
	const origSort = window.sortTable;
	window.sortTable = function (n, isNumeric) {
		origSort(n, isNumeric);
		updateNaturalOrder();
		saveSortState(n, table.dataset.sortOrder || 'asc');
		applyCurrentView();
	};

	// ── main render ───────────────────────────────────────────────────────────
	function applyCurrentView() {
		const raw = searchInput.value.trim();
		const lower = raw.toLowerCase();
		const tokens = lower.split(/\s+/).filter(Boolean);
		const hasQuery = raw.length > 0;
		const regexErr = document.getElementById('enh111477-regex-error');

		// compile regex if needed
		compiledRegex = null;
		regexErr.style.display = 'none';
		if (activeMode === 'regex' && hasQuery) {
			try {
				compiledRegex = new RegExp(raw, 'i');
			} catch {
				regexErr.style.display = '';
				// show all (or starred-only), don't crash
			}
		}

		const filterActive = hasQuery || showStarredOnly || minWordsVal > 0 || maxWordsVal > 0;
		const copyBar = document.getElementById('copyAllBar');
		if (filterActive) {
			table.classList.add('searching');
			if (copyBar) copyBar.classList.add('show');
		} else {
			table.classList.remove('searching');
			if (copyBar) copyBar.classList.remove('show');
			document.querySelectorAll('#fileTable tbody input[type=checkbox]').forEach(cb => cb.checked = false);
			const selAll = document.getElementById('selectAllCheckbox');
			if (selAll) selAll.checked = false;
		}

		let visibleCount = 0;
		const scored = [];

		entryRows().forEach(row => {
			const rawName = row.dataset.name || '';
			const lname = rawName.toLowerCase();
			const isStarred = starredSet.has(row.dataset.url);
			let matches = true;
			let rank = 0;

			// word count gate
			const wc = wordCount(rawName);
			if (minWordsVal > 0 && wc < minWordsVal) matches = false;
			if (maxWordsVal > 0 && wc > maxWordsVal) matches = false;

			// starred gate
			if (showStarredOnly && !isStarred) matches = false;

			// search gate
			if (matches && hasQuery) {
				if (activeMode === 'exact') {
					matches = lname === lower;
					rank = 0;
				} else if (activeMode === 'regex') {
					matches = compiledRegex ? compiledRegex.test(rawName) : true;
					rank = 0;
				} else {
					// normal ranked
					rank = getRank(lname, tokens, lower);
					matches = rank >= 0;
				}
			}

			row.style.display = matches ? '' : 'none';
			if (matches) visibleCount++;

			// highlighting
			const anchor = row.querySelector('a[href]');
			if (anchor) {
				if (!anchor.dataset.orig) anchor.dataset.orig = anchor.textContent;
				if (matches && hasQuery && activeMode !== 'exact') {
					if (activeMode === 'regex' && compiledRegex) {
						anchor.innerHTML = highlightRegex(anchor.dataset.orig, new RegExp(raw, 'ig'));
					} else if (activeMode === 'normal') {
						anchor.innerHTML = highlightTokens(anchor.dataset.orig, tokens);
					} else {
						anchor.textContent = anchor.dataset.orig;
					}
				} else {
					anchor.textContent = anchor.dataset.orig;
				}
			}

			if (activeMode === 'normal' && hasQuery) scored.push({ row, rank, name: lname });
		});

		// re-sort rows by rank in normal mode, otherwise keep natural order
		if (activeMode === 'normal' && hasQuery) {
			scored.sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
			scored.forEach(s => tbody.appendChild(s.row));
			if (parentRow) tbody.insertBefore(parentRow, tbody.firstChild);
		} else {
			restoreNaturalOrder();
		}

		updateSelectedCount();

		// count display
		if (filterActive) {
			let parts = [];
			if (hasQuery) parts.push(`query: "${raw}"`);
			if (activeMode !== 'normal') parts.push(activeMode + ' mode');
			if (minWordsVal > 0 && maxWordsVal > 0) parts.push(`${minWordsVal}–${maxWordsVal} words`);
			else if (minWordsVal > 0) parts.push(`≥${minWordsVal} words`);
			else if (maxWordsVal > 0) parts.push(`≤${maxWordsVal} words`);
			if (showStarredOnly) parts.push('starred only');
			countEl.textContent = `${visibleCount} result${visibleCount === 1 ? '' : 's'}  ·  ${parts.join('  ·  ')}`;
		} else {
			countEl.textContent = '';
		}
	}

	window.filterTable = applyCurrentView;

	const debouncedApply = debounce(applyCurrentView, 60);
	searchInput.addEventListener('input', debouncedApply);

	// ── restore state on load ─────────────────────────────────────────────────
	localStorage.removeItem(SEARCH_KEY); // don't restore search on reload

	const savedSort = getSortState();
	if (savedSort && Number.isInteger(savedSort.col)) {
		const isNumeric = savedSort.col === 2;
		origSort(savedSort.col, isNumeric);
		if (savedSort.order === 'desc') origSort(savedSort.col, isNumeric);
		updateNaturalOrder();
	} else {
		updateNaturalOrder();
	}

	applyCurrentView();
})();