diff --git a/.github/workflows/program-classification-validator.yml b/.github/workflows/program-classification-validator.yml index 95657f51b4..56e8afbcf9 100644 --- a/.github/workflows/program-classification-validator.yml +++ b/.github/workflows/program-classification-validator.yml @@ -81,24 +81,30 @@ jobs: // ------------------------------------ // Metadata // ------------------------------------ + + const normalizedBody = body.replace(/\r\n/g, '\n'); + const has = (re) => re.test(normalizedBody); if ( - body.includes('program: gssoc') || - body.includes('- [x] gssoc') + has(/\bprogram\s*:\s*gssoc\b/i) || + has(/^\s*[-*]\s*\[x\]\s*(gssoc|i am contributing under gssoc|i am participating via gssoc)\b/im) || + has(/\bprogram\n\s*gssoc\b/i) ) { detected.add('gssoc'); } if ( - body.includes('program: nsoc') || - body.includes('- [x] nsoc') + has(/\bprogram\s*:\s*nsoc\b/i) || + has(/^\s*[-*]\s*\[x\]\s*(nsoc|i am contributing under nsoc|i am participating via nsoc)\b/im) || + has(/\bprogram\n\s*nsoc\b/i) ) { detected.add('nsoc'); } if ( - body.includes('program: general') || - body.includes('- [x] general contribution') + has(/\bprogram\s*:\s*general\b/i) || + has(/^\s*[-*]\s*\[x\]\s*(general contribution|i am a general contributor)\b/im) || + has(/\bprogram\n\s*general\b/i) ) { detected.add('general'); } diff --git a/index.html b/index.html index 09b54a5756..80eef1025b 100644 --- a/index.html +++ b/index.html @@ -38,35 +38,69 @@ } - - - - - @@ -75,7 +109,12 @@ // Theme restoration - runs early to prevent FOUC (function(){ try { - if (localStorage.getItem('theme') === 'dark') { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'dark') { + document.documentElement.classList.add('dark'); + } else if (savedTheme === 'light') { + document.documentElement.classList.remove('dark'); + } else if (globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches) { document.documentElement.classList.add('dark'); } } catch(e) { diff --git a/src/js/app.js b/src/js/app.js index d1c9415db2..1a64404e71 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -109,9 +109,26 @@ const CONTACT_TIPS = { // ══════════════════════════════════════════════ (function initTheme() { try { - const saved = localStorage.getItem('theme') || 'light'; - document.documentElement.classList.toggle('dark', saved === 'dark'); + const saved = localStorage.getItem('theme'); + if (saved === 'dark') { + document.documentElement.classList.add('dark'); + } else if (saved === 'light') { + document.documentElement.classList.remove('dark'); + } else if (globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } updateThemeIcon(); + + if (globalThis.matchMedia) { + globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { + if (!localStorage.getItem('theme')) { + document.documentElement.classList.toggle('dark', e.matches); + updateThemeIcon(); + } + }); + } } catch (e) { console.warn('Theme init failed:', e); } @@ -1071,9 +1088,17 @@ function searchComparator(a, b, search, sort) { if (nameB === search && nameA !== search) return 1; if (nameA.startsWith(search) && !nameB.startsWith(search)) return -1; if (nameB.startsWith(search) && !nameA.startsWith(search)) return 1; + + if (globalThis.orgSearchScores && globalThis.orgSearchScores.has(a.name) && globalThis.orgSearchScores.has(b.name)) { + const diff = globalThis.orgSearchScores.get(a.name) - globalThis.orgSearchScores.get(b.name); + if (diff !== 0) return diff; + } + return applySecondarySort(a, b, sort); } +let orgSearchFuse = null; + function applyFilters() { const search = (document.getElementById('searchInput')?.value || '').trim().toLowerCase(); const categoryValue = document.getElementById('categoryFilter')?.value || 'all'; @@ -1081,7 +1106,30 @@ function applyFilters() { const compF = document.getElementById('complexityFilter')?.value || 'all'; const sort = document.getElementById('sortSelect')?.value || 'alpha'; - filteredOrgs = ORGS.filter(o => matchesFilters(o, cat, compF, search)); + globalThis.orgSearchScores = new Map(); + let baseOrgs = ORGS; + let useFallbackSearch = false; + + if (search) { + if (typeof Fuse !== 'undefined') { + if (!orgSearchFuse) { + orgSearchFuse = new Fuse(ORGS, { + includeScore: true, + threshold: 0.3, + keys: ['name', 'tags', 'cat'] + }); + } + const results = orgSearchFuse.search(search); + baseOrgs = results.map(r => { + globalThis.orgSearchScores.set(r.item.name, r.score); + return r.item; + }); + } else { + useFallbackSearch = true; + } + } + + filteredOrgs = baseOrgs.filter(o => matchesFilters(o, cat, compF, useFallbackSearch ? search : '')); // Smart sorting: Exact match first, startsWith second, alphabetic/secondary sort third if (search) { @@ -2622,6 +2670,7 @@ if (typeof module !== 'undefined' && module.exports) { githubUrlFromValue, orgMatchesLanguages, applySecondarySort, + searchComparator, openModal, renderModalHeader, closeModal, diff --git a/tests/filtering.test.js b/tests/filtering.test.js index 44950f4191..123915f5f5 100644 --- a/tests/filtering.test.js +++ b/tests/filtering.test.js @@ -27,7 +27,7 @@ globalThis.document = { globalThis.ORGS = require('../src/js/org.js'); // Import the modules -const { orgMatchesLanguages, applySecondarySort } = require('../src/js/app.js'); +const { orgMatchesLanguages, applySecondarySort, searchComparator } = require('../src/js/app.js'); require('../src/js/skillExtractor.js'); // Populates LANGUAGE_MAP on global test('orgMatchesLanguages returns true when languages set is empty', () => { @@ -76,3 +76,29 @@ test('applySecondarySort sorts correctly', () => { // alphabetical sort (default) assert.ok(applySecondarySort(a, b, 'alpha') < 0); }); + +test('searchComparator sorts with fuzzy scores', () => { + const a = { name: 'The Python Foundation', years: 10, competition: 'hot' }; + const b = { name: 'A Pyhton Group', years: 5, competition: 'chill' }; + const c = { name: 'Some Other', years: 5, competition: 'chill' }; + + globalThis.orgSearchScores = new Map(); + globalThis.orgSearchScores.set('The Python Foundation', 0.1); + globalThis.orgSearchScores.set('A Pyhton Group', 0.3); + globalThis.orgSearchScores.set('Some Other', 0.9); + + // a has better score than b + assert.ok(searchComparator(a, b, 'pyhton', 'alpha') < 0); + // b has better score than c + assert.ok(searchComparator(b, c, 'pyhton', 'alpha') < 0); + + // Exact match takes precedence over score + const exact = { name: 'pyhton', years: 1, competition: 'chill' }; + globalThis.orgSearchScores.set('pyhton', 0.5); // Worse score somehow + assert.ok(searchComparator(exact, a, 'pyhton', 'alpha') < 0); + + // StartsWith takes precedence over score + const starts = { name: 'pyhton starts', years: 1, competition: 'chill' }; + globalThis.orgSearchScores.set('pyhton starts', 0.4); + assert.ok(searchComparator(starts, b, 'pyhton', 'alpha') < 0); +});