111477 Enhancements

Better search indexing, search filters, and starring.

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.

(I already have a user script manager, let me install it!)

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