// ==UserScript==
// @name crwflags tool
// @license MIT
// @namespace http://tampermonkey.net/
// @version 1.1
// @description tool for crwflags.com
// @match *://www.crwflags.com/fotw/flags/*
// @grant GM_addStyle
// @require https://unpkg.com/[email protected]/dist/fuse.min.js
// @run-at document-idle
// ==/UserScript==
(async function () {
'use strict';
/* ========== CONFIG ========== */
const BASE = 'https://www.crwflags.com/fotw/flags/';
const DB_NAME = 'crwflags_tool';
const STORE_NAME = 'flags';
const FETCH_TIMEOUT_MS = 12_000;
const KEYWORD_THROTTLE_MS = 90;
const FAV_KEY = 'fotw_favs_v6';
const LONG_NAME_MIN_LENGTH = 40;
/* ========== UI ========== */
GM_addStyle(`
#fotwPanel{ position:fixed; top:48px; right:12px; width:360px; max-height:82vh; overflow-y:auto; background:#003399; padding:14px; border-radius:10px; font-family:Arial,sans-serif; color:#fff; box-shadow:0 0 18px rgba(0,0,0,0.7); z-index:2147483647; }
#fotwHeader{ font-size:18px; font-weight:700; margin-bottom:10px; text-align:center; user-select:none; }
#fotwSearchInput, #fotwCategoryFilter{ width:100%; padding:8px; font-size:14px; border-radius:6px; border:none; margin-bottom:8px; box-sizing:border-box; }
#fotwButtonsRow{ display:grid; grid-template-columns:repeat(2,1fr); gap:6px; justify-items:stretch; margin-bottom:8px; }
#fotwButtonsRow button{ background:#0055cc; border:none; border-radius:6px; padding:8px 6px; color:#fff; font-size:13px; cursor:pointer; }
#fotwButtonsRow button:hover{ background:#0077ff; }
#fotwSearchResults{ background:#fff; color:#000; max-height:260px; overflow-y:auto; border-radius:6px; display:none; font-size:13px; margin-bottom:8px; }
#fotwSearchResults div{ padding:8px 10px; border-bottom:1px solid #ddd; cursor:pointer; }
#fotwSearchResults div:hover{ background:#eee; }
#fotwFlagInfo{ background:#002266; border-radius:8px; padding:10px; font-size:13px; min-height:56px; line-height:1.3em; word-break:break-word; }
button[disabled]{ opacity:0.55; cursor:not-allowed; }
`);
const panel = document.createElement('div');
panel.id = 'fotwPanel';
panel.innerHTML = `
<div id="fotwHeader">⚑ crwflags tool</div>
<input id="fotwSearchInput" type="search" placeholder="Search flags..." disabled>
<select id="fotwCategoryFilter" disabled>
<option value="all">All Categories</option>
<option value="country">Countries</option>
<option value="historical">Historical</option>
<option value="regional">Regional</option>
<option value="maritime">Maritime</option>
<option value="obscure">Obscure</option>
</select>
<div id="fotwButtonsRow">
<button id="btnRandom" disabled>🎲 Random</button>
<button id="btnRandomLongName" disabled>📝 Long Name</button>
<button id="btnRandomHistorical" disabled>🏛 Historical</button>
<button id="btnRandomObscure" disabled>🏴 Obscure</button>
<button id="btnPrev" disabled>⬅ Prev</button>
<button id="btnNext" disabled>Next ➡</button>
<button id="btnFavToggle" disabled>⭐ Fav</button>
<button id="btnViewFavs" disabled>📂 View Favs</button>
<button id="btnRandomFav" disabled>🎯 Random Fav</button>
<button id="btnRebuild" disabled>🔄 Rebuild Full DB</button>
</div>
<div id="fotwStatus" style="font-size:13px;min-height:18px;margin-bottom:6px;">Initializing...</div>
<div id="fotwSearchResults"></div>
<div id="fotwFlagInfo"></div>
`;
document.body.appendChild(panel);
const statusEl = document.getElementById('fotwStatus');
const searchEl = document.getElementById('fotwSearchInput');
const resultsEl = document.getElementById('fotwSearchResults');
const infoEl = document.getElementById('fotwFlagInfo');
const categoryFilterEl = document.getElementById('fotwCategoryFilter');
const btnRandom = document.getElementById('btnRandom');
const btnRandomLongName = document.getElementById('btnRandomLongName');
const btnRandomHistorical = document.getElementById('btnRandomHistorical');
const btnRandomObscure = document.getElementById('btnRandomObscure');
const btnPrev = document.getElementById('btnPrev');
const btnNext = document.getElementById('btnNext');
const btnFavToggle = document.getElementById('btnFavToggle');
const btnViewFavs = document.getElementById('btnViewFavs');
const btnRandomFav = document.getElementById('btnRandomFav');
const btnRebuild = document.getElementById('btnRebuild');
/* ========== UTIL ========== */
const sleep = ms => new Promise(r => setTimeout(r, ms));
function escapeHtml(s) { return (s||'').replace(/[&<>\"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} finally {
clearTimeout(timeout);
}
}
async function waitForFuse(timeoutMs = 5000) {
const start = Date.now();
while (typeof window.Fuse === 'undefined') {
if (Date.now() - start > timeoutMs) throw new Error("Fuse.js failed to load");
await sleep(50);
}
}
/* ========== IndexedDB helpers ========== */
function openDB() { return new Promise((resolve, reject) => { const r=indexedDB.open(DB_NAME,1); r.onupgradeneeded=e=>{const d=e.target.result;if(!d.objectStoreNames.contains(STORE_NAME))d.createObjectStore(STORE_NAME,{keyPath:'id'})}; r.onsuccess=()=>resolve(r.result); r.onerror=()=>reject(r.error) }); }
function idbGetAll(db) { return new Promise((resolve, reject) => { try{const t=db.transaction(STORE_NAME,'readonly').objectStore(STORE_NAME).getAll();t.onsuccess=()=>resolve(t.result||[]); t.onerror=()=>reject(t.error)}catch(e){reject(e)} }); }
function idbPutBatch(db, items) { return new Promise((resolve, reject) => { try{const t=db.transaction(STORE_NAME,'readwrite');const s=t.objectStore(STORE_NAME);items.forEach(it=>s.put(it));t.oncomplete=()=>resolve();t.onerror=()=>reject(t.error)}catch(e){reject(e)} }); }
/* ========== STATE ========== */
let fuse = null;
let masterList = [];
let dbInst = null;
let rebuilding = false;
let currentList = [];
let currentIndex = -1;
let currentItem = null;
function safeFuseCreate() { try { fuse = new Fuse(masterList, { keys: ['name'], threshold: 0.3 }); } catch (e) { console.warn('Fuse creation failed', e); fuse = null; } }
function enableUI() { document.querySelectorAll('#fotwPanel button, #fotwPanel input, #fotwPanel select').forEach(el => el.disabled = false); }
function disableUI() { document.querySelectorAll('#fotwPanel button, #fotwPanel input, #fotwPanel select').forEach(el => el.disabled = true); }
function setStatus(msg) { statusEl.textContent = msg; }
/* ========== CORE ID HELPERS (DO NOT CHANGE) ========== */
function filenameFromId(id) {
if (!id) return '';
let seg = id.split('/').pop();
return seg.split('?')[0].split('#')[0].toLowerCase();
}
function isTwoLetterCountryId(id) {
const fn = filenameFromId(id);
return /^[a-z]{2}(?:\.html?)?$/.test(fn);
}
function isIndexPageId(id) {
const fn = filenameFromId(id);
return /^keyword[a-z]\.html?$/.test(fn);
}
/* ========== Rebuild masterList ========== */
async function rebuildMasterList(db) {
if (rebuilding) return;
rebuilding = true;
disableUI();
btnRebuild.disabled = true;
setStatus('Rebuilding DB...');
const newMap = new Map();
try {
const letters = 'abcdefghijklmnopqrstuvwxyz';
for (const ch of letters) {
const path = `${BASE}keyword${ch}.html`;
let html = '';
try { html = await fetchWithTimeout(path); }
catch (e) { console.warn(`letter ${ch} failed`, e); await sleep(KEYWORD_THROTTLE_MS); continue; }
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const a of doc.querySelectorAll('a[href]')) {
const href = a.getAttribute('href');
if (!href) continue;
const url = new URL(href, path).href;
if (url.startsWith(BASE)) {
const id = url.slice(BASE.length);
const name = (a.textContent || '').trim() || id;
if (!newMap.has(id)) newMap.set(id, { id, name, url, category: 'misc' });
}
}
setStatus(`Rebuild: scanned letter '${ch.toUpperCase()}' (${newMap.size} items)`);
await sleep(KEYWORD_THROTTLE_MS);
}
let allItems = Array.from(newMap.values());
setStatus(`Categorizing ${allItems.length} links...`);
await sleep(100);
const obscureKeywords = /\b(proposed|fictional|micronation|unofficial|club|football|society|organization|personal|local custom|movement|variant|political|party|separatist|ethnic|company|house flag|aspirant|cultural|sports|association|UFE|unidentified)\b/i;
for (const item of allItems) {
if (isTwoLetterCountryId(item.id)) { item.category = 'country'; continue; }
if (isIndexPageId(item.id)) { continue; } // Keep as misc, will be filtered out
const name = item.name.toLowerCase();
if (/\b(historical|history|former|formerly|obsolete)\b/i.test(name)) item.category = 'historical';
else if (/\b(ensign|naval|jack|pennant|burgee|yacht|ship|maritime|merchant)\b/i.test(name)) item.category = 'maritime';
else if (/\b(region|province|municipal|city|county|prefecture|oblast|krai|state flag|provincial)\b/i.test(name)) item.category = 'regional';
else if (obscureKeywords.test(name)) item.category = 'obscure';
else if (item.name.split(/\s+/).length <= 3) item.category = 'regional';
else {
// --- THIS IS THE KEY CHANGE ---
// If it's none of the above, it's obscure by default.
item.category = 'obscure';
}
}
masterList = allItems.filter(it => it.category !== 'misc'); // Remove index pages from final list
await idbPutBatch(db, masterList);
safeFuseCreate();
setStatus(`Rebuild complete: ${masterList.length} items.`);
} catch (e) {
setStatus(`Rebuild failed: ${e.message}`);
console.error(e);
} finally {
rebuilding = false;
enableUI();
}
}
/* ========== Navigation and Selection ========== */
function updateCurrentItemDisplay() {
if (!currentItem) {
infoEl.innerHTML = 'No item selected.';
btnPrev.disabled = btnNext.disabled = btnFavToggle.disabled = true;
return;
}
infoEl.innerHTML = `<b>${escapeHtml(currentItem.name)}</b> <br><small style="opacity:.7;">[${escapeHtml(currentItem.category)}]</small> <br><a href="${escapeHtml(currentItem.url)}" target="_blank" rel="noopener noreferrer">Open page</a>`;
updateFavButton();
btnPrev.disabled = currentIndex <= 0;
btnNext.disabled = currentIndex >= currentList.length - 1;
btnFavToggle.disabled = false;
}
function goToItem(item) {
if (!item) return;
location.href = item.url;
}
// --- BULLETPROOF FUNCTION ---
// This function performs a LIVE check and is guaranteed to work.
function pickTrulyRandomObscure() {
if (!masterList.length) return null;
// 1. Start with the entire list of non-country flags. This is our potential pool.
let pool = masterList.filter(it => !isTwoLetterCountryId(it.id));
// 2. Further refine by removing any index pages that might have slipped through.
pool = pool.filter(it => !isIndexPageId(it.id));
// 3. For the best experience, also remove major, well-defined categories.
pool = pool.filter(it => it.category !== 'country' && it.category !== 'historical' && it.category !== 'maritime');
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
/* ========== Favorites ========== */
let favorites = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]'));
function saveFavs() { localStorage.setItem(FAV_KEY, JSON.stringify(Array.from(favorites))); }
function toggleFav(id) { if(id){ if(favorites.has(id))favorites.delete(id); else favorites.add(id); saveFavs(); updateFavButton(); } }
function updateFavButton() { const curId = currentItem ? currentItem.id : null; btnFavToggle.textContent = favorites.has(curId) ? '⭐ Unfav' : '⭐ Fav'; }
/* ========== Events ========== */
searchEl.addEventListener('input', e => {
const term = (e.target.value||'').trim();
if (!term) { resultsEl.style.display = 'none'; resultsEl.innerHTML = ''; return; }
const cat = categoryFilterEl.value;
let results = fuse.search(term).slice(0, 80).map(r => r.item);
if (cat && cat !== 'all') results = results.filter(it => it.category === cat);
resultsEl.innerHTML = results.map(it => `<div data-url="${escapeHtml(it.url)}">${escapeHtml(it.name)} <span style="opacity:.6;font-size:11px">[${escapeHtml(it.category||'misc')}]</span></div>`).join('');
resultsEl.style.display = 'block';
});
resultsEl.addEventListener('click', e => { const div = e.target.closest('div[data-url]'); if(div) location.href = div.dataset.url; });
categoryFilterEl.addEventListener('change', () => searchEl.dispatchEvent(new Event('input')));
btnRandom.addEventListener('click', () => {
const cat = categoryFilterEl.value;
let item;
if (cat === 'obscure') item = pickTrulyRandomObscure();
else {
const pool = cat === 'all' ? masterList : masterList.filter(it => it.category === cat);
if (pool.length) item = pool[Math.floor(Math.random() * pool.length)];
}
if (item) goToItem(item);
else alert(`No items found in category "${cat}".`);
});
btnRandomObscure.addEventListener('click', () => {
const r = pickTrulyRandomObscure();
if (r) goToItem(r);
else alert('No obscure flags found. Please try rebuilding the database.');
});
btnRandomLongName.addEventListener('click', () => {
const pool = masterList.filter(it => it.name.length >= LONG_NAME_MIN_LENGTH);
if (pool.length) goToItem(pool[Math.floor(Math.random() * pool.length)]);
else alert(`No flags found with long names (≥ ${LONG_NAME_MIN_LENGTH} chars).`);
});
btnRandomHistorical.addEventListener('click', () => {
const pool = masterList.filter(it => it.category === 'historical');
if (pool.length) goToItem(pool[Math.floor(Math.random() * pool.length)]);
});
btnPrev.addEventListener('click', () => { if (currentIndex > 0) goToItem(currentList[currentIndex - 1]); });
btnNext.addEventListener('click', () => { if (currentIndex < currentList.length - 1) goToItem(currentList[currentIndex + 1]); });
btnFavToggle.addEventListener('click', () => { if (currentItem) toggleFav(currentItem.id); });
btnViewFavs.addEventListener('click', () => {
const favArray = masterList.filter(it => favorites.has(it.id));
if (!favArray.length) { alert('No favorites yet.'); return; }
goToItem(favArray[0]);
});
btnRandomFav.addEventListener('click', () => {
const favArray = masterList.filter(it => favorites.has(it.id));
if (!favArray.length) { alert('No favorites yet.'); return; }
goToItem(favArray[Math.floor(Math.random() * favArray.length)]);
});
btnRebuild.addEventListener('click', () => {
if (rebuilding) return;
if (confirm('This will rebuild the flag database with the new, broader "obscure" category.\nThis is recommended and can take a minute.\nContinue?')) {
rebuildMasterList(dbInst);
}
});
/* ========== INITIALIZE ========== */
async function initialize() {
setStatus('Loading data...');
disableUI();
try {
await waitForFuse();
dbInst = await openDB();
masterList = await idbGetAll(dbInst);
if (!masterList.length) {
setStatus('No local DB. Rebuilding...');
await rebuildMasterList(dbInst);
}
setStatus(`Loaded ${masterList.length} flags.`);
safeFuseCreate();
enableUI();
const currentPageId = location.href.startsWith(BASE) ? location.href.slice(BASE.length) : null;
currentItem = currentPageId ? masterList.find(it => it.id === currentPageId) : null;
if (currentItem) {
currentList = masterList.filter(it => it.category === currentItem.category && !isIndexPageId(it.id) && !isTwoLetterCountryId(it.id));
currentIndex = currentList.findIndex(it => it.id === currentPageId);
}
updateCurrentItemDisplay();
} catch (e) {
setStatus('Error on init: ' + e.message);
console.error(e);
btnRebuild.disabled = false;
}
}
initialize();
})();